初版
This commit is contained in:
+1
-1
@@ -160,4 +160,4 @@ test/
|
|||||||
|
|
||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
*.vsix
|
*.vsix
|
||||||
|
.trae/
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
@@ -1,2 +1,5 @@
|
|||||||
# TimeTravel
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>星际旅行睡眠助手</title>
|
||||||
|
<script type="module">
|
||||||
|
if (import.meta.hot?.on) {
|
||||||
|
import.meta.hot.on('vite:error', (error) => {
|
||||||
|
if (error.err) {
|
||||||
|
console.error(
|
||||||
|
[error.err.message, error.err.frame].filter(Boolean).join('\n'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+4942
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "trae-project",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "vue-tsc -b",
|
||||||
|
"lint": "eslint . --ext .ts,.vue",
|
||||||
|
"lint:fix": "eslint . --ext .ts,.vue --fix"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-vue-next": "^0.511.0",
|
||||||
|
"tailwind-merge": "^3.3.0",
|
||||||
|
"three": "^0.178.0",
|
||||||
|
"vue": "^3.4.15",
|
||||||
|
"vue-router": "^4.2.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.15.30",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.0.1",
|
||||||
|
"@typescript-eslint/parser": "^7.0.1",
|
||||||
|
"@vitejs/plugin-vue": "^5.0.3",
|
||||||
|
"@vue/runtime-dom": "^3.4.15",
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"autoprefixer": "^10.4.17",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-plugin-vue": "^9.20.1",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "~5.3.3",
|
||||||
|
"unplugin-vue-dev-locator": "^1.0.0",
|
||||||
|
"vite": "^5.0.12",
|
||||||
|
"vite-plugin-trae-solo-badge": "^1.0.0",
|
||||||
|
"vue-tsc": "^1.8.27"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** WARNING: DON'T EDIT THIS FILE */
|
||||||
|
/** WARNING: DON'T EDIT THIS FILE */
|
||||||
|
/** WARNING: DON'T EDIT THIS FILE */
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1779705859742" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7281" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="256"><path d="M797.184 298.282667c69.418667-30.378667 141.056 38.058667 113.834667 108.8l-207.146667 538.666666a85.333333 85.333333 0 0 1-120.533333 44.288L127.573333 741.418667c-62.293333-33.962667-58.325333-124.672 6.656-153.088L797.184 298.24z" fill="#c1ddfe" p-id="7282" data-spm-anchor-id="a313x.search_index.0.i0.3fea3a81eGl4Vj" class="selected"></path><path d="M979.072 227.626667c32.426667-68.522667-33.92-142.208-105.472-117.034667L98.858667 383.189333c-70.229333 24.704-77.354667 121.173333-11.52 155.946667L256 628.138667v228.992c0 69.589333 78.848 109.909333 135.253333 69.12l181.418667-130.986667 31.146667 16.469333a85.333333 85.333333 0 0 0 116.992-38.912l258.261333-545.194666z m-335.36 508.629333l-251.434667-132.693333 474.965334-339.2-223.573334 471.893333z m-335.829333-177.237333L127.146667 463.658667l619.178666-217.813334-438.485333 313.173334zM341.333333 678.4l139.904 77.738667-139.946666 100.992V678.4z" fill="#0a5ab7" p-id="7283" data-spm-anchor-id="a313x.search_index.0.i1.3fea3a81eGl4Vj" class=""></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<div>empty</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { ref, watchEffect, onMounted, computed } from 'vue'
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const theme = ref<Theme>('light')
|
||||||
|
|
||||||
|
const getPreferredTheme = (): Theme => {
|
||||||
|
const saved = localStorage.getItem('theme') as Theme | null
|
||||||
|
if (saved === 'light' || saved === 'dark') return saved
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyTheme = (t: Theme) => {
|
||||||
|
document.documentElement.classList.remove('light', 'dark')
|
||||||
|
document.documentElement.classList.add(t)
|
||||||
|
localStorage.setItem('theme', t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
theme.value = theme.value === 'light' ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
theme.value = getPreferredTheme()
|
||||||
|
applyTheme(theme.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
applyTheme(theme.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
toggleTheme,
|
||||||
|
isDark: computed(() => theme.value === 'dark'),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Public audio resource mapping used by the future control bar.
|
||||||
|
export const BGM_MAP = {
|
||||||
|
'星海低语': '/audio/bgm.mp3',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type BgmName = keyof typeof BGM_MAP
|
||||||
|
|
||||||
|
export const DEFAULT_BGM_NAME: BgmName = '星海低语'
|
||||||
|
export const DEFAULT_BGM_VOLUME = 0.6
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
export const APP_CONFIG = {
|
||||||
|
// Rendering and canvas constraints.
|
||||||
|
scene: {
|
||||||
|
background: '#030712',
|
||||||
|
maxPixelRatio: 1.75,
|
||||||
|
},
|
||||||
|
// Default perspective camera setup.
|
||||||
|
camera: {
|
||||||
|
fov: 60,
|
||||||
|
near: 0.1,
|
||||||
|
far: 260,
|
||||||
|
initialZ: 10.5,
|
||||||
|
},
|
||||||
|
// Shared motion timing and presets for the travel sequence.
|
||||||
|
animation: {
|
||||||
|
speedMin: 0.1,
|
||||||
|
speedMax: 3,
|
||||||
|
defaultSpeed: 1,
|
||||||
|
speedPresets: {
|
||||||
|
slow: 0.55,
|
||||||
|
steady: 1,
|
||||||
|
deep: 1.65,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Starfield behavior tuned for center-area spawning and parallax depth.
|
||||||
|
starfield: {
|
||||||
|
count: 1400,
|
||||||
|
sourceRadius: 7.5,
|
||||||
|
farZ: -110,
|
||||||
|
resetZ: 10,
|
||||||
|
boundsX: 72,
|
||||||
|
boundsY: 46,
|
||||||
|
radialExponent: 0.55,
|
||||||
|
progressExponent: 1.22,
|
||||||
|
seedProgressExponent: 0.72,
|
||||||
|
baseSizeMin: 1.3,
|
||||||
|
baseSizeMax: 3.6,
|
||||||
|
baseAlphaMin: 0.62,
|
||||||
|
baseAlphaMax: 1,
|
||||||
|
pointScale: 52,
|
||||||
|
pointMinSize: 2.8,
|
||||||
|
pointMaxSize: 76,
|
||||||
|
accelerationXY: 0.22,
|
||||||
|
accelerationZ: 0.1,
|
||||||
|
nearFieldBoost: 1.8,
|
||||||
|
edgeBoost: 7.2,
|
||||||
|
edgeBurst: 3.4,
|
||||||
|
trailStartProgress: 0.58,
|
||||||
|
trailStrength: 1,
|
||||||
|
trailLength: 0.38,
|
||||||
|
trailWidth: 0.16,
|
||||||
|
},
|
||||||
|
// Smoke layers create volumetric light fog passing through the camera.
|
||||||
|
smoke: {
|
||||||
|
count: 18,
|
||||||
|
sourceRadius: 10,
|
||||||
|
farZ: -120,
|
||||||
|
resetZ: 9,
|
||||||
|
boundsX: 42,
|
||||||
|
boundsY: 30,
|
||||||
|
},
|
||||||
|
// Pointer-follow intensity and easing.
|
||||||
|
interaction: {
|
||||||
|
pointerSmoothing: 0.08,
|
||||||
|
maxOffset: 0.4,
|
||||||
|
},
|
||||||
|
// Shared UI timings used by overlays and toolbars.
|
||||||
|
ui: {
|
||||||
|
overlayFadeMs: 320,
|
||||||
|
controlsAutoHideMs: 3000,
|
||||||
|
},
|
||||||
|
} as const
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { BGM_MAP, DEFAULT_BGM_NAME, DEFAULT_BGM_VOLUME, type BgmName } from '@/constants/bgm'
|
||||||
|
import { APP_CONFIG } from '@/constants/config'
|
||||||
|
import {
|
||||||
|
createTravelScene as createTravelSceneRenderer,
|
||||||
|
type PointerTarget,
|
||||||
|
} from '@/utils/createTravelScene'
|
||||||
|
|
||||||
|
export type TravelSpeedPreset = 'slow' | 'steady' | 'deep'
|
||||||
|
|
||||||
|
export type TravelSceneState = {
|
||||||
|
controlsVisible: boolean
|
||||||
|
currentTrack: BgmName
|
||||||
|
errorMessage: string
|
||||||
|
speedPreset: TravelSpeedPreset
|
||||||
|
started: boolean
|
||||||
|
starting: boolean
|
||||||
|
volume: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TravelSceneController = {
|
||||||
|
destroy: () => void
|
||||||
|
getState: () => TravelSceneState
|
||||||
|
markActivity: () => void
|
||||||
|
setPointerTarget: (pointer: PointerTarget) => void
|
||||||
|
setSpeedPreset: (speedPreset: TravelSpeedPreset) => void
|
||||||
|
setTrack: (trackName: BgmName) => Promise<void>
|
||||||
|
setVolume: (volume: number) => void
|
||||||
|
start: () => Promise<void>
|
||||||
|
subscribe: (listener: (state: TravelSceneState) => void) => () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_STATE: TravelSceneState = {
|
||||||
|
controlsVisible: false,
|
||||||
|
currentTrack: DEFAULT_BGM_NAME,
|
||||||
|
errorMessage: '',
|
||||||
|
speedPreset: 'steady',
|
||||||
|
started: false,
|
||||||
|
starting: false,
|
||||||
|
volume: DEFAULT_BGM_VOLUME,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVITY_EVENTS: Array<keyof WindowEventMap> = [
|
||||||
|
'keydown',
|
||||||
|
'pointerdown',
|
||||||
|
'pointermove',
|
||||||
|
'touchmove',
|
||||||
|
'touchstart',
|
||||||
|
'wheel',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function createTravelScene(container: HTMLElement): TravelSceneController {
|
||||||
|
let state = { ...INITIAL_STATE }
|
||||||
|
let sceneRenderer: ReturnType<typeof createTravelSceneRenderer> | null = null
|
||||||
|
let audio: HTMLAudioElement | null = null
|
||||||
|
let hideControlsTimer = 0
|
||||||
|
let eventsBound = false
|
||||||
|
|
||||||
|
const listeners = new Set<(state: TravelSceneState) => void>()
|
||||||
|
const cleanupCallbacks: Array<() => void> = []
|
||||||
|
|
||||||
|
const emit = () => {
|
||||||
|
const snapshot = { ...state }
|
||||||
|
listeners.forEach((listener) => listener(snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchState = (partialState: Partial<TravelSceneState>) => {
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
...partialState,
|
||||||
|
}
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearHideControlsTimer = () => {
|
||||||
|
if (hideControlsTimer) {
|
||||||
|
window.clearTimeout(hideControlsTimer)
|
||||||
|
hideControlsTimer = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleAutoHide = () => {
|
||||||
|
clearHideControlsTimer()
|
||||||
|
|
||||||
|
if (!state.started) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hideControlsTimer = window.setTimeout(() => {
|
||||||
|
patchState({ controlsVisible: false })
|
||||||
|
}, APP_CONFIG.ui.controlsAutoHideMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureAudio = () => {
|
||||||
|
if (audio) {
|
||||||
|
return audio
|
||||||
|
}
|
||||||
|
|
||||||
|
audio = new Audio(BGM_MAP[state.currentTrack])
|
||||||
|
audio.loop = true
|
||||||
|
audio.preload = 'none'
|
||||||
|
audio.volume = state.volume
|
||||||
|
return audio
|
||||||
|
}
|
||||||
|
|
||||||
|
const playCurrentTrack = async () => {
|
||||||
|
const audioElement = ensureAudio()
|
||||||
|
const nextTrackSrc = BGM_MAP[state.currentTrack]
|
||||||
|
|
||||||
|
if (audioElement.src !== new URL(nextTrackSrc, window.location.origin).href) {
|
||||||
|
audioElement.pause()
|
||||||
|
audioElement.src = nextTrackSrc
|
||||||
|
audioElement.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
audioElement.loop = true
|
||||||
|
audioElement.volume = state.volume
|
||||||
|
|
||||||
|
try {
|
||||||
|
await audioElement.play()
|
||||||
|
patchState({ errorMessage: '' })
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '音频播放失败'
|
||||||
|
patchState({ errorMessage: `BGM 播放失败:${message}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindActivityEvents = () => {
|
||||||
|
if (eventsBound) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
eventsBound = true
|
||||||
|
|
||||||
|
const onActivity = () => {
|
||||||
|
if (!state.started) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
patchState({ controlsVisible: true })
|
||||||
|
scheduleAutoHide()
|
||||||
|
}
|
||||||
|
|
||||||
|
ACTIVITY_EVENTS.forEach((eventName) => {
|
||||||
|
window.addEventListener(eventName, onActivity, { passive: true })
|
||||||
|
cleanupCallbacks.push(() => {
|
||||||
|
window.removeEventListener(eventName, onActivity)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
clearHideControlsTimer()
|
||||||
|
cleanupCallbacks.splice(0).forEach((callback) => callback())
|
||||||
|
|
||||||
|
if (audio) {
|
||||||
|
audio.pause()
|
||||||
|
audio.src = ''
|
||||||
|
audio.load()
|
||||||
|
audio = null
|
||||||
|
}
|
||||||
|
|
||||||
|
sceneRenderer?.dispose()
|
||||||
|
sceneRenderer = null
|
||||||
|
},
|
||||||
|
getState() {
|
||||||
|
return { ...state }
|
||||||
|
},
|
||||||
|
markActivity() {
|
||||||
|
if (!state.started) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
patchState({ controlsVisible: true })
|
||||||
|
scheduleAutoHide()
|
||||||
|
},
|
||||||
|
setPointerTarget(pointer) {
|
||||||
|
sceneRenderer?.setPointerTarget(pointer)
|
||||||
|
},
|
||||||
|
setSpeedPreset(speedPreset) {
|
||||||
|
sceneRenderer?.setSpeed(APP_CONFIG.animation.speedPresets[speedPreset])
|
||||||
|
patchState({ speedPreset })
|
||||||
|
scheduleAutoHide()
|
||||||
|
},
|
||||||
|
async setTrack(trackName) {
|
||||||
|
patchState({ currentTrack: trackName })
|
||||||
|
|
||||||
|
if (state.started) {
|
||||||
|
await playCurrentTrack()
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleAutoHide()
|
||||||
|
},
|
||||||
|
setVolume(volume) {
|
||||||
|
const nextVolume = Math.min(1, Math.max(0, volume))
|
||||||
|
ensureAudio().volume = nextVolume
|
||||||
|
patchState({ volume: nextVolume })
|
||||||
|
scheduleAutoHide()
|
||||||
|
},
|
||||||
|
async start() {
|
||||||
|
if (state.started || state.starting) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
patchState({ starting: true })
|
||||||
|
|
||||||
|
if (!sceneRenderer) {
|
||||||
|
sceneRenderer = createTravelSceneRenderer(container)
|
||||||
|
}
|
||||||
|
|
||||||
|
bindActivityEvents()
|
||||||
|
sceneRenderer.setSpeed(APP_CONFIG.animation.speedPresets[state.speedPreset])
|
||||||
|
sceneRenderer.setStarted(true)
|
||||||
|
patchState({
|
||||||
|
controlsVisible: true,
|
||||||
|
started: true,
|
||||||
|
starting: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await playCurrentTrack()
|
||||||
|
scheduleAutoHide()
|
||||||
|
},
|
||||||
|
subscribe(listener) {
|
||||||
|
listeners.add(listener)
|
||||||
|
listener({ ...state })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import './styles/base.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
BGM_MAP,
|
||||||
|
DEFAULT_BGM_NAME,
|
||||||
|
DEFAULT_BGM_VOLUME,
|
||||||
|
type BgmName,
|
||||||
|
} from '@/constants/bgm'
|
||||||
|
import {
|
||||||
|
createTravelScene,
|
||||||
|
type TravelSceneController,
|
||||||
|
type TravelSceneState,
|
||||||
|
type TravelSpeedPreset,
|
||||||
|
} from '@/controllers/createTravelScene'
|
||||||
|
|
||||||
|
type FullscreenHostElement = HTMLElement & {
|
||||||
|
mozRequestFullScreen?: () => Promise<void> | void
|
||||||
|
msRequestFullscreen?: () => Promise<void> | void
|
||||||
|
webkitRequestFullscreen?: () => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
type FullscreenCapableDocument = Document & {
|
||||||
|
mozCancelFullScreen?: () => Promise<void> | void
|
||||||
|
mozFullScreenElement?: Element | null
|
||||||
|
mozFullScreenEnabled?: boolean
|
||||||
|
msExitFullscreen?: () => Promise<void> | void
|
||||||
|
msFullscreenElement?: Element | null
|
||||||
|
msFullscreenEnabled?: boolean
|
||||||
|
webkitExitFullscreen?: () => Promise<void> | void
|
||||||
|
webkitFullscreenElement?: Element | null
|
||||||
|
webkitFullscreenEnabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageRoot = ref<HTMLElement | null>(null)
|
||||||
|
const sceneHost = ref<HTMLElement | null>(null)
|
||||||
|
const isStarting = ref(false)
|
||||||
|
const started = ref(false)
|
||||||
|
const controlsVisible = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const fullscreenAvailable = ref(false)
|
||||||
|
const isFullscreen = ref(false)
|
||||||
|
const currentTrack = ref<BgmName>(DEFAULT_BGM_NAME)
|
||||||
|
const volume = ref(DEFAULT_BGM_VOLUME)
|
||||||
|
const speedPreset = ref<TravelSpeedPreset>('steady')
|
||||||
|
|
||||||
|
let controller: TravelSceneController | null = null
|
||||||
|
let unsubscribe: (() => void) | null = null
|
||||||
|
|
||||||
|
const speedOptions: Array<{ label: string, value: TravelSpeedPreset }> = [
|
||||||
|
{ label: '慢速', value: 'slow' },
|
||||||
|
{ label: '标准', value: 'steady' },
|
||||||
|
{ label: '深眠', value: 'deep' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const trackOptions = Object.keys(BGM_MAP) as BgmName[]
|
||||||
|
|
||||||
|
const volumePercent = computed(() => `${Math.round(volume.value * 100)}%`)
|
||||||
|
const fullscreenButtonLabel = computed(() => {
|
||||||
|
if (!fullscreenAvailable.value) {
|
||||||
|
return '当前设备不支持全屏'
|
||||||
|
}
|
||||||
|
|
||||||
|
return isFullscreen.value ? '退出全屏' : '全屏体验'
|
||||||
|
})
|
||||||
|
|
||||||
|
const syncState = (state: TravelSceneState) => {
|
||||||
|
controlsVisible.value = state.controlsVisible
|
||||||
|
currentTrack.value = state.currentTrack
|
||||||
|
errorMessage.value = state.errorMessage
|
||||||
|
isStarting.value = state.starting
|
||||||
|
speedPreset.value = state.speedPreset
|
||||||
|
started.value = state.started
|
||||||
|
volume.value = state.volume
|
||||||
|
}
|
||||||
|
|
||||||
|
const markActivity = () => {
|
||||||
|
controller?.markActivity()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStart = async () => {
|
||||||
|
if (!controller || isStarting.value || started.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await controller.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTrackChange = async (event: Event) => {
|
||||||
|
const nextTrack = (event.target as HTMLSelectElement).value as BgmName
|
||||||
|
await controller?.setTrack(nextTrack)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVolumeChange = (event: Event) => {
|
||||||
|
const nextVolume = Number((event.target as HTMLInputElement).value)
|
||||||
|
controller?.setVolume(nextVolume)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSpeedChange = (nextPreset: TravelSpeedPreset) => {
|
||||||
|
controller?.setSpeedPreset(nextPreset)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFullscreenDocument = () => document as FullscreenCapableDocument
|
||||||
|
|
||||||
|
const getFullscreenElement = () => {
|
||||||
|
const fullscreenDocument = getFullscreenDocument()
|
||||||
|
return fullscreenDocument.fullscreenElement
|
||||||
|
?? fullscreenDocument.webkitFullscreenElement
|
||||||
|
?? fullscreenDocument.mozFullScreenElement
|
||||||
|
?? fullscreenDocument.msFullscreenElement
|
||||||
|
?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncFullscreenState = () => {
|
||||||
|
isFullscreen.value = Boolean(getFullscreenElement())
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncFullscreenAvailability = () => {
|
||||||
|
const fullscreenDocument = getFullscreenDocument()
|
||||||
|
const fullscreenHost = pageRoot.value as FullscreenHostElement | null
|
||||||
|
fullscreenAvailable.value = Boolean(
|
||||||
|
fullscreenDocument.fullscreenEnabled
|
||||||
|
?? fullscreenDocument.webkitFullscreenEnabled
|
||||||
|
?? fullscreenDocument.mozFullScreenEnabled
|
||||||
|
?? fullscreenDocument.msFullscreenEnabled
|
||||||
|
?? fullscreenHost?.requestFullscreen
|
||||||
|
?? fullscreenHost?.webkitRequestFullscreen
|
||||||
|
?? fullscreenHost?.mozRequestFullScreen
|
||||||
|
?? fullscreenHost?.msRequestFullscreen,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const enterFullscreen = async () => {
|
||||||
|
const fullscreenHost = pageRoot.value as FullscreenHostElement | null
|
||||||
|
if (!fullscreenHost) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullscreenHost.requestFullscreen) {
|
||||||
|
await fullscreenHost.requestFullscreen({ navigationUI: 'hide' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullscreenHost.webkitRequestFullscreen) {
|
||||||
|
await fullscreenHost.webkitRequestFullscreen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullscreenHost.mozRequestFullScreen) {
|
||||||
|
await fullscreenHost.mozRequestFullScreen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullscreenHost.msRequestFullscreen) {
|
||||||
|
await fullscreenHost.msRequestFullscreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitFullscreen = async () => {
|
||||||
|
const fullscreenDocument = getFullscreenDocument()
|
||||||
|
if (fullscreenDocument.exitFullscreen) {
|
||||||
|
await fullscreenDocument.exitFullscreen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullscreenDocument.webkitExitFullscreen) {
|
||||||
|
await fullscreenDocument.webkitExitFullscreen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullscreenDocument.mozCancelFullScreen) {
|
||||||
|
await fullscreenDocument.mozCancelFullScreen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullscreenDocument.msExitFullscreen) {
|
||||||
|
await fullscreenDocument.msExitFullscreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFullscreenToggle = async () => {
|
||||||
|
markActivity()
|
||||||
|
if (!fullscreenAvailable.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getFullscreenElement()) {
|
||||||
|
await exitFullscreen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await enterFullscreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePointerFromClient = (clientX: number, clientY: number) => {
|
||||||
|
if (!sceneHost.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = sceneHost.value.getBoundingClientRect()
|
||||||
|
const width = rect.width || 1
|
||||||
|
const height = rect.height || 1
|
||||||
|
const x = ((clientX - rect.left) / width) * 2 - 1
|
||||||
|
const y = ((clientY - rect.top) / height) * 2 - 1
|
||||||
|
|
||||||
|
controller?.setPointerTarget({ x, y: -y })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
|
markActivity()
|
||||||
|
updatePointerFromClient(event.clientX, event.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerLeave = () => {
|
||||||
|
controller?.setPointerTarget({ x: 0, y: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchMove = (event: TouchEvent) => {
|
||||||
|
markActivity()
|
||||||
|
const touch = event.touches[0]
|
||||||
|
if (!touch) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePointerFromClient(touch.clientX, touch.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
controller?.setPointerTarget({ x: 0, y: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!sceneHost.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncFullscreenAvailability()
|
||||||
|
syncFullscreenState()
|
||||||
|
document.addEventListener('fullscreenchange', syncFullscreenState)
|
||||||
|
document.addEventListener('webkitfullscreenchange', syncFullscreenState)
|
||||||
|
document.addEventListener('mozfullscreenchange', syncFullscreenState)
|
||||||
|
document.addEventListener('MSFullscreenChange', syncFullscreenState)
|
||||||
|
|
||||||
|
controller = createTravelScene(sceneHost.value)
|
||||||
|
unsubscribe = controller.subscribe(syncState)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('fullscreenchange', syncFullscreenState)
|
||||||
|
document.removeEventListener('webkitfullscreenchange', syncFullscreenState)
|
||||||
|
document.removeEventListener('mozfullscreenchange', syncFullscreenState)
|
||||||
|
document.removeEventListener('MSFullscreenChange', syncFullscreenState)
|
||||||
|
unsubscribe?.()
|
||||||
|
unsubscribe = null
|
||||||
|
controller?.destroy()
|
||||||
|
controller = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main
|
||||||
|
ref="pageRoot"
|
||||||
|
class="travel-page"
|
||||||
|
@pointermove="handlePointerMove"
|
||||||
|
@pointerleave="handlePointerLeave"
|
||||||
|
@pointerdown="markActivity"
|
||||||
|
@touchstart.passive="markActivity"
|
||||||
|
@touchmove.passive="handleTouchMove"
|
||||||
|
@touchend.passive="handleTouchEnd"
|
||||||
|
@touchcancel.passive="handleTouchEnd"
|
||||||
|
>
|
||||||
|
<div ref="sceneHost" class="scene-layer" aria-label="Three.js 场景容器" />
|
||||||
|
<div class="scene-vignette" aria-hidden="true" />
|
||||||
|
|
||||||
|
<transition name="overlay-fade">
|
||||||
|
<section v-if="!started" class="start-overlay">
|
||||||
|
<div class="hero-card">
|
||||||
|
<p class="hero-kicker">Sleep Space Travel</p>
|
||||||
|
<h1 class="hero-title">星际旅行睡眠助手</h1>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="start-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="isStarting"
|
||||||
|
@click="handleStart"
|
||||||
|
>
|
||||||
|
{{ isStarting ? '准备航线中...' : '开始旅行' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="hero-description">
|
||||||
|
点击开始后进入旅行状态,随我穿越星海,坠入梦乡。
|
||||||
|
</p>
|
||||||
|
<p v-if="errorMessage" class="error-text">{{ errorMessage }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<section class="hud-layer" :class="{ 'hud-layer--active': started }">
|
||||||
|
<transition name="controls-fade">
|
||||||
|
<div
|
||||||
|
v-if="started && controlsVisible"
|
||||||
|
class="glass-toolbar"
|
||||||
|
@pointerdown.stop="markActivity"
|
||||||
|
@pointermove.stop="markActivity"
|
||||||
|
@touchstart.stop.passive="markActivity"
|
||||||
|
>
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<span class="toolbar-label">速度</span>
|
||||||
|
<div class="segment-control" role="group" aria-label="旅行速度">
|
||||||
|
<button
|
||||||
|
v-for="option in speedOptions"
|
||||||
|
:key="option.value"
|
||||||
|
class="segment-button"
|
||||||
|
:class="{ 'segment-button--active': option.value === speedPreset }"
|
||||||
|
type="button"
|
||||||
|
@click="handleSpeedChange(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="toolbar-group toolbar-group--stretch">
|
||||||
|
<span class="toolbar-label">BGM</span>
|
||||||
|
<select class="glass-select" :value="currentTrack" @change="handleTrackChange">
|
||||||
|
<option v-for="trackName in trackOptions" :key="trackName" :value="trackName">
|
||||||
|
{{ trackName }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="toolbar-group toolbar-group--stretch">
|
||||||
|
<span class="toolbar-label">音量 {{ volumePercent }}</span>
|
||||||
|
<input
|
||||||
|
class="glass-range"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
:value="volume"
|
||||||
|
@input="handleVolumeChange"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="toolbar-group toolbar-group--compact">
|
||||||
|
<span class="toolbar-label">显示</span>
|
||||||
|
<button
|
||||||
|
class="toolbar-action"
|
||||||
|
:class="{ 'toolbar-action--active': isFullscreen }"
|
||||||
|
type="button"
|
||||||
|
:disabled="!fullscreenAvailable"
|
||||||
|
@click="handleFullscreenToggle"
|
||||||
|
>
|
||||||
|
{{ fullscreenButtonLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import HomePage from '@/pages/HomePage.vue'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
component: HomePage,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica,
|
||||||
|
Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family:
|
||||||
|
'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
|
--bg-primary: #030712;
|
||||||
|
--bg-secondary: rgba(7, 15, 30, 0.72);
|
||||||
|
--border-soft: rgba(148, 163, 184, 0.24);
|
||||||
|
--text-primary: #e2e8f0;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--accent: #38bdf8;
|
||||||
|
--accent-strong: #22d3ee;
|
||||||
|
--shadow-soft: 0 24px 80px rgba(2, 6, 23, 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(14, 165, 233, 0.18), transparent 32%),
|
||||||
|
linear-gradient(180deg, #020617 0%, #030712 45%, #02030a 100%);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.travel-page {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: radial-gradient(circle at top, rgba(56, 189, 248, 0.08), transparent 34%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-layer canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-vignette {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at center, transparent 0%, rgba(2, 6, 23, 0.08) 48%, rgba(2, 6, 23, 0.54) 100%),
|
||||||
|
linear-gradient(180deg, rgba(2, 6, 23, 0.16) 0%, rgba(2, 6, 23, 0.48) 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-overlay {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
width: min(640px, 100%);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 32px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(9, 16, 34, 0.8), rgba(8, 15, 29, 0.62)),
|
||||||
|
rgba(7, 15, 30, 0.6);
|
||||||
|
backdrop-filter: blur(22px);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-kicker {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.28em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(42px, 8vw, 72px);
|
||||||
|
line-height: 0.95;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-description {
|
||||||
|
margin: 18px auto 0;
|
||||||
|
max-width: 34rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-button {
|
||||||
|
min-width: 220px;
|
||||||
|
margin-top: 28px;
|
||||||
|
border: 1px solid rgba(125, 211, 252, 0.45);
|
||||||
|
border-radius: 999px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(34, 211, 238, 0.92), rgba(59, 130, 246, 0.88));
|
||||||
|
box-shadow: 0 18px 48px rgba(14, 165, 233, 0.24);
|
||||||
|
color: #e0f2fe;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 15px 26px;
|
||||||
|
transition:
|
||||||
|
transform 160ms ease,
|
||||||
|
box-shadow 160ms ease,
|
||||||
|
opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-button:hover:not(:disabled) {
|
||||||
|
box-shadow: 0 22px 52px rgba(14, 165, 233, 0.32);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-button:disabled {
|
||||||
|
cursor: progress;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
margin: 14px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
color: #fda4af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-layer--active {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
width: min(860px, calc(100% - 24px));
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||||
|
border-radius: 28px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(15, 23, 42, 0.74), rgba(15, 23, 42, 0.58));
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
box-shadow: 0 18px 56px rgba(2, 6, 23, 0.48);
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-group {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-group--stretch {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-group--compact {
|
||||||
|
flex: 0 0 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-label {
|
||||||
|
color: rgba(226, 232, 240, 0.82);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-control {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(2, 6, 23, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(226, 232, 240, 0.78);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 10px 16px;
|
||||||
|
transition:
|
||||||
|
background 160ms ease,
|
||||||
|
color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-button--active {
|
||||||
|
background: rgba(56, 189, 248, 0.16);
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-action {
|
||||||
|
min-height: 46px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(2, 6, 23, 0.36);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 16px;
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
background 160ms ease,
|
||||||
|
color 160ms ease,
|
||||||
|
opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-action:hover:not(:disabled) {
|
||||||
|
border-color: rgba(125, 211, 252, 0.32);
|
||||||
|
background: rgba(15, 23, 42, 0.56);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-action--active {
|
||||||
|
border-color: rgba(56, 189, 248, 0.28);
|
||||||
|
background: rgba(56, 189, 248, 0.14);
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-action:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-select,
|
||||||
|
.glass-range,
|
||||||
|
.toolbar-action {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-select {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(2, 6, 23, 0.36);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 46px;
|
||||||
|
outline: none;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-range {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-fade-enter-active,
|
||||||
|
.overlay-fade-leave-active,
|
||||||
|
.controls-fade-enter-active,
|
||||||
|
.controls-fade-leave-active {
|
||||||
|
transition:
|
||||||
|
opacity 320ms ease,
|
||||||
|
transform 320ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-fade-enter-from,
|
||||||
|
.overlay-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-fade-enter-from,
|
||||||
|
.controls-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.start-overlay {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: clamp(36px, 12vw, 54px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-description {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-toolbar {
|
||||||
|
bottom: 16px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 14px;
|
||||||
|
width: min(520px, calc(100% - 16px));
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-control {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-group--stretch {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-group--compact {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { createTravelScene } from '@/utils/createTravelScene'
|
||||||
|
|
||||||
|
export function createScene(container: HTMLElement) {
|
||||||
|
const controller = createTravelScene(container)
|
||||||
|
|
||||||
|
const updatePointer = (clientX: number, clientY: number) => {
|
||||||
|
const rect = container.getBoundingClientRect()
|
||||||
|
const width = rect.width || 1
|
||||||
|
const height = rect.height || 1
|
||||||
|
const normalizedX = ((clientX - rect.left) / width) * 2 - 1
|
||||||
|
const normalizedY = ((clientY - rect.top) / height) * 2 - 1
|
||||||
|
|
||||||
|
controller.setPointerTarget({
|
||||||
|
x: normalizedX,
|
||||||
|
y: -normalizedY,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetPointer = () => {
|
||||||
|
controller.setPointerTarget({ x: 0, y: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMouseMove = (event: MouseEvent) => {
|
||||||
|
updatePointer(event.clientX, event.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMouseLeave = () => {
|
||||||
|
resetPointer()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTouchStart = (event: TouchEvent) => {
|
||||||
|
const touch = event.touches[0]
|
||||||
|
if (!touch) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePointer(touch.clientX, touch.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTouchMove = (event: TouchEvent) => {
|
||||||
|
const touch = event.touches[0]
|
||||||
|
if (!touch) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePointer(touch.clientX, touch.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTouchEnd = () => {
|
||||||
|
resetPointer()
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.setStarted(true)
|
||||||
|
controller.setSpeed(1)
|
||||||
|
|
||||||
|
container.addEventListener('mousemove', onMouseMove)
|
||||||
|
container.addEventListener('mouseleave', onMouseLeave)
|
||||||
|
container.addEventListener('touchstart', onTouchStart, { passive: true })
|
||||||
|
container.addEventListener('touchmove', onTouchMove, { passive: true })
|
||||||
|
container.addEventListener('touchend', onTouchEnd, { passive: true })
|
||||||
|
container.addEventListener('touchcancel', onTouchEnd, { passive: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('mousemove', onMouseMove)
|
||||||
|
container.removeEventListener('mouseleave', onMouseLeave)
|
||||||
|
container.removeEventListener('touchstart', onTouchStart)
|
||||||
|
container.removeEventListener('touchmove', onTouchMove)
|
||||||
|
container.removeEventListener('touchend', onTouchEnd)
|
||||||
|
container.removeEventListener('touchcancel', onTouchEnd)
|
||||||
|
controller.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
import * as THREE from 'three'
|
||||||
|
|
||||||
|
import { APP_CONFIG } from '@/constants/config'
|
||||||
|
|
||||||
|
type PointerTarget = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type TravelSceneController = {
|
||||||
|
setStarted: (started: boolean) => void
|
||||||
|
setSpeed: (speed: number) => void
|
||||||
|
setPointerTarget: (pointer: PointerTarget) => void
|
||||||
|
dispose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type StarState = {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
z: number
|
||||||
|
vx: number
|
||||||
|
vy: number
|
||||||
|
vz: number
|
||||||
|
ax: number
|
||||||
|
ay: number
|
||||||
|
az: number
|
||||||
|
delay: number
|
||||||
|
baseSize: number
|
||||||
|
baseAlpha: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type SmokeState = {
|
||||||
|
mesh: THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
z: number
|
||||||
|
age: number
|
||||||
|
vx: number
|
||||||
|
vy: number
|
||||||
|
vz: number
|
||||||
|
rotation: number
|
||||||
|
spin: number
|
||||||
|
baseScaleX: number
|
||||||
|
baseScaleY: number
|
||||||
|
opacity: number
|
||||||
|
wobble: number
|
||||||
|
wobbleSpeed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAR_COUNT = APP_CONFIG.starfield.count
|
||||||
|
const STAR_SOURCE_RADIUS = APP_CONFIG.starfield.sourceRadius
|
||||||
|
const STAR_FAR_Z = APP_CONFIG.starfield.farZ
|
||||||
|
const STAR_RESET_Z = APP_CONFIG.starfield.resetZ
|
||||||
|
const STAR_BOUNDS_X = APP_CONFIG.starfield.boundsX
|
||||||
|
const STAR_BOUNDS_Y = APP_CONFIG.starfield.boundsY
|
||||||
|
|
||||||
|
const SMOKE_COUNT = APP_CONFIG.smoke.count
|
||||||
|
const SMOKE_SOURCE_RADIUS = APP_CONFIG.smoke.sourceRadius
|
||||||
|
const SMOKE_FAR_Z = APP_CONFIG.smoke.farZ
|
||||||
|
const SMOKE_RESET_Z = APP_CONFIG.smoke.resetZ
|
||||||
|
const SMOKE_BOUNDS_X = APP_CONFIG.smoke.boundsX
|
||||||
|
const SMOKE_BOUNDS_Y = APP_CONFIG.smoke.boundsY
|
||||||
|
|
||||||
|
const STAR_COLORS = ['#ffffff', '#f3f8ff', '#e8f4ff', '#d7eeff', '#fff7ea'] as const
|
||||||
|
const SMOKE_COLORS = ['#8dd8ff', '#adc4ff', '#d6b5ff', '#ffe7ff'] as const
|
||||||
|
|
||||||
|
const STAR_VERTEX_SHADER = `
|
||||||
|
attribute float aSizeBase;
|
||||||
|
attribute float aAlphaBase;
|
||||||
|
attribute float aProgress;
|
||||||
|
attribute float aTail;
|
||||||
|
attribute vec2 aDir;
|
||||||
|
varying vec3 vColor;
|
||||||
|
varying float vAlpha;
|
||||||
|
varying float vTail;
|
||||||
|
varying vec2 vDir;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
|
||||||
|
float scaledProgress = max(aProgress, 0.0);
|
||||||
|
float pointSize = aSizeBase * (0.95 + scaledProgress * 2.85) * ${APP_CONFIG.starfield.pointScale.toFixed(1)} / max(1.0, -mvPosition.z);
|
||||||
|
gl_Position = projectionMatrix * mvPosition;
|
||||||
|
gl_PointSize = clamp(pointSize, ${APP_CONFIG.starfield.pointMinSize.toFixed(1)}, ${APP_CONFIG.starfield.pointMaxSize.toFixed(1)});
|
||||||
|
vColor = color;
|
||||||
|
vAlpha = aAlphaBase * (0.32 + scaledProgress * 0.68);
|
||||||
|
vTail = aTail;
|
||||||
|
vDir = aDir;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const STAR_FRAGMENT_SHADER = `
|
||||||
|
varying vec3 vColor;
|
||||||
|
varying float vAlpha;
|
||||||
|
varying float vTail;
|
||||||
|
varying vec2 vDir;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec2 centered = gl_PointCoord - vec2(0.5);
|
||||||
|
float distanceToCenter = length(centered);
|
||||||
|
float core = 1.0 - smoothstep(0.0, 0.22, distanceToCenter);
|
||||||
|
float halo = 1.0 - smoothstep(0.12, 0.5, distanceToCenter);
|
||||||
|
vec2 dir = length(vDir) > 0.0001 ? normalize(vDir) : vec2(0.0, 1.0);
|
||||||
|
vec2 side = vec2(-dir.y, dir.x);
|
||||||
|
float along = dot(centered, dir);
|
||||||
|
float across = dot(centered, side);
|
||||||
|
float tailLong = 1.0 - smoothstep(-${(0.18 + APP_CONFIG.starfield.trailLength).toFixed(2)}, 0.05, along);
|
||||||
|
float tailThin = 1.0 - smoothstep(${(APP_CONFIG.starfield.trailWidth * 0.35).toFixed(2)}, ${(APP_CONFIG.starfield.trailWidth).toFixed(2)}, abs(across));
|
||||||
|
float tailFade = smoothstep(0.01, 0.52, -along);
|
||||||
|
float tail = tailLong * tailThin * tailFade * vTail;
|
||||||
|
float alpha = (core * 0.95 + halo * 0.45 + tail * 0.85) * vAlpha;
|
||||||
|
|
||||||
|
if (alpha < 0.01) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
|
||||||
|
gl_FragColor = vec4(vColor, alpha);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(start: number, end: number, alpha: number) {
|
||||||
|
return start + (end - start) * alpha
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothstep(edge0: number, edge1: number, value: number) {
|
||||||
|
const t = clamp((value - edge0) / (edge1 - edge0), 0, 1)
|
||||||
|
return t * t * (3 - 2 * t)
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomRange(min: number, max: number) {
|
||||||
|
return min + Math.random() * (max - min)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleDisk(radius: number) {
|
||||||
|
const angle = Math.random() * Math.PI * 2
|
||||||
|
const distance = Math.pow(Math.random(), APP_CONFIG.starfield.radialExponent) * radius
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.cos(angle) * distance,
|
||||||
|
y: Math.sin(angle) * distance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickTier() {
|
||||||
|
const roll = Math.random()
|
||||||
|
|
||||||
|
if (roll > 0.84) {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roll > 0.5) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSmokeTexture() {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = 256
|
||||||
|
canvas.height = 256
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Unable to create smoke texture context.')
|
||||||
|
}
|
||||||
|
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
context.globalCompositeOperation = 'screen'
|
||||||
|
|
||||||
|
for (let index = 0; index < 12; index += 1) {
|
||||||
|
const radius = randomRange(42, 94)
|
||||||
|
const x = randomRange(64, 192)
|
||||||
|
const y = randomRange(64, 192)
|
||||||
|
const gradient = context.createRadialGradient(x, y, radius * 0.12, x, y, radius)
|
||||||
|
|
||||||
|
gradient.addColorStop(0, 'rgba(255, 255, 255, 0.5)')
|
||||||
|
gradient.addColorStop(0.4, 'rgba(220, 235, 255, 0.26)')
|
||||||
|
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)')
|
||||||
|
|
||||||
|
context.fillStyle = gradient
|
||||||
|
context.beginPath()
|
||||||
|
context.arc(x, y, radius, 0, Math.PI * 2)
|
||||||
|
context.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
const texture = new THREE.CanvasTexture(canvas)
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace
|
||||||
|
texture.needsUpdate = true
|
||||||
|
|
||||||
|
return texture
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDirection(x: number, y: number) {
|
||||||
|
const length = Math.hypot(x, y)
|
||||||
|
|
||||||
|
if (length < 1e-4) {
|
||||||
|
const angle = Math.random() * Math.PI * 2
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.cos(angle),
|
||||||
|
y: Math.sin(angle),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: x / length,
|
||||||
|
y: y / length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTravelScene(container: HTMLElement): TravelSceneController {
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, APP_CONFIG.scene.maxPixelRatio))
|
||||||
|
renderer.setSize(container.clientWidth, container.clientHeight)
|
||||||
|
renderer.setClearColor(APP_CONFIG.scene.background, 1)
|
||||||
|
container.appendChild(renderer.domElement)
|
||||||
|
|
||||||
|
const scene = new THREE.Scene()
|
||||||
|
const camera = new THREE.PerspectiveCamera(
|
||||||
|
APP_CONFIG.camera.fov,
|
||||||
|
container.clientWidth / container.clientHeight,
|
||||||
|
APP_CONFIG.camera.near,
|
||||||
|
APP_CONFIG.camera.far,
|
||||||
|
)
|
||||||
|
camera.position.set(0, 0, APP_CONFIG.camera.initialZ)
|
||||||
|
|
||||||
|
const travelRoot = new THREE.Group()
|
||||||
|
scene.add(travelRoot)
|
||||||
|
|
||||||
|
const ambientLight = new THREE.AmbientLight('#d9efff', 0.45)
|
||||||
|
scene.add(ambientLight)
|
||||||
|
|
||||||
|
const starsGeometry = new THREE.BufferGeometry()
|
||||||
|
const starPositions = new Float32Array(STAR_COUNT * 3)
|
||||||
|
const starColors = new Float32Array(STAR_COUNT * 3)
|
||||||
|
const starProgress = new Float32Array(STAR_COUNT)
|
||||||
|
const starSizes = new Float32Array(STAR_COUNT)
|
||||||
|
const starAlpha = new Float32Array(STAR_COUNT)
|
||||||
|
const starTail = new Float32Array(STAR_COUNT)
|
||||||
|
const starDirections = new Float32Array(STAR_COUNT * 2)
|
||||||
|
const starStates = new Array<StarState>(STAR_COUNT)
|
||||||
|
const starColor = new THREE.Color()
|
||||||
|
|
||||||
|
const smokeGeometry = new THREE.PlaneGeometry(1, 1, 1, 1)
|
||||||
|
const smokeTexture = createSmokeTexture()
|
||||||
|
const smokeStates: SmokeState[] = []
|
||||||
|
|
||||||
|
const pointerTarget = { x: 0, y: 0 }
|
||||||
|
const pointerCurrent = { x: 0, y: 0 }
|
||||||
|
|
||||||
|
let started = false
|
||||||
|
let targetSpeed = 1
|
||||||
|
let currentSpeed = 0
|
||||||
|
let animationFrameId = 0
|
||||||
|
let disposed = false
|
||||||
|
|
||||||
|
const starMaterial = new THREE.ShaderMaterial({
|
||||||
|
vertexShader: STAR_VERTEX_SHADER,
|
||||||
|
fragmentShader: STAR_FRAGMENT_SHADER,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
vertexColors: true,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
})
|
||||||
|
|
||||||
|
function spawnStar(index: number, seededProgress?: number) {
|
||||||
|
const tier = pickTier()
|
||||||
|
const source = sampleDisk(STAR_SOURCE_RADIUS)
|
||||||
|
const direction = normalizeDirection(
|
||||||
|
source.x + randomRange(-1.3, 1.3),
|
||||||
|
source.y + randomRange(-1.3, 1.3),
|
||||||
|
)
|
||||||
|
const progress = seededProgress ?? 0
|
||||||
|
const tierSpeed = [18, 26, 36][tier]
|
||||||
|
const tierDrift = [3.2, 5.2, 8.2][tier]
|
||||||
|
const tierScale = [0.95, 1.25, 1.65][tier]
|
||||||
|
const z = lerp(STAR_FAR_Z, STAR_RESET_Z - 3, progress)
|
||||||
|
const radialTravel = Math.pow(progress, APP_CONFIG.starfield.progressExponent) * (18 + tier * 10)
|
||||||
|
|
||||||
|
const state: StarState = {
|
||||||
|
x: source.x + direction.x * radialTravel + randomRange(-0.6, 0.6) * progress,
|
||||||
|
y: source.y + direction.y * radialTravel + randomRange(-0.6, 0.6) * progress,
|
||||||
|
z,
|
||||||
|
vx: direction.x * tierDrift * (1 + progress * 0.7),
|
||||||
|
vy: direction.y * tierDrift * (1 + progress * 0.7),
|
||||||
|
vz: tierSpeed * randomRange(0.9, 1.16) * (1 + progress * 0.5),
|
||||||
|
ax: direction.x * randomRange(1.4, 3.6),
|
||||||
|
ay: direction.y * randomRange(1.4, 3.6),
|
||||||
|
az: randomRange(5.5, 10.5),
|
||||||
|
delay: seededProgress === undefined ? randomRange(0.03, 0.42) : randomRange(0, 0.12),
|
||||||
|
baseSize: randomRange(APP_CONFIG.starfield.baseSizeMin, APP_CONFIG.starfield.baseSizeMax) * tierScale,
|
||||||
|
baseAlpha: randomRange(APP_CONFIG.starfield.baseAlphaMin, APP_CONFIG.starfield.baseAlphaMax),
|
||||||
|
}
|
||||||
|
|
||||||
|
starStates[index] = state
|
||||||
|
starSizes[index] = state.baseSize
|
||||||
|
starAlpha[index] = state.baseAlpha
|
||||||
|
starTail[index] = 0
|
||||||
|
starDirections[index * 2] = direction.x
|
||||||
|
starDirections[index * 2 + 1] = direction.y
|
||||||
|
|
||||||
|
starColor.set(STAR_COLORS[Math.floor(Math.random() * STAR_COLORS.length)])
|
||||||
|
starColors[index * 3] = starColor.r
|
||||||
|
starColors[index * 3 + 1] = starColor.g
|
||||||
|
starColors[index * 3 + 2] = starColor.b
|
||||||
|
|
||||||
|
starPositions[index * 3] = state.x
|
||||||
|
starPositions[index * 3 + 1] = state.y
|
||||||
|
starPositions[index * 3 + 2] = state.z
|
||||||
|
starProgress[index] = seededProgress ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnSmoke(mesh?: SmokeState['mesh']) {
|
||||||
|
const source = sampleDisk(SMOKE_SOURCE_RADIUS)
|
||||||
|
const direction = normalizeDirection(
|
||||||
|
source.x + randomRange(-2.2, 2.2),
|
||||||
|
source.y + randomRange(-2.2, 2.2),
|
||||||
|
)
|
||||||
|
const scaleBase = randomRange(5.8, 14.5)
|
||||||
|
const material = mesh
|
||||||
|
? mesh.material
|
||||||
|
: new THREE.MeshBasicMaterial({
|
||||||
|
map: smokeTexture,
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
})
|
||||||
|
material.color.set(SMOKE_COLORS[Math.floor(Math.random() * SMOKE_COLORS.length)])
|
||||||
|
|
||||||
|
const smokeMesh =
|
||||||
|
mesh ??
|
||||||
|
new THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>(smokeGeometry, material)
|
||||||
|
|
||||||
|
const state: SmokeState = {
|
||||||
|
mesh: smokeMesh,
|
||||||
|
x: source.x,
|
||||||
|
y: source.y,
|
||||||
|
z: randomRange(SMOKE_FAR_Z, SMOKE_FAR_Z + 16),
|
||||||
|
age: 0,
|
||||||
|
vx: direction.x * randomRange(1.8, 4.6),
|
||||||
|
vy: direction.y * randomRange(1.8, 4.6),
|
||||||
|
vz: randomRange(9.5, 17.5),
|
||||||
|
rotation: randomRange(0, Math.PI * 2),
|
||||||
|
spin: randomRange(-0.16, 0.16),
|
||||||
|
baseScaleX: scaleBase * randomRange(0.8, 1.2),
|
||||||
|
baseScaleY: scaleBase * randomRange(0.75, 1.25),
|
||||||
|
opacity: randomRange(0.05, 0.13),
|
||||||
|
wobble: randomRange(0.5, 1.2),
|
||||||
|
wobbleSpeed: randomRange(0.35, 0.9),
|
||||||
|
}
|
||||||
|
|
||||||
|
smokeMesh.position.set(state.x, state.y, state.z)
|
||||||
|
smokeMesh.scale.set(state.baseScaleX, state.baseScaleY, 1)
|
||||||
|
smokeMesh.material.opacity = 0
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < STAR_COUNT; index += 1) {
|
||||||
|
spawnStar(index, Math.pow(Math.random(), APP_CONFIG.starfield.seedProgressExponent))
|
||||||
|
}
|
||||||
|
|
||||||
|
starsGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3))
|
||||||
|
starsGeometry.setAttribute('color', new THREE.BufferAttribute(starColors, 3))
|
||||||
|
starsGeometry.setAttribute('aProgress', new THREE.BufferAttribute(starProgress, 1))
|
||||||
|
starsGeometry.setAttribute('aSizeBase', new THREE.BufferAttribute(starSizes, 1))
|
||||||
|
starsGeometry.setAttribute('aAlphaBase', new THREE.BufferAttribute(starAlpha, 1))
|
||||||
|
starsGeometry.setAttribute('aTail', new THREE.BufferAttribute(starTail, 1))
|
||||||
|
starsGeometry.setAttribute('aDir', new THREE.BufferAttribute(starDirections, 2))
|
||||||
|
|
||||||
|
const stars = new THREE.Points(starsGeometry, starMaterial)
|
||||||
|
travelRoot.add(stars)
|
||||||
|
|
||||||
|
for (let index = 0; index < SMOKE_COUNT; index += 1) {
|
||||||
|
const state = spawnSmoke()
|
||||||
|
smokeStates.push(state)
|
||||||
|
travelRoot.add(state.mesh)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clock = new THREE.Clock()
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
const width = container.clientWidth || window.innerWidth
|
||||||
|
const height = container.clientHeight || window.innerHeight
|
||||||
|
|
||||||
|
camera.aspect = width / height
|
||||||
|
camera.updateProjectionMatrix()
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, APP_CONFIG.scene.maxPixelRatio))
|
||||||
|
renderer.setSize(width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStars(delta: number) {
|
||||||
|
const positionAttribute = starsGeometry.getAttribute('position') as THREE.BufferAttribute
|
||||||
|
const progressAttribute = starsGeometry.getAttribute('aProgress') as THREE.BufferAttribute
|
||||||
|
const tailAttribute = starsGeometry.getAttribute('aTail') as THREE.BufferAttribute
|
||||||
|
const directionAttribute = starsGeometry.getAttribute('aDir') as THREE.BufferAttribute
|
||||||
|
|
||||||
|
for (let index = 0; index < STAR_COUNT; index += 1) {
|
||||||
|
const state = starStates[index]
|
||||||
|
|
||||||
|
if (state.delay > 0) {
|
||||||
|
state.delay = Math.max(0, state.delay - delta)
|
||||||
|
progressAttribute.array[index] = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = clamp((state.z - STAR_FAR_Z) / (STAR_RESET_Z - STAR_FAR_Z), 0, 1)
|
||||||
|
const edgeRatio = clamp(
|
||||||
|
Math.max(Math.abs(state.x) / STAR_BOUNDS_X, Math.abs(state.y) / STAR_BOUNDS_Y),
|
||||||
|
0,
|
||||||
|
1.2,
|
||||||
|
)
|
||||||
|
const nearBoost = 1 + Math.pow(progress, 2.2) * APP_CONFIG.starfield.nearFieldBoost
|
||||||
|
const edgeBoost = 1 + Math.pow(edgeRatio, 3.1) * APP_CONFIG.starfield.edgeBoost
|
||||||
|
const burstBoost = 1 + Math.pow(edgeRatio, 4.2) * APP_CONFIG.starfield.edgeBurst
|
||||||
|
|
||||||
|
state.vx += state.ax * delta * APP_CONFIG.starfield.accelerationXY * nearBoost * edgeBoost
|
||||||
|
state.vy += state.ay * delta * APP_CONFIG.starfield.accelerationXY * nearBoost * edgeBoost
|
||||||
|
state.vz += state.az * delta * APP_CONFIG.starfield.accelerationZ * nearBoost * edgeBoost
|
||||||
|
|
||||||
|
state.x += state.vx * delta * burstBoost
|
||||||
|
state.y += state.vy * delta * burstBoost
|
||||||
|
state.z += state.vz * delta * (1 + (burstBoost - 1) * 0.45)
|
||||||
|
|
||||||
|
const nextProgress = clamp((state.z - STAR_FAR_Z) / (STAR_RESET_Z - STAR_FAR_Z), 0, 1)
|
||||||
|
const nextEdgeRatio = clamp(
|
||||||
|
Math.max(Math.abs(state.x) / STAR_BOUNDS_X, Math.abs(state.y) / STAR_BOUNDS_Y),
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
const progressTrail = clamp(
|
||||||
|
(nextProgress - APP_CONFIG.starfield.trailStartProgress) / (1 - APP_CONFIG.starfield.trailStartProgress),
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
const edgeTrail = Math.pow(nextEdgeRatio, 2.4)
|
||||||
|
const direction = normalizeDirection(state.vx, state.vy)
|
||||||
|
const tailStrength = clamp(
|
||||||
|
Math.max(progressTrail * 0.75, edgeTrail) * APP_CONFIG.starfield.trailStrength,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
const offset = index * 3
|
||||||
|
const directionOffset = index * 2
|
||||||
|
|
||||||
|
if (
|
||||||
|
state.z > STAR_RESET_Z ||
|
||||||
|
Math.abs(state.x) > STAR_BOUNDS_X ||
|
||||||
|
Math.abs(state.y) > STAR_BOUNDS_Y
|
||||||
|
) {
|
||||||
|
spawnStar(index)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
positionAttribute.array[offset] = state.x
|
||||||
|
positionAttribute.array[offset + 1] = state.y
|
||||||
|
positionAttribute.array[offset + 2] = state.z
|
||||||
|
progressAttribute.array[index] = nextProgress
|
||||||
|
tailAttribute.array[index] = tailStrength
|
||||||
|
directionAttribute.array[directionOffset] = direction.x
|
||||||
|
directionAttribute.array[directionOffset + 1] = direction.y
|
||||||
|
}
|
||||||
|
|
||||||
|
positionAttribute.needsUpdate = true
|
||||||
|
progressAttribute.needsUpdate = true
|
||||||
|
tailAttribute.needsUpdate = true
|
||||||
|
directionAttribute.needsUpdate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSmoke(delta: number, elapsed: number) {
|
||||||
|
for (const state of smokeStates) {
|
||||||
|
state.age += delta
|
||||||
|
state.x += state.vx * delta
|
||||||
|
state.y += state.vy * delta
|
||||||
|
state.z += state.vz * delta
|
||||||
|
state.rotation += state.spin * delta
|
||||||
|
|
||||||
|
if (
|
||||||
|
state.z > SMOKE_RESET_Z ||
|
||||||
|
Math.abs(state.x) > SMOKE_BOUNDS_X ||
|
||||||
|
Math.abs(state.y) > SMOKE_BOUNDS_Y
|
||||||
|
) {
|
||||||
|
const resetState = spawnSmoke(state.mesh)
|
||||||
|
Object.assign(state, resetState)
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = clamp((state.z - SMOKE_FAR_Z) / (SMOKE_RESET_Z - SMOKE_FAR_Z), 0, 1)
|
||||||
|
const depthFadeIn = smoothstep(0.02, 0.28, progress)
|
||||||
|
const ageFadeIn = smoothstep(0, 0.55, state.age)
|
||||||
|
const fadeIn = depthFadeIn * ageFadeIn
|
||||||
|
const fadeOut = 1 - smoothstep(0.68, 1, progress)
|
||||||
|
const pulse = 1 + Math.sin(elapsed * state.wobbleSpeed + state.rotation) * 0.12 * state.wobble
|
||||||
|
const scale = lerp(0.56, 1.9, progress) * pulse
|
||||||
|
|
||||||
|
state.mesh.position.set(state.x, state.y, state.z)
|
||||||
|
state.mesh.lookAt(camera.position)
|
||||||
|
state.mesh.rotateZ(state.rotation)
|
||||||
|
state.mesh.scale.set(state.baseScaleX * scale, state.baseScaleY * scale, 1)
|
||||||
|
state.mesh.material.opacity = state.opacity * fadeIn * fadeOut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (disposed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaSeconds = Math.min(clock.getDelta(), 0.033)
|
||||||
|
const elapsed = clock.elapsedTime
|
||||||
|
|
||||||
|
pointerCurrent.x = lerp(pointerCurrent.x, pointerTarget.x, APP_CONFIG.interaction.pointerSmoothing)
|
||||||
|
pointerCurrent.y = lerp(pointerCurrent.y, pointerTarget.y, APP_CONFIG.interaction.pointerSmoothing)
|
||||||
|
|
||||||
|
const speedGoal = started ? targetSpeed : 0
|
||||||
|
currentSpeed = lerp(currentSpeed, speedGoal, 0.06)
|
||||||
|
|
||||||
|
const motionDelta = deltaSeconds * currentSpeed
|
||||||
|
updateStars(motionDelta)
|
||||||
|
updateSmoke(motionDelta * 0.62, elapsed)
|
||||||
|
|
||||||
|
travelRoot.rotation.z = pointerCurrent.x * 0.03
|
||||||
|
travelRoot.position.x = -pointerCurrent.x * 0.8
|
||||||
|
travelRoot.position.y = -pointerCurrent.y * 0.45
|
||||||
|
|
||||||
|
camera.position.x = pointerCurrent.x * APP_CONFIG.interaction.maxOffset * 2.8
|
||||||
|
camera.position.y = pointerCurrent.y * APP_CONFIG.interaction.maxOffset * 1.8
|
||||||
|
camera.lookAt(pointerCurrent.x * 1.5, pointerCurrent.y * 0.8, -30)
|
||||||
|
|
||||||
|
renderer.render(scene, camera)
|
||||||
|
animationFrameId = window.requestAnimationFrame(render)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
render()
|
||||||
|
|
||||||
|
return {
|
||||||
|
setStarted(nextStarted) {
|
||||||
|
started = nextStarted
|
||||||
|
},
|
||||||
|
setSpeed(nextSpeed) {
|
||||||
|
targetSpeed = clamp(nextSpeed, APP_CONFIG.animation.speedMin, APP_CONFIG.animation.speedMax)
|
||||||
|
},
|
||||||
|
setPointerTarget(pointer) {
|
||||||
|
pointerTarget.x = clamp(pointer.x, -1, 1)
|
||||||
|
pointerTarget.y = clamp(pointer.y, -1, 1)
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
if (disposed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
disposed = true
|
||||||
|
window.cancelAnimationFrame(animationFrameId)
|
||||||
|
window.removeEventListener('resize', onResize)
|
||||||
|
|
||||||
|
for (const smokeState of smokeStates) {
|
||||||
|
smokeState.mesh.material.dispose()
|
||||||
|
travelRoot.remove(smokeState.mesh)
|
||||||
|
}
|
||||||
|
|
||||||
|
starsGeometry.dispose()
|
||||||
|
starMaterial.dispose()
|
||||||
|
smokeGeometry.dispose()
|
||||||
|
smokeTexture.dispose()
|
||||||
|
renderer.dispose()
|
||||||
|
scene.clear()
|
||||||
|
|
||||||
|
if (renderer.domElement.parentElement === container) {
|
||||||
|
container.removeChild(renderer.domElement)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { PointerTarget, TravelSceneController }
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
|
||||||
|
export default {
|
||||||
|
darkMode: "class",
|
||||||
|
content: ["./index.html", "./src/**/*.{js,ts,vue}"],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
},
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"strict": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": false,
|
||||||
|
"baseUrl": "./",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.vue",
|
||||||
|
"api"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
import Inspector from 'unplugin-vue-dev-locator/vite'
|
||||||
|
import traeBadgePlugin from 'vite-plugin-trae-solo-badge'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
sourcemap: 'hidden',
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
Inspector(),
|
||||||
|
traeBadgePlugin({
|
||||||
|
variant: 'dark',
|
||||||
|
position: 'bottom-right',
|
||||||
|
prodOnly: true,
|
||||||
|
clickable: true,
|
||||||
|
clickUrl: 'https://www.trae.ai/solo?showJoin=1',
|
||||||
|
autoTheme: true,
|
||||||
|
autoThemeTarget: '#app',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'), // ✅ 定义 @ = src
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user