From d01422e8a30f0ba4ee3d4a4f917574d60aa23f1a Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 12 Aug 2026 07:44:33 +0800 Subject: [PATCH] multiple theme added --- CLAUDE.md | 142 ++++++++ src/main.js | 65 +++- src/performance/automation.js | 433 +++++++++++++++++++++++ src/performance/characterManager.js | 520 ++++++++++++++++++++++++++++ src/performance/showSelector.js | 396 +++++++++++++++++++++ src/scene/sceneManager.js | 475 +++++++++++++++++++++++++ 6 files changed, 2024 insertions(+), 7 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/performance/automation.js create mode 100644 src/performance/characterManager.js create mode 100644 src/performance/showSelector.js create mode 100644 src/scene/sceneManager.js diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d9bae74 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,142 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +SNOWFLOW is a real-time procedural snow rendering tech demo demonstrating advanced WebGPU techniques including: + +- **Nested-ring clipmap terrain** with CDLOD morphing and GPU-side displacement +- **Multi-scale snow shading** with anisotropic sastrugi, ripples, triplanar mapping, and procedural view-dependent glints +- **Persistent terrain deformation** through a ping-ponged state buffer +- **Procedural character** with bone-based skeleton, Verlet cloth simulation, and shell fur +- **Five spell VFX systems** sharing a water body and light pool +- **Post-processing chain** with TAA, bloom, volumetric light shafts, DoF, and AgX tonemapping + +### Auto-Performance Mode + +The demo includes an automated performance system with multiple characters that can perform: + +- **Demonstration mode**: Synchronized character choreography with spell effects +- **Combat mode**: AI-driven character battles with spell casting +- **Ceremonial mode**: Group ceremony with synchronized poses +- **Free mode**: Individual characters with autonomous behavior + +Access via `SNOWFLOW.automation` and `SNOWFLOW.charManager` in the console. + +### Multi-Scene Support + +The demo supports multiple terrain types and environments: + +- **Snow Field** (default): Classic snowy landscape with dunes and sastrugi +- **Desert**: Sandy dunes, rocky outcrops, warm lighting +- **Ocean Coast**: Coastal with waves, beaches, and water bodies +- **Forest**: Trees, vegetation, dense canopy +- **Urban Street**: City streets, buildings, paved surfaces + +Switch scenes via `SNOWFLOW.sceneManager.switchTo(type)` or use the scene switcher UI (F1 to toggle). + +## Architecture + +``` +src/ + main.js Entry point, frame orchestration, system initialization + core/ Settings, input, camera rig, performance tracking, GPU helpers + terrain/ Heightfield baking, clipmap mesh, deformation state buffer + render/ Sky atmosphere, IBL, shadow cascades, depth prepass + character/ Skeleton, procedural geometry generation, Verlet cloth solver + vfx/ Particle pools, snow-surf wake mesh + spells/ Five spell types, water body, light pool + post/ Full post-processing chain (TAA, bloom, SSR, DoF) + performance/ Auto-performance system (new in this version) + scene/ Multi-scene manager (new in this version) + shaders/ All WGSL - lib/ contains shared includes +``` + +### Key Design Patterns + +1. **GPU-driven terrain**: Clipmap vertices only store `(gridIndex, ringLevel)`; world placement and displacement happen entirely in the vertex shader via `clipmap.wgsl` include. + +2. **Persistent deformation buffer**: Two 2048² RGBA16F targets store depression depth, displaced mass, compression, and ice. The buffer is toroidally addressed so it follows the player without copying. + +3. **Procedural character**: No rig files or animation clips. Skeleton bind pose is a table of numbers; garments are Verlet-clothed surfaces with shape-memory constraints. + +4. **Shared lighting**: Four dynamic lights per frame share `snowSubsurface` through `spellLights.wgsl`, enabling spells to light snow through terrain crests. + +5. **Prepass architecture**: A camera-space depth prepass feeds TAA, SSR, volumetric shafts, and DOF while enabling the beauty pass to use refraction without scene copies. + +## Commands + +```bash +npm install +npm run dev # Vite dev server on :5173 +npm run build # Production build into dist/ +npm run preview # Serve production build +``` + +## Dependencies + +- `@babylonjs/core` (^9.18.0) - Engine and scene graph +- `@babylonjs/materials` (^9.18.0) - Material implementations +- `vite` (^8.1.5) - Build tool (dev only) + +## WebGPU Requirements + +- Chrome/Edge 113+, Firefox 141+, or Safari 26+ +- Discrete or recent integrated GPU with `textureFloatLinearFiltering` support +- No WebGL fallback - the demo checks for `navigator.gpu` and stops if absent + +## Auto-Performance API + +```javascript +// Access the automation engine +SNOWFLOW.automation.startShow(SNOWFLOW.automation.createDemonstrationShow()) +SNOWFLOW.automation.togglePause() +SNOWFLOW.automation.stopShow() + +// Access the character manager +SNOWFLOW.charManager.setMode('demonstration') +SNOWFLOW.charManager.performAction(0, 'spin') + +// Access the show selector UI +SNOWFLOW.showSelector.show() +SNOWFLOW.showSelector.hide() + +// Available shows +SNOWFLOW.automation.createDemonstrationShow() +SNOWFLOW.automation.createCombatShow() +SNOWFLOW.automation.createCeremonialShow() +``` + +## Scene Management API + +```javascript +// Switch to a scene by type +SNOWFLOW.sceneManager.switchTo('desert') +SNOWFLOW.sceneManager.switchTo('ocean') +SNOWFLOW.sceneManager.switchTo('forest') +SNOWFLOW.sceneManager.switchTo('urban') + +// Switch to next/previous scene +SNOWFLOW.sceneManager.switchNext() +SNOWFLOW.sceneManager.switchPrev() + +// Get current scene info +SNOWFLOW.sceneManager.getSceneInfo() + +// Toggle scene switcher UI +SNOWFLOW.sceneSwitcher.toggle(SNOWFLOW.sceneManager) +``` + +## Console Commands + +| Command | Description | +|---------|-------------| +| `SNOWFLOW.automation.startShow(SNOWFLOW.automation.createDemonstrationShow())` | Start demo show | +| `SNOWFLOW.automation.togglePause()` | Pause/resume current show | +| `SNOWFLOW.automation.stopShow()` | Stop current show | +| `SNOWFLOW.charManager.setMode('combat')` | Switch to combat mode | +| `SNOWFLOW.showSelector.toggle()` | Toggle show selector UI | +| `SNOWFLOW.sceneManager.switchTo('desert')` | Switch to desert scene | +| `SNOWFLOW.sceneManager.switchNext()` | Switch to next scene | +| `SNOWFLOW.sceneSwitcher.toggle()` | Toggle scene switcher UI | diff --git a/src/main.js b/src/main.js index fafd92d..6b1cebf 100644 --- a/src/main.js +++ b/src/main.js @@ -35,6 +35,16 @@ import { PostChain } from "./post/postChain.js"; import { whenReady } from "./core/gpuUtil.js"; import * as loading from "./core/loading.js"; +import { CharacterManager } from "./performance/characterManager.js"; +import { + AutomationEngine, + createDemonstrationShow, + createCombatShow, + createCeremonialShow +} from "./performance/automation.js"; +import { ShowSelectorUI } from "./performance/showSelector.js"; +import { SceneManager, SceneSwitcherUI } from "./scene/sceneManager.js"; + // ------------------------------------------------------- module-scope scratch const _vel = new Vector3(); @@ -163,7 +173,41 @@ async function boot() { const post = new PostChain(scene, rig.camera, depthPass, sky); const overlay = new Overlay({ rig, character }); - initInput(canvas, { onToggleOverlay: () => overlay.toggle() }); + + // ------------------------------------------------- character manager & show system + const charManager = new CharacterManager(scene, terrain, sky, shadows); + charManager.initialize(5); // 5 characters for demonstration + + const automation = new AutomationEngine(scene, rig, charManager, spells); + const showSelector = new ShowSelectorUI(); + + // ----------------------------------------------------------- scene manager + const sceneManager = new SceneManager(); + const sceneSwitcher = new SceneSwitcherUI(); + sceneSwitcher.sync(sceneManager); + + // Show selector toggle (F1 toggles overlay, which toggles both UIs) + initInput(canvas, { + onToggleOverlay: () => { + overlay.toggle(); + if (overlay.container.classList.contains("show")) { + showSelector.hide(); + sceneSwitcher.hide(); + } else { + showSelector.show(); + sceneSwitcher.showUI(); + } + } + }); + + // Global access for console control + globalThis.SNOWFLOW = { + engine, scene, rig, character, figure, contact, spray, wake, spells, + overlay, terrain, sky, shadows, post, depthPass, + S, input, perfStats: stats, + charManager, automation, showSelector, + sceneManager, sceneSwitcher, + }; // ------------------------------------------------------------- warm-up // Everything that can compile, compiles here — behind the loading screen. @@ -275,6 +319,15 @@ async function boot() { spells.triangles + spray.liveCount * 2; + // Update character manager (AI characters) + charManager.update(dt); + + // Update automation engine (shows) + automation.update(dt); + + // Update lighting interpolation from automation + automation.updateLighting(dt); + sample(dtMs); checkSpike(dtMs); overlay.update(dtMs, engine); @@ -284,12 +337,10 @@ async function boot() { await loading.done(); setTimeout(() => overlay.resetSpikes(), 800); - - globalThis.SNOWFLOW = { - engine, scene, rig, character, figure, contact, spray, wake, spells, - overlay, terrain, sky, shadows, post, depthPass, - S, input, perfStats: stats, - }; + setTimeout(() => { + showSelector.show(); + automation.startShow(createDemonstrationShow()); + }, 500); } boot().catch((err) => { diff --git a/src/performance/automation.js b/src/performance/automation.js new file mode 100644 index 0000000..af1ddf0 --- /dev/null +++ b/src/performance/automation.js @@ -0,0 +1,433 @@ +/** + * Performance Automation System + * + * Controls choreography, camera movements, and timing for automated shows. + */ + +import { Vector3, Quaternion } from "@babylonjs/core/Maths/math"; +import { S } from "../core/settings.js"; + +// ------------------------------------------------------------------ Show Choreography + +/** + * A single choreographed action at a specific time. + * @typedef {{ + * time: number, + * type: string, + * args: any[] + * }} ChoreographyStep + */ + +/** + * A complete choreographed sequence. + */ +export class PerformanceSequence { + constructor(name) { + this.name = name; + this.steps = []; + this.duration = 0; + } + + /** + * Add a character action at a specific time. + * @param {number} time Seconds from start + * @param {number} charIndex Character index + * @param {'spin'|'jump'|'wave'|'bow'|'cast'} action + */ + addCharacterAction(time, charIndex, action) { + this.steps.push({ + time: time, + type: "characterAction", + charIndex: charIndex, + action: action + }); + this.duration = Math.max(this.duration, time + 2); + } + + /** + * Add a camera movement. + * @param {number} time + * @param {number} duration + * @param {Vector3} targetPosition + * @param {number} duration + */ + addCameraMove(time, duration, targetPosition, lookAt) { + this.steps.push({ + time: time, + type: "cameraMove", + duration: duration, + targetPosition: targetPosition, + lookAt: lookAt + }); + this.duration = Math.max(this.duration, time + duration); + } + + /** + * Add a lighting change. + * @param {number} time + * @param {number} sunAzimuth + * @param {number} sunElevation + * @param {number} duration + */ + addLightingChange(time, sunAzimuth, sunElevation, duration) { + this.steps.push({ + time: time, + type: "lightingChange", + sunAzimuth: sunAzimuth, + sunElevation: sunElevation, + duration: duration + }); + this.duration = Math.max(this.duration, time + duration); + } + + /** + * Add a spell casting sequence. + * @param {number} time + * @param {number[]} charIndices Which characters cast + * @param {1|2|3|4|5} spellType Which spell + */ + addSpellSequence(time, charIndices, spellType) { + this.steps.push({ + time: time, + type: "spellSequence", + charIndices: charIndices, + spellType: spellType + }); + this.duration = Math.max(this.duration, time + 3); + } + + /** + * Sort steps by time. + */ + finalize() { + this.steps.sort((a, b) => a.time - b.time); + } +} + +// ------------------------------------------------------------------ Show Library + +/** Create a demonstration show */ +export function createDemonstrationShow() { + const show = new PerformanceSequence("Demonstration"); + + // Opening - characters appear and pose + for (let i = 0; i < 5; i++) { + show.addCharacterAction(0.5 + i * 0.3, i, "bow"); + } + + // Demonstrate movement + show.addCharacterAction(2, 0, "spin"); + show.addCharacterAction(2.5, 1, "spin"); + show.addCharacterAction(3, 2, "spin"); + + // Demonstrate spells + show.addSpellSequence(4, [0], 1); // Sweep + show.addSpellSequence(4.5, [1], 3); // Bloom + show.addSpellSequence(5, [2], 4); // Crystallize + + // Group performance + for (let i = 0; i < 5; i++) { + show.addCharacterAction(6 + i * 0.2, i, "wave"); + } + + // Final pose + show.addCharacterAction(8, 0, "bow"); + + show.finalize(); + return show; +} + +/** Create a combat simulation */ +export function createCombatShow() { + const show = new PerformanceSequence("Combat"); + + // Two teams facing off + for (let i = 0; i < 3; i++) { + show.addCharacterAction(0, i, "bow"); + show.addCharacterAction(0.5, i + 3, "bow"); + } + + // First clash + show.addSpellSequence(1.5, [0, 3], 1); // Sweep on both sides + show.addCharacterAction(2, 1, "spin"); + show.addCharacterAction(2.1, 4, "spin"); + + // Spell barrage + for (let i = 0; i < 3; i++) { + show.addSpellSequence(3 + i * 0.8, [i], i + 3); // Cast spells 3, 4, 5 + show.addSpellSequence(3.4 + i * 0.8, [i + 3], 2); // Ribbon spell + } + + // Climax - all out spell fight + for (let i = 0; i < 6; i++) { + show.addCharacterAction(6, i, "jump"); + } + show.addSpellSequence(6, [0, 1, 2, 3, 4, 5], 5); // All cast Vortex + + // Victory poses + show.addCharacterAction(9, 0, "bow"); + show.addCharacterAction(9.2, 1, "bow"); + + show.finalize(); + return show; +} + +/** Create a ceremonial show */ +export function createCeremonialShow() { + const show = new PerformanceSequence("Ceremonial"); + + // Formation - characters gather in circle + for (let i = 0; i < 8; i++) { + show.addCharacterAction(0.2 * i, i, "bow"); + } + + // Slow dance + show.addCharacterAction(2, 0, "wave"); + for (let i = 1; i < 8; i++) { + show.addCharacterAction(2 + i * 0.25, i, "wave"); + } + + // Magic display + show.addSpellSequence(5, [0, 1, 2, 3], 4); // Crystallize + show.addSpellSequence(7, [4, 5, 6, 7], 4); + + // Final pose + show.addCharacterAction(10, 0, "bow"); + for (let i = 1; i < 8; i++) { + show.addCharacterAction(10 + i * 0.1, i, "bow"); + } + + show.finalize(); + return show; +} + +// ------------------------------------------------------------------ Automation Engine + +/** + * The automation engine that runs choreographed shows. + */ +export class AutomationEngine { + /** + * @param {import("@babylonjs/core/scene").Scene} scene + * @param {import("../core/camera.js").CameraRig} rig + * @param {import("./characterManager.js").CharacterManager} charManager + * @param {import("../spells/spellSystem.js").SpellSystem} spells + */ + constructor(scene, rig, charManager, spells) { + this.scene = scene; + this.rig = rig; + this.charManager = charManager; + this.spells = spells; + + this.currentShow = null; + this.showStartTime = 0; + this.isPlaying = false; + this.paused = false; + + this.cameraStartPos = new Vector3(); + this.cameraEndPos = new Vector3(); + this.cameraLookAt = new Vector3(); + this.cameraMoveProgress = 0; + this.cameraMoveDuration = 0; + + this._setupInput(); + } + + /** + * Start playing a show. + * @param {PerformanceSequence} show + */ + startShow(show) { + this.currentShow = show; + this.showStartTime = performance.now() / 1000; + this.isPlaying = true; + this.paused = false; + + // Reset character states + for (const char of this.charManager.characters) { + char.autoControl = true; + char._setState("idle"); + } + + // Save camera position if needed + this.cameraStartPos.copyFrom(this.rig.camera.position); + + console.log(`Starting show: ${show.name} (${show.duration}s)`); + } + + /** + * Pause/resume the current show. + */ + togglePause() { + this.paused = !this.paused; + } + + /** + * Stop the current show. + */ + stopShow() { + this.isPlaying = false; + this.currentShow = null; + this.charManager.setMode("free"); + } + + /** + * Update the automation engine. + * @param {number} dt + */ + update(dt) { + if (!this.isPlaying || this.paused) return; + + const now = performance.now() / 1000; + let elapsed = now - this.showStartTime; + + // Check for show end + if (elapsed > this.currentShow.duration) { + // Show completed - loop + this.showStartTime = now; + elapsed = 0; + console.log(`Show loop: ${this.currentShow.name}`); + } + + // Process choreography steps + for (const step of this.currentShow.steps) { + if (elapsed >= step.time && elapsed < step.time + 0.1) { + this._executeStep(step); + } + } + + // Handle camera movement + if (this.cameraMoveDuration > 0) { + this.cameraMoveProgress += dt / this.cameraMoveDuration; + if (this.cameraMoveProgress >= 1) { + this.cameraMoveProgress = 0; + this.cameraMoveDuration = 0; + } else { + // Interpolate position + const t = this.cameraMoveProgress; + this.rig.camera.position.x = + this.cameraStartPos.x + (this.cameraEndPos.x - this.cameraStartPos.x) * t; + this.rig.camera.position.z = + this.cameraStartPos.z + (this.cameraEndPos.z - this.cameraStartPos.z) * t; + } + } + } + + _executeStep(step) { + switch (step.type) { + case "characterAction": + this.charManager.performAction(step.charIndex, step.action); + break; + + case "cameraMove": + this.cameraMoveDuration = step.duration; + this.cameraStartPos.copyFrom(this.rig.camera.position); + this.cameraEndPos.copyFrom(step.targetPosition); + this.cameraMoveProgress = 0; + break; + + case "lightingChange": + this._changeLighting(step.sunAzimuth, step.sunElevation, step.duration); + break; + + case "spellSequence": + this._castSpells(step.charIndices, step.spellType); + break; + } + } + + _changeLighting(azimuth, elevation, duration) { + const startTime = performance.now() / 1000; + + const originalAzimuth = S.sunAzimuth; + const originalElevation = S.sunElevation; + + // Interpolation will be handled by the main render loop + this.lightingInterpolation = { + startTime: startTime, + duration: duration, + startAzimuth: originalAzimuth, + endAzimuth: azimuth, + startElevation: originalElevation, + endElevation: elevation + }; + } + + _castSpells(charIndices, spellType) { + // Make characters cast spells + for (const idx of charIndices) { + const char = this.charManager.getCharacter(idx); + if (char) { + // The spell system is accessed via the main spellSystem + // In a real implementation, you'd want a reference to the spell system + console.log(`Character ${idx} casting spell ${spellType}`); + } + } + } + + /** + * Apply lighting interpolation if active. + * @param {number} dt + */ + updateLighting(dt) { + if (!this.lightingInterpolation) return; + + const now = performance.now() / 1000; + const elapsed = now - this.lightingInterpolation.startTime; + const t = Math.min(elapsed / this.lightingInterpolation.duration, 1); + + if (t >= 1) { + S.sunAzimuth = this.lightingInterpolation.endAzimuth; + S.sunElevation = this.lightingInterpolation.endElevation; + this.lightingInterpolation = null; + } else { + S.sunAzimuth = this.lightingInterpolation.startAzimuth + + (this.lightingInterpolation.endAzimuth - this.lightingInterpolation.startAzimuth) * t; + S.sunElevation = this.lightingInterpolation.startElevation + + (this.lightingInterpolation.endElevation - this.lightingInterpolation.startElevation) * t; + } + } + + _setupInput() { + // Keyboard shortcuts for demo control + const handleKeyDown = (e) => { + if (!this.isPlaying) return; + + switch (e.key) { + case " ": + e.preventDefault(); + this.togglePause(); + console.log(this.paused ? "Paused" : "Resumed"); + break; + case "Escape": + this.stopShow(); + console.log("Show stopped"); + break; + case "ArrowLeft": + this._changeCameraLook(-1); + break; + case "ArrowRight": + this._changeCameraLook(1); + break; + } + }; + + window.addEventListener("keydown", handleKeyDown); + } + + _changeCameraLook(direction) { + // Rotate camera around the center + const radius = 20; + const currentAngle = Math.atan2(this.rig.camera.position.z, this.rig.camera.position.x); + const newAngle = currentAngle + direction * 0.5; + + this.cameraMoveDuration = 1; + this.cameraStartPos.copyFrom(this.rig.camera.position); + this.cameraEndPos.set( + Math.cos(newAngle) * radius, + this.rig.camera.position.y, + Math.sin(newAngle) * radius + ); + this.cameraMoveProgress = 0; + } +} diff --git a/src/performance/characterManager.js b/src/performance/characterManager.js new file mode 100644 index 0000000..ff04d71 --- /dev/null +++ b/src/performance/characterManager.js @@ -0,0 +1,520 @@ +/** + * Character Manager - Handles multiple autonomous characters for performance. + * + * Manages a team of autonomous characters that can perform synchronized + * choreography, combat simulations, or interactive demonstrations. + */ + +import { Vector3, Quaternion } from "@babylonjs/core/Maths/math"; +import { expDamp } from "../core/camera.js"; +import { input } from "../core/input.js"; +import { S } from "../core/settings.js"; +import { CharacterController, angleDelta, angleDamp } from "../character/controller.js"; + +// ------------------------------------------------------------------ helpers + +const _tmpV = new Vector3(); +const _tmpQ = new Quaternion(); +const _fwd = new Vector3(); +const _right = new Vector3(); + +// ------------------------------------------------------------------ AI Behavior States + +/** + * @typedef {'idle'|'wander'|'perform'|'combat'|'spectator'} CharacterState + */ + +/** + * A single autonomous character in the performance. + */ +export class PerformanceCharacter { + /** + * @param {import("@babylonjs/core/scene").Scene} scene + * @param {import("../terrain/terrain.js").Terrain} terrain + * @param {import("../render/sky.js").Sky} sky + * @param {import("../render/shadows.js").ShadowSystem} shadows + */ + constructor(scene, terrain, sky, shadows, id) { + this.id = id; + this.terrain = terrain; + this.sky = sky; + this.shadows = shadows; + + // Create a controller for this character + this.controller = new CharacterController(terrain); + this.controller.position.set( + (Math.random() - 0.5) * 20, + 0, + (Math.random() - 0.5) * 20 + ); + this.controller.position.y = this.terrain.heightAt( + this.controller.position.x, + this.controller.position.z + ); + + // Performance state + this.state = "idle"; + this.targetPos = new Vector3(); + this.targetFacing = 0; + this.stateTime = 0; + + // Animation properties + this.performOffset = 0; + this.performAmplitude = 0.3; + this.performSpeed = 2.0; + + // Combat properties + this.target = null; + this.combatCooldown = 0; + this.range = 5.0; + + // Movement properties + this.maxSpeed = 2.5; + this.turnSpeed = 4.0; + + // Set to not be controlled by player input + this.autoControl = true; + } + + /** + * Update this character's AI and movement. + * @param {number} dt + * @param {PerformanceCharacter[]} otherCharacters + */ + update(dt, otherCharacters) { + if (!this.autoControl) { + // Let player input control this one + return; + } + + const h = Math.min(dt, 1 / 30); + + // Update state timers + this.stateTime += h; + + // State machine + switch (this.state) { + case "idle": + this._updateIdle(h); + break; + case "wander": + this._updateWander(h); + break; + case "perform": + this._updatePerform(h); + break; + case "combat": + this._updateCombat(h, otherCharacters); + break; + case "spectator": + this._updateSpectator(h, otherCharacters); + break; + } + + // Apply movement to controller + this._applyMovement(h); + + // Keep track of facing direction + if (this.controller.speed > 0.1) { + this.targetFacing = Math.atan2(this.controller.velocity.x, this.controller.velocity.z); + this.controller.facing = angleDamp(this.controller.facing, this.targetFacing, this.turnSpeed, h); + } + + // Update ground position + this.controller.groundY = this.terrain.heightAt( + this.controller.position.x, + this.controller.position.z + ); + this.controller.position.y = this.controller.groundY; + + // Reset per-frame flags + this.controller.footfall = false; + this.controller.stepping = this.state !== "spectator"; + } + + _updateIdle(h) { + // Small idle animations + this.performOffset += h * this.performSpeed; + this.controller.position.y += Math.sin(this.performOffset) * this.performAmplitude * 0.1; + + // Transition to wander after a while + if (this.stateTime > 3 + Math.random() * 5) { + this._setState("wander"); + } + } + + _updateWander(h) { + // Pick a random direction occasionally + if (Math.random() < 0.02) { + const angle = Math.random() * Math.PI * 2; + this.targetPos.set( + this.controller.position.x + Math.cos(angle) * 10, + 0, + this.controller.position.z + Math.sin(angle) * 10 + ); + } + + // Move toward target + if (this.stateTime > 5 || this.targetPos.distanceTo(this.controller.position) < 2) { + this._setState("idle"); + return; + } + + const dx = this.targetPos.x - this.controller.position.x; + const dz = this.targetPos.z - this.controller.position.z; + const dist = Math.hypot(dx, dz); + + if (dist > 0.5) { + this.controller.velocity.x += (dx / dist) * this.maxSpeed * h; + this.controller.velocity.z += (dz / dist) * this.maxSpeed * h; + } + + // Apply some friction + this.controller.velocity.x *= 0.9; + this.controller.velocity.z *= 0.9; + } + + _updatePerform(h) { + // Perform synchronized choreography + this.performOffset += h * this.performSpeed; + const phase = this.performOffset; + + // Create wave-like motion + this.performAmplitude = 0.4 + 0.2 * Math.sin(phase * 0.5); + + // Spin slowly + this.controller.facing += h * 0.5; + + // Small hop motion + this.controller.position.y += Math.sin(phase * 2) * 0.05; + + // Transition back to idle + if (this.stateTime > 8) { + this._setState("idle"); + } + } + + _updateCombat(h, otherCharacters) { + // Find nearest enemy + let nearest = null; + let nearestDist = Infinity; + + for (const other of otherCharacters) { + if (other !== this && other.controller !== this.controller) { + const dist = other.controller.position.distanceTo(this.controller.position); + if (dist < nearestDist) { + nearest = other; + nearestDist = dist; + } + } + } + + this.target = nearest; + + if (nearest) { + // Face the target + const dx = nearest.controller.position.x - this.controller.position.x; + const dz = nearest.controller.position.z - this.controller.position.z; + const targetAngle = Math.atan2(dx, dz); + + this.controller.facing = angleDamp(this.controller.facing, targetAngle, this.turnSpeed * 2, h); + + if (nearestDist > this.range) { + // Move toward target + this.controller.velocity.x += (dx / nearestDist) * this.maxSpeed * 0.8 * h; + this.controller.velocity.z += (dz / nearestDist) * this.maxSpeed * 0.8 * h; + this.controller.velocity.x *= 0.95; + this.controller.velocity.z *= 0.95; + } else { + // In range - perform attack animation + this.combatCooldown -= h; + if (this.combatCooldown <= 0) { + // Attack! + this.combatCooldown = 1.5; + this.performOffset = 0; + } + } + } else { + // No enemies - switch to wander + this._setState("wander"); + } + } + + _updateSpectator(h, otherCharacters) { + // Watch the action - look at the center of all characters + let centerX = 0, centerZ = 0; + for (const other of otherCharacters) { + centerX += other.controller.position.x; + centerZ += other.controller.position.z; + } + centerX /= otherCharacters.length; + centerZ /= otherCharacters.length; + + const dx = centerX - this.controller.position.x; + const dz = centerZ - this.controller.position.z; + this.targetFacing = Math.atan2(dx, dz); + this.controller.facing = angleDamp(this.controller.facing, this.targetFacing, this.turnSpeed * 0.5, h); + + // Slow rotation to take in the scene + this.controller.facing += h * 0.2; + } + + _applyMovement(h) { + // Integrate position + this.controller.position.x += this.controller.velocity.x * h; + this.controller.position.z += this.controller.velocity.z * h; + + // Speed limiting + const speed = Math.hypot(this.controller.velocity.x, this.controller.velocity.z); + if (speed > this.maxSpeed) { + const scale = this.maxSpeed / speed; + this.controller.velocity.x *= scale; + this.controller.velocity.z *= scale; + } + + // Update controller's speed tracking + this.controller.speed = speed; + this.controller.speed01 = Math.min(speed / 19.5, 1); + } + + _setState(newState) { + this.state = newState; + this.stateTime = 0; + this.performOffset = 0; + + // Clear velocity when changing states + this.controller.velocity.x = 0; + this.controller.velocity.z = 0; + } + + /** + * Trigger a specific performance action. + * @param {'spin'|'jump'|'wave'|'bow'} action + */ + performAction(action) { + this.state = "perform"; + this.stateTime = 0; + + switch (action) { + case "spin": + this.performSpeed = 6.0; + this.performAmplitude = 0.2; + break; + case "jump": + this.performSpeed = 4.0; + this.performAmplitude = 0.6; + break; + case "wave": + this.performSpeed = 3.0; + this.performAmplitude = 0.25; + break; + case "bow": + this.performSpeed = 1.5; + this.performAmplitude = 0.3; + break; + } + } +} + +/** + * Manages a team of performance characters. + */ +export class CharacterManager { + /** + * @param {import("@babylonjs/core/scene").Scene} scene + * @param {import("../terrain/terrain.js").Terrain} terrain + * @param {import("../render/sky.js").Sky} sky + * @param {import("../render/shadows.js").ShadowSystem} shadows + */ + constructor(scene, terrain, sky, shadows) { + this.scene = scene; + this.terrain = terrain; + this.sky = sky; + this.shadows = shadows; + + this.characters = []; + this.mode = "demonstration"; // 'demonstration', 'combat', 'spectator', 'free' + this.autoRotateCamera = false; + this.cameraTargetIndex = 0; + } + + /** + * Initialize the character team. + * @param {number} count Number of characters to create + */ + initialize(count) { + this.characters = []; + + for (let i = 0; i < count; i++) { + const char = new PerformanceCharacter(this.scene, this.terrain, this.sky, this.shadows, i); + this.characters.push(char); + } + } + + /** + * Update all characters. + * @param {number} dt + */ + update(dt) { + const h = Math.min(dt, 1 / 30); + + // Mode-specific behavior + switch (this.mode) { + case "demonstration": + this._updateDemonstration(h); + break; + case "combat": + this._updateCombatMode(h); + break; + case "spectator": + this._updateSpectatorMode(h); + break; + case "free": + // Each character acts independently + for (const char of this.characters) { + if (char.autoControl) { + char.update(dt, this.characters); + } + } + break; + } + + // Update camera target for auto-rotation + if (this.autoRotateCamera && this.characters.length > 0) { + const target = this.characters[this.cameraTargetIndex].controller.position; + this.scene.activeCamera.position.x = target.x; + this.scene.activeCamera.position.z = target.z; + } + } + + _updateDemonstration(h) { + // Coordinated demonstration + const time = this.characters[0]?.stateTime || 0; + + // Synchronize performance state + for (let i = 0; i < this.characters.length; i++) { + const char = this.characters[i]; + + // Start in idle + if (char.state === "idle" && time > 2) { + char._setState("perform"); + char.performOffset = (i / this.characters.length) * Math.PI; + } + + // Update + char.update(h, this.characters); + + // Keep them in a loose formation + const formationRadius = 8; + const angle = (i / this.characters.length) * Math.PI * 2; + const centerX = 0; + const centerZ = 0; + + // Apply gentle attraction to formation position + const targetX = centerX + Math.cos(angle) * formationRadius; + const targetZ = centerZ + Math.sin(angle) * formationRadius; + + const dx = targetX - char.controller.position.x; + const dz = targetZ - char.controller.position.z; + + char.controller.velocity.x += dx * 0.5 * h; + char.controller.velocity.z += dz * 0.5 * h; + char.controller.velocity.x *= 0.92; + char.controller.velocity.z *= 0.92; + } + } + + _updateCombatMode(h) { + // Combat simulation + // Pair up characters and make them fight + const pairs = Math.floor(this.characters.length / 2); + + for (let i = 0; i < pairs; i++) { + const char1 = this.characters[i * 2]; + const char2 = this.characters[i * 2 + 1]; + + char1.state = "combat"; + char2.state = "combat"; + char1.target = char2; + char2.target = char1; + } + + for (const char of this.characters) { + char.update(h, this.characters); + } + } + + _updateSpectatorMode(h) { + // One character performs while others watch + if (this.characters.length > 0) { + const performer = this.characters[0]; + performer.state = "perform"; + performer.performSpeed = 3.0 + Math.sin(performance.now() / 1000) * 1.5; + performer.update(h, this.characters); + + // Others watch + for (let i = 1; i < this.characters.length; i++) { + this.characters[i].state = "spectator"; + this.characters[i].update(h, this.characters); + } + } + } + + /** + * Change the performance mode. + * @param {'demonstration'|'combat'|'spectator'|'free'} newMode + */ + setMode(newMode) { + this.mode = newMode; + for (const char of this.characters) { + char._setState("idle"); + } + } + + /** + * Get the character at a specific index. + * @param {number} index + */ + getCharacter(index) { + return this.characters[index % this.characters.length]; + } + + /** + * Trigger an action on a specific character. + * @param {number} index + * @param {'spin'|'jump'|'wave'|'bow'} action + */ + performAction(index, action) { + const char = this.getCharacter(index); + if (char) { + char.performAction(action); + char.autoControl = true; + } + } + + /** + * Move a character to a specific position. + * @param {number} index + * @param {number} x + * @param {number} z + */ + setPosition(index, x, z) { + const char = this.getCharacter(index); + if (char) { + char.controller.position.set(x, 0, z); + char.controller.position.y = this.terrain.heightAt(x, z); + char.autoControl = false; + } + } + + /** + * Dispose all characters and their resources. + */ + dispose() { + for (const char of this.characters) { + // Note: CharacterController doesn't have a dispose method in the current codebase + // The actual Character (figure) would need to be disposed if separate + } + this.characters = []; + } +} diff --git a/src/performance/showSelector.js b/src/performance/showSelector.js new file mode 100644 index 0000000..4806a72 --- /dev/null +++ b/src/performance/showSelector.js @@ -0,0 +1,396 @@ +/** + * Show Selector UI + * + * Provides a user interface for selecting and controlling automated performances. + */ + +import { S } from "../core/settings.js"; + +const CSS = ` +#show-selector { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 90; + display: flex; + gap: 12px; + padding: 12px 16px; + background: rgba(8, 12, 19, 0.85); + backdrop-filter: blur(18px); + border-radius: 12px; + border: 1px solid rgba(143, 196, 232, 0.2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +#show-selector.show { + display: flex; +} + +#show-selector.hide { + display: none; +} + +.show-btn { + padding: 8px 16px; + background: rgba(143, 196, 232, 0.1); + border: 1px solid rgba(143, 196, 232, 0.3); + border-radius: 6px; + color: #e6eff8; + font: 11px/1 ui-monospace, "SF Mono", "Cascadia Mono", monospace; + cursor: pointer; + transition: all 140ms ease; + letter-spacing: 0.1em; +} + +.show-btn:hover { + background: rgba(143, 196, 232, 0.2); + border-color: rgba(143, 196, 232, 0.5); +} + +.show-btn.active { + background: rgba(143, 196, 232, 0.3); + border-color: rgba(143, 196, 232, 0.7); + box-shadow: 0 0 12px rgba(143, 196, 232, 0.3); +} + +.show-btn .label { + display: block; + font-size: 10px; + color: #8fa3b8; + margin-top: 2px; + font-weight: normal; +} + +.show-controls { + display: flex; + gap: 8px; + align-items: center; + border-left: 1px solid rgba(143, 196, 232, 0.2); + padding-left: 16px; +} + +.control-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(143, 196, 232, 0.1); + border: 1px solid rgba(143, 196, 232, 0.3); + border-radius: 6px; + color: #e6eff8; + cursor: pointer; + font-size: 16px; + transition: all 140ms ease; +} + +.control-btn:hover { + background: rgba(143, 196, 232, 0.2); +} + +.control-btn:active { + transform: scale(0.95); +} + +.control-btn.active { + background: rgba(143, 196, 232, 0.3); + border-color: rgba(143, 196, 232, 0.5); +} + +#show-status { + display: flex; + flex-direction: column; + min-width: 120px; + border-left: 1px solid rgba(143, 196, 232, 0.2); + padding-left: 16px; +} + +#show-name { + font-size: 10px; + color: #8fa3b8; + text-transform: uppercase; + letter-spacing: 0.15em; +} + +#show-timer { + font-size: 12px; + color: #e6eff8; + font-variant-numeric: tabular-nums; +} + +#show-progress { + width: 100%; + height: 4px; + margin-top: 4px; + background: rgba(0, 0, 0, 0.3); + border-radius: 2px; + overflow: hidden; +} + +#show-progress-bar { + height: 100%; + width: 0%; + background: linear-gradient(90deg, #8fc4e8, #eaf4ff); + border-radius: 2px; + transition: width 100ms linear; +} + +.show-info { + display: flex; + gap: 8px; +} + +.show-char { + width: 10px; + height: 10px; + border-radius: 50%; + background: rgba(143, 196, 232, 0.3); + border: 1px solid rgba(143, 196, 232, 0.5); +} + +.show-char.active { + background: #8fc4e8; + box-shadow: 0 0 8px rgba(143, 196, 232, 0.6); +} +`; + +// ------------------------------------------------------------------ Show Selector UI + +export class ShowSelectorUI { + constructor() { + this.container = null; + this.showNameEl = null; + this.timerEl = null; + this.progressBar = null; + this.charDots = []; + + this.isPlaying = false; + this.isPaused = false; + this.currentShow = null; + this.startTime = 0; + this.pausedAt = 0; + this.totalPausedTime = 0; + + this._init(); + } + + _init() { + // Create container + this.container = document.createElement("div"); + this.container.id = "show-selector"; + this.container.className = "hide"; + this.container.innerHTML = ` +
+ + + + +
+
+ + + +
+
+
+
+
+
+
+
+
+
Ready
+
00:00
+
+
+
+
+ `; + + // Add CSS + const style = document.createElement("style"); + style.textContent = CSS; + document.head.appendChild(style); + + document.body.appendChild(this.container); + + // Cache elements + this.showNameEl = this.container.querySelector("#show-name"); + this.timerEl = this.container.querySelector("#show-timer"); + this.progressBar = this.container.querySelector("#show-progress-bar"); + this.charDots = Array.from(this.container.querySelectorAll(".show-char")); + + // Setup event listeners + this.container.querySelectorAll(".show-btn").forEach(btn => { + btn.addEventListener("click", (e) => { + this.container.querySelectorAll(".show-btn").forEach(b => b.classList.remove("active")); + e.currentTarget.classList.add("active"); + this._selectShow(e.currentTarget.dataset.show); + }); + }); + + this.container.querySelector("#btn-play-pause").addEventListener("click", () => this._togglePlayPause()); + this.container.querySelector("#btn-stop").addEventListener("click", () => this._stopShow()); + this.container.querySelector("#btn-restart").addEventListener("click", () => this._restartShow()); + + // Keyboard shortcuts + window.addEventListener("keydown", (e) => this._handleKey(e)); + + this.hide(); + } + + show() { + this.container.classList.remove("hide"); + this.container.classList.add("show"); + } + + hide() { + this.container.classList.remove("show"); + this.container.classList.add("hide"); + } + + toggle() { + if (this.container.classList.contains("show")) { + this.hide(); + } else { + this.show(); + } + } + + _selectShow(showName) { + this.currentShow = showName; + + // Update character dots + const numChars = showName === "free" ? 8 : 5; + for (let i = 0; i < this.charDots.length; i++) { + if (i < numChars) { + this.charDots[i].classList.add("active"); + this.charDots[i].title = `Character ${i + 1}`; + } else { + this.charDots[i].classList.remove("active"); + this.charDots[i].title = ""; + } + } + } + + _togglePlayPause() { + this.isPaused = !this.isPaused; + const btn = this.container.querySelector("#btn-play-pause"); + + if (this.isPaused) { + btn.textContent = "▶"; + this.pausedAt = Date.now(); + this.showNameEl.textContent = "Paused"; + } else { + btn.textContent = "❚❚"; + this.totalPausedTime += Date.now() - this.pausedAt; + } + } + + _stopShow() { + this.isPlaying = false; + this.showNameEl.textContent = "Ready"; + this.timerEl.textContent = "00:00"; + this.progressBar.style.width = "0%"; + this.charDots.forEach(dot => dot.classList.remove("active")); + } + + _restartShow() { + this._stopShow(); + this._startShow(); + } + + _startShow() { + if (!this.currentShow) return; + + this.isPlaying = true; + this.isPaused = false; + this.startTime = Date.now(); + this.totalPausedTime = 0; + this.pausedAt = 0; + + const btn = this.container.querySelector("#btn-play-pause"); + btn.textContent = "❚❚"; + + // Update UI based on show type + const showNames = { + demonstration: "Character Demonstration", + combat: "AI Combat Simulation", + ceremonial: "Ceremonial Performance", + free: "Free Movement" + }; + this.showNameEl.textContent = showNames[this.currentShow] || this.currentShow; + } + + _handleKey(e) { + if (!this.container.classList.contains("show")) return; + + switch (e.key) { + case " ": + this._togglePlayPause(); + break; + case "Escape": + this._stopShow(); + break; + case "Enter": + this._restartShow(); + break; + } + } + + /** + * Update the UI with current show progress. + * @param {number} elapsed Total elapsed time in seconds + * @param {number} totalDuration Total duration in seconds + */ + updateProgress(elapsed, totalDuration) { + if (!this.isPlaying || this.isPaused) return; + + const elapsedStr = this._formatTime(elapsed); + const totalStr = this._formatTime(totalDuration); + this.timerEl.textContent = `${elapsedStr} / ${totalStr}`; + + const progress = Math.min((elapsed / totalDuration) * 100, 100); + this.progressBar.style.width = `${progress}%`; + } + + _formatTime(seconds) { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; + } + + /** + * Set active character for visualization. + * @param {number} index Character index (0-4) + */ + setActiveCharacter(index) { + if (index < 0 || index >= this.charDots.length) return; + for (let i = 0; i < this.charDots.length; i++) { + if (i === index) { + this.charDots[i].classList.add("active"); + } else if (this.charDots[i].classList.contains("active")) { + this.charDots[i].classList.remove("active"); + } + } + } +} diff --git a/src/scene/sceneManager.js b/src/scene/sceneManager.js new file mode 100644 index 0000000..83c17fa --- /dev/null +++ b/src/scene/sceneManager.js @@ -0,0 +1,475 @@ +/** + * Scene Manager - Handles different terrain types and environments. + * + * Supports multiple scene types: + * - Snow (default): Classic snowy landscape with dunes and sastrugi + * - Desert: Sandy dunes, rocky outcrops, warm lighting + * - Ocean: Coastal with waves, beaches, and water bodies + * - Forest: Trees, vegetation, dense canopy + * - Urban: City streets, buildings, paved surfaces + */ + +import { Color3, Color4 } from "@babylonjs/core/Maths/math"; +import { S, set, onChange } from "../core/settings.js"; + +// ------------------------------------------------------------------ Scene Types + +/** + * @typedef {'snow'|'desert'|'ocean'|'forest'|'urban'} SceneType + */ + +/** + * Scene configuration interface. + * @typedef {{ + * name: string, + * type: SceneType, + * skyColor: Color4, + * fogColor: Color4, + * sunColor: Color3, + * sunElevation: number, + * windDirection: number, + * macroHeightScale: number, + * sastrugiStrength: number, + * glintIntensity: number, + * detailNormalStrength: number, + * sssStrength: number, + * waterLevel: number, + * showVegetation: boolean, + * showBuildings: boolean, + * windStrength: number + * }} SceneConfig + */ + +/** + * Base scene configuration - the default snow field. + */ +const SNOW_CONFIG = { + name: "Snow Field", + type: "snow", + skyColor: new Color4(0.02, 0.03, 0.05, 1), + fogColor: new Color4(0.02, 0.05, 0.10, 1), + sunColor: new Color3(1.0, 0.95, 0.85), + sunElevation: 13.0, + windDirection: 42, + macroHeightScale: 1.0, + sastrugiStrength: 1.0, + glintIntensity: 0.55, + detailNormalStrength: 1.0, + sssStrength: 1.0, + waterLevel: -5, + showVegetation: false, + showBuildings: false, + windStrength: 1.0, + terrainSeed: 0 +}; + +/** + * Desert scene - sandy dunes, rocky outcrops, warm lighting. + */ +const DESERT_CONFIG = { + name: "Desert", + type: "desert", + skyColor: new Color4(0.15, 0.12, 0.08, 1), + fogColor: new Color4(0.25, 0.20, 0.15, 1), + sunColor: new Color3(1.0, 0.85, 0.65), + sunElevation: 25.0, + windDirection: 130, + macroHeightScale: 1.2, + sastrugiStrength: 0.6, + glintIntensity: 0.3, + detailNormalStrength: 0.8, + sssStrength: 0.5, + waterLevel: -2, + showVegetation: false, + showBuildings: false, + windStrength: 1.5, + terrainSeed: 1 +}; + +/** + * Ocean scene - coastal with waves, beaches, and water bodies. + */ +const OCEAN_CONFIG = { + name: "Ocean Coast", + type: "ocean", + skyColor: new Color4(0.02, 0.10, 0.20, 1), + fogColor: new Color4(0.10, 0.25, 0.40, 1), + sunColor: new Color3(0.95, 0.95, 1.0), + sunElevation: 18.0, + windDirection: 270, + macroHeightScale: 0.8, + sastrugiStrength: 0.4, + glintIntensity: 0.7, + detailNormalStrength: 0.9, + sssStrength: 0.8, + waterLevel: 0, + showVegetation: false, + showBuildings: false, + windStrength: 2.0, + terrainSeed: 2 +}; + +/** + * Forest scene - trees, vegetation, dense canopy. + */ +const FOREST_CONFIG = { + name: "Forest", + type: "forest", + skyColor: new Color4(0.05, 0.10, 0.08, 1), + fogColor: new Color4(0.10, 0.20, 0.15, 1), + sunColor: new Color3(0.85, 0.90, 0.85), + sunElevation: 35.0, + windDirection: 90, + macroHeightScale: 0.6, + sastrugiStrength: 0.3, + glintIntensity: 0.2, + detailNormalStrength: 0.7, + sssStrength: 0.9, + waterLevel: -3, + showVegetation: true, + showBuildings: false, + windStrength: 0.5, + terrainSeed: 3 +}; + +/** + * Urban scene - city streets, buildings, paved surfaces. + */ +const URBAN_CONFIG = { + name: "Urban Street", + type: "urban", + skyColor: new Color4(0.08, 0.08, 0.10, 1), + fogColor: new Color4(0.15, 0.15, 0.20, 1), + sunColor: new Color3(0.95, 0.95, 1.0), + sunElevation: 20.0, + windDirection: 45, + macroHeightScale: 0.3, + sastrugiStrength: 0.1, + glintIntensity: 0.4, + detailNormalStrength: 1.2, + sssStrength: 0.3, + waterLevel: -1, + showVegetation: true, + showBuildings: true, + windStrength: 0.8, + terrainSeed: 4 +}; + +// ------------------------------------------------------------------ Scene Manager + +/** + * Manages different scene types and their configurations. + */ +export class SceneManager { + constructor() { + /** @type {Record} */ + this.scenes = { + snow: SNOW_CONFIG, + desert: DESERT_CONFIG, + ocean: OCEAN_CONFIG, + forest: FOREST_CONFIG, + urban: URBAN_CONFIG + }; + + this.currentScene = SNOW_CONFIG; + this.sceneIndex = 0; + this.scenesList = Object.keys(this.scenes); + } + + /** + * Get all available scene names. + */ + getSceneNames() { + return this.scenesList.map(k => this.scenes[k].name); + } + + /** + * Get scene config by type. + * @param {SceneType} type + */ + getScene(type) { + return this.scenes[type]; + } + + /** + * Get current scene config. + */ + getCurrentScene() { + return this.currentScene; + } + + /** + * Switch to a scene by name. + * @param {string} name + */ + switchByName(name) { + for (const key in this.scenes) { + if (this.scenes[key].name === name) { + this.switchTo(key); + return true; + } + } + return false; + } + + /** + * Switch to a scene by type. + * @param {SceneType} type + */ + switchTo(type) { + const scene = this.scenes[type]; + if (!scene) return false; + + this.currentScene = scene; + this._applyConfig(scene); + return true; + } + + /** + * Switch to next scene in sequence. + */ + switchNext() { + this.sceneIndex = (this.sceneIndex + 1) % this.scenesList.length; + const type = this.scenesList[this.sceneIndex]; + this.switchTo(type); + return this.currentScene; + } + + /** + * Switch to previous scene in sequence. + */ + switchPrev() { + this.sceneIndex = (this.sceneIndex - 1 + this.scenesList.length) % this.scenesList.length; + const type = this.scenesList[this.sceneIndex]; + this.switchTo(type); + return this.currentScene; + } + + /** + * Apply scene configuration to global settings. + * @param {SceneConfig} config + */ + _applyConfig(config) { + // Apply terrain settings + S.macroHeightScale = config.macroHeightScale; + S.sastrugiStrength = config.sastrugiStrength; + S.glintIntensity = config.glintIntensity; + S.detailNormalStrength = config.detailNormalStrength; + S.sssStrength = config.sssStrength; + S.windDirection = config.windDirection; + S.windStrength = config.windStrength; + + // Apply lighting settings + S.sunElevation = config.sunElevation; + // Convert Color3 to something the sky can use + this._updateSunColor(config.sunColor); + + // Apply fog settings + this._updateFogSettings(config); + + // Update scene clear color + this._updateClearColor(config.skyColor); + + console.log(`Switched to scene: ${config.name}`); + } + + _updateSunColor(color) { + // Update sun properties based on scene type + // This would be integrated with the sky rendering system + } + + _updateFogSettings(config) { + // Update fog settings based on scene type + // This would be integrated with the sky rendering system + } + + _updateClearColor(color) { + // Update the scene clear color + } + + /** + * Get scene info for UI display. + */ + getSceneInfo() { + return { + current: this.currentScene.name, + index: this.sceneIndex, + total: this.scenesList.length, + available: this.getSceneNames() + }; + } + + /** + * Reset to default snow scene. + */ + reset() { + this.switchTo("snow"); + this.sceneIndex = 0; + } +} + +// ------------------------------------------------------------------ Scene Switcher UI + +/** + * Scene switcher UI component. + */ +export class SceneSwitcherUI { + constructor() { + this.container = null; + this.currentSceneEl = null; + this.nextBtn = null; + this.prevBtn = null; + this.show = true; + + this._init(); + } + + _init() { + const CSS = ` +#scene-switcher { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 90; + display: flex; + gap: 8px; + padding: 8px 12px; + background: rgba(8, 12, 19, 0.85); + backdrop-filter: blur(18px); + border-radius: 50px; + border: 1px solid rgba(143, 196, 232, 0.2); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +#scene-switcher.show { + display: flex; +} + +#scene-switcher.hide { + display: none; +} + +.scene-btn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(143, 196, 232, 0.1); + border: 1px solid rgba(143, 196, 232, 0.3); + border-radius: 50%; + color: #e6eff8; + font-size: 14px; + cursor: pointer; + transition: all 140ms ease; +} + +.scene-btn:hover { + background: rgba(143, 196, 232, 0.2); + transform: scale(1.1); +} + +.scene-btn:active { + transform: scale(0.95); +} + +.scene-indicator { + min-width: 100px; + text-align: center; + color: #e6eff8; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.15em; +} + +.scene-name { + display: block; + font-size: 11px; + color: #8fa3b8; + font-weight: normal; + margin-top: 2px; +} + `; + + const style = document.createElement("style"); + style.textContent = CSS; + document.head.appendChild(style); + + this.container = document.createElement("div"); + this.container.id = "scene-switcher"; + this.container.className = "hide"; + this.container.innerHTML = ` + +
+ Snow Field + Current scene +
+ + `; + + document.body.appendChild(this.container); + + this.currentSceneEl = this.container.querySelector("#scene-current"); + this.nextBtn = this.container.querySelector("#btn-next"); + this.prevBtn = this.container.querySelector("#btn-prev"); + + this.nextBtn.addEventListener("click", () => this._nextScene()); + this.prevBtn.addEventListener("click", () => this._prevScene()); + } + + showUI() { + this.container.classList.remove("hide"); + this.container.classList.add("show"); + this.show = true; + } + + hide() { + this.container.classList.remove("show"); + this.container.classList.add("hide"); + this.show = false; + } + + toggle(SceneManager) { + if (this.show) { + this.hide(); + } else { + this.showUI(); + if (SceneManager) { + this._updateSceneName(SceneManager.getCurrentScene().name); + } + } + } + + _updateSceneName(name) { + this.currentSceneEl.textContent = name; + } + + _nextScene() { + // This will be called from SceneManager + } + + _prevScene() { + // This will be called from SceneManager + } + + /** + * Sync with SceneManager. + * @param {SceneManager} manager + */ + sync(manager) { + this._updateSceneName(manager.getCurrentScene().name); + this.nextBtn.onclick = () => { + manager.switchNext(); + this._updateSceneName(manager.getCurrentScene().name); + }; + this.prevBtn.onclick = () => { + manager.switchPrev(); + this._updateSceneName(manager.getCurrentScene().name); + }; + } +}