diff --git a/docs/technical/scene-runtime.md b/docs/technical/scene-runtime.md index 9c9bba7..fcbe033 100644 --- a/docs/technical/scene-runtime.md +++ b/docs/technical/scene-runtime.md @@ -72,14 +72,23 @@ It tracks: - `gameMapLoaded`: map data and visible map nodes settled - `gameStageLoaded`: Rapier gameplay stage mounted - `showGameStage`: true when the map is ready enough to mount gameplay content -- `gameplayReady`: true when map, stage, and octree are all ready +- `shadowsReady`: renderer, shadow lights, and scene matrices have been forced once after the scene is mounted +- `gameplayReady`: true when map, stage, octree, and the shadow warmup are all ready -The final game-scene readiness condition is: +The base game-scene readiness condition before the shadow warmup is: ```ts showGameStage && gameStageLoaded && octree !== null; ``` +After that condition is met, `SceneShadowWarmup` runs one final loading step: + +```txt +Activation des ombres -> Ombres prêtes -> Gameplay prêt +``` + +This keeps the loading overlay visible until the renderer shadow map, shadow-casting light, and mounted scene graph have all been explicitly refreshed. + The debug physics scene is ready when: ```ts diff --git a/public/assets/bg-site.png b/public/assets/bg-site.png new file mode 100644 index 0000000..d3a1e36 --- /dev/null +++ b/public/assets/bg-site.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e32d667a6e17ca75437f7fde9bad637bfd691543f14e48d7bca82f95f993414 +size 1469658 diff --git a/public/assets/UI/cassé.webm b/public/assets/world/UI/cassé.webm similarity index 100% rename from public/assets/UI/cassé.webm rename to public/assets/world/UI/cassé.webm diff --git a/public/assets/UI/centrale.webm b/public/assets/world/UI/centrale.webm similarity index 100% rename from public/assets/UI/centrale.webm rename to public/assets/world/UI/centrale.webm diff --git a/public/assets/world/UI/ebike-mission-notification.png b/public/assets/world/UI/ebike-mission-notification.png new file mode 100644 index 0000000..6b01a51 --- /dev/null +++ b/public/assets/world/UI/ebike-mission-notification.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e2320742fb3cd9723da9e8e1a5946e03ef5c282524cf38dcd7e80ce44cbeb409 +size 7581 diff --git a/public/assets/UI/ebike.webm b/public/assets/world/UI/ebike.webm similarity index 100% rename from public/assets/UI/ebike.webm rename to public/assets/world/UI/ebike.webm diff --git a/public/assets/world/UI/farm-mission-notification.png b/public/assets/world/UI/farm-mission-notification.png new file mode 100644 index 0000000..ec2dfd1 --- /dev/null +++ b/public/assets/world/UI/farm-mission-notification.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bee91650a877eeabb339dbf3f3c3195d5e75d408f70d56ce6ed12014ae2ec0fd +size 7821 diff --git a/public/assets/UI/interagir.webm b/public/assets/world/UI/interagir.webm similarity index 100% rename from public/assets/UI/interagir.webm rename to public/assets/world/UI/interagir.webm diff --git a/public/assets/UI/laferme.webm b/public/assets/world/UI/laferme.webm similarity index 100% rename from public/assets/UI/laferme.webm rename to public/assets/world/UI/laferme.webm diff --git a/public/assets/world/UI/pylon-mission-notification.png b/public/assets/world/UI/pylon-mission-notification.png new file mode 100644 index 0000000..9875053 --- /dev/null +++ b/public/assets/world/UI/pylon-mission-notification.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f6b8238095f9443f52ae52a015720903349e3064953cb72b900f387f07ccef1 +size 9567 diff --git a/public/assets/gps/map_background.png b/public/assets/world/gps/map_background.png similarity index 100% rename from public/assets/gps/map_background.png rename to public/assets/world/gps/map_background.png diff --git a/public/assets/cinematics/intro.mp4 b/public/cinematics/intro.mp4 similarity index 100% rename from public/assets/cinematics/intro.mp4 rename to public/cinematics/intro.mp4 diff --git a/public/assets/cinematics/outro.mp4 b/public/cinematics/outro.mp4 similarity index 100% rename from public/assets/cinematics/outro.mp4 rename to public/cinematics/outro.mp4 diff --git a/public/fonts/NersansOne.woff b/public/fonts/NersansOne.woff new file mode 100644 index 0000000..b030e6f Binary files /dev/null and b/public/fonts/NersansOne.woff differ diff --git a/public/fonts/NersansOne.woff2 b/public/fonts/NersansOne.woff2 new file mode 100644 index 0000000..f2d4bb8 Binary files /dev/null and b/public/fonts/NersansOne.woff2 differ diff --git a/public/map.json b/public/map.json index df91c1d..1b3b11a 100644 --- a/public/map.json +++ b/public/map.json @@ -584,22 +584,6 @@ } ] }, - { - "name": "arbre", - "type": "Object3D", - "position": [50.072, 2.2583, 78.7082], - "rotation": [0, 0, 0], - "scale": [1, 1, 1], - "children": [ - { - "name": "arbre", - "type": "Mesh", - "position": [50.072, 2.2583, 78.7082], - "rotation": [0, 0, 0], - "scale": [1, 1, 1] - } - ] - }, { "name": "arbre", "type": "Object3D", @@ -888,22 +872,6 @@ } ] }, - { - "name": "arbre", - "type": "Object3D", - "position": [59.1794, 2.2557, 73.349], - "rotation": [0, 0, 0], - "scale": [1, 1, 1], - "children": [ - { - "name": "arbre", - "type": "Mesh", - "position": [59.1794, 2.2557, 73.349], - "rotation": [0, 0, 0], - "scale": [1, 1, 1] - } - ] - }, { "name": "arbre", "type": "Object3D", @@ -1112,22 +1080,6 @@ } ] }, - { - "name": "arbre", - "type": "Object3D", - "position": [74.0452, 2.309, 59.2374], - "rotation": [0, 0, 0], - "scale": [1, 1, 1], - "children": [ - { - "name": "arbre", - "type": "Mesh", - "position": [74.0452, 2.309, 59.2374], - "rotation": [0, 0, 0], - "scale": [1, 1, 1] - } - ] - }, { "name": "arbre", "type": "Object3D", @@ -2754,22 +2706,6 @@ } ] }, - { - "name": "buisson", - "type": "Object3D", - "position": [73.7334, 1.1132, 54.1382], - "rotation": [0, 0, 0], - "scale": [1, 1, 1], - "children": [ - { - "name": "buisson", - "type": "Mesh", - "position": [73.7334, 1.1132, 54.1382], - "rotation": [0, 0, 0], - "scale": [1, 1, 1] - } - ] - }, { "name": "buisson", "type": "Object3D", @@ -3330,22 +3266,6 @@ } ] }, - { - "name": "buisson", - "type": "Object3D", - "position": [67.9046, 0.5562, 74.8395], - "rotation": [0, 0, 0], - "scale": [1, 1, 1], - "children": [ - { - "name": "buisson", - "type": "Mesh", - "position": [67.9046, 0.5562, 74.8395], - "rotation": [0, 0, 0], - "scale": [1, 1, 1] - } - ] - }, { "name": "buisson", "type": "Object3D", @@ -3714,22 +3634,6 @@ } ] }, - { - "name": "buisson", - "type": "Object3D", - "position": [73.5205, 0.3748, 75.9136], - "rotation": [0, 0, 0], - "scale": [1, 1, 1], - "children": [ - { - "name": "buisson", - "type": "Mesh", - "position": [73.5205, 0.3748, 75.9136], - "rotation": [0, 0, 0], - "scale": [1, 1, 1] - } - ] - }, { "name": "buisson", "type": "Object3D", @@ -3858,22 +3762,6 @@ } ] }, - { - "name": "buisson", - "type": "Object3D", - "position": [66.999, 1.7223, 48.3983], - "rotation": [0, 0, 0], - "scale": [1, 1, 1], - "children": [ - { - "name": "buisson", - "type": "Mesh", - "position": [66.999, 1.7223, 48.3983], - "rotation": [0, 0, 0], - "scale": [1, 1, 1] - } - ] - }, { "name": "buisson", "type": "Object3D", @@ -4914,22 +4802,6 @@ } ] }, - { - "name": "buisson", - "type": "Object3D", - "position": [61.3924, 0.4621, 82.2195], - "rotation": [0, 0, 0], - "scale": [1, 1, 1], - "children": [ - { - "name": "buisson", - "type": "Mesh", - "position": [61.3924, 0.4621, 82.2195], - "rotation": [0, 0, 0], - "scale": [1, 1, 1] - } - ] - }, { "name": "buisson", "type": "Object3D", @@ -5122,22 +4994,6 @@ } ] }, - { - "name": "buisson", - "type": "Object3D", - "position": [61.1082, 0.6236, 77.7642], - "rotation": [0, 0, 0], - "scale": [1, 1, 1], - "children": [ - { - "name": "buisson", - "type": "Mesh", - "position": [61.1082, 0.6236, 77.7642], - "rotation": [0, 0, 0], - "scale": [1, 1, 1] - } - ] - }, { "name": "buisson", "type": "Object3D", @@ -5170,22 +5026,6 @@ } ] }, - { - "name": "buisson", - "type": "Object3D", - "position": [53.1033, 1.6054, 63.3842], - "rotation": [0, 0, 0], - "scale": [1, 1, 1], - "children": [ - { - "name": "buisson", - "type": "Mesh", - "position": [53.1033, 1.6054, 63.3842], - "rotation": [0, 0, 0], - "scale": [1, 1, 1] - } - ] - }, { "name": "buisson", "type": "Object3D", @@ -5266,22 +5106,6 @@ } ] }, - { - "name": "buisson", - "type": "Object3D", - "position": [59.647, 1.5484, 59.429], - "rotation": [0, 0, 0], - "scale": [1, 1, 1], - "children": [ - { - "name": "buisson", - "type": "Mesh", - "position": [59.647, 1.5484, 59.429], - "rotation": [0, 0, 0], - "scale": [1, 1, 1] - } - ] - }, { "name": "buisson", "type": "Object3D", @@ -5410,22 +5234,6 @@ } ] }, - { - "name": "buisson", - "type": "Object3D", - "position": [69.2496, 0.6286, 71.5478], - "rotation": [0, 0, 0], - "scale": [1, 1, 1], - "children": [ - { - "name": "buisson", - "type": "Mesh", - "position": [69.2496, 0.6286, 71.5478], - "rotation": [0, 0, 0], - "scale": [1, 1, 1] - } - ] - }, { "name": "buisson", "type": "Object3D", @@ -6226,22 +6034,6 @@ } ] }, - { - "name": "buisson", - "type": "Object3D", - "position": [58.3126, 0.686, 77.9828], - "rotation": [0, 0, 0], - "scale": [1, 1, 1], - "children": [ - { - "name": "buisson", - "type": "Mesh", - "position": [58.3126, 0.686, 77.9828], - "rotation": [0, 0, 0], - "scale": [1, 1, 1] - } - ] - }, { "name": "buisson", "type": "Object3D", @@ -37602,23 +37394,6 @@ "rotation": [0, 0, 0], "scale": [1, 1, 1], "children": [ - { - "name": "ebike", - "type": "Object3D", - "role": "group", - "position": [0, 0, 0], - "rotation": [0, 0, 0], - "scale": [1, 1, 1], - "children": [ - { - "name": "ebike", - "type": "Object3D", - "position": [42.2399, 4.5484, 34.6468], - "rotation": [0, 0, 0], - "scale": [1, 1, 1] - } - ] - }, { "name": "zone1_residence", "type": "Object3D", @@ -40477,14 +40252,14 @@ "name": "lafabrik", "type": "Object3D", "position": [59.4973, 6.2746, 64.6354], - "rotation": [-3.1416, -0.7309, -3.1416], + "rotation": [-3.1416, 2.4107, -3.1416], "scale": [1, 2, 1], "children": [ { "name": "lafabrik", "type": "Mesh", "position": [59.4973, 6.2746, 64.6354], - "rotation": [-3.1416, -0.7309, -3.1416], + "rotation": [-3.1416, 2.4107, -3.1416], "scale": [1, 2, 1] } ] diff --git a/public/models/gant_r/model.gltf b/public/models/gant_r/model.gltf index 3618596..6f22319 100644 --- a/public/models/gant_r/model.gltf +++ b/public/models/gant_r/model.gltf @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c82eb9596e0829193b8c860670ff9cad959dfcced8d17183c2347346870d267b +oid sha256:b51af270cf5c4900b17dfefa48f0e622d1b5965214a89df73fbf4e78d65da5ba size 31499 diff --git a/public/models/pylone/model.gltf b/public/models/pylone/model.gltf index 7ed6228..e72d715 100644 --- a/public/models/pylone/model.gltf +++ b/public/models/pylone/model.gltf @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:187bb31dbfbb25fb08ed41463ec81be9e6ff0cba6bc0e06aa0e622ebe16486e2 -size 8555 +oid sha256:06f375b482357753b1cfb212fa4f8398e9da1aa234f8259a7b1e0df9d7572afd +size 8540 diff --git a/public/sounds/dialogue/dialogues.json b/public/sounds/dialogue/dialogues.json index af596ef..2f30ef1 100644 --- a/public/sounds/dialogue/dialogues.json +++ b/public/sounds/dialogue/dialogues.json @@ -31,14 +31,13 @@ "id": "narrateur_bienvenueaaltera", "voice": "narrateur", "audio": "/sounds/dialogue/narrateur_bienvenueaaltera.mp3", - "subtitleCueIndex": 1, - "timecode": 0 + "subtitleCueIndex": 1 }, { "id": "narrateur_intro_prenom", "voice": "narrateur", "audio": "/sounds/dialogue/narrateur_intro_prenom.mp3", - "subtitleCueIndex": 2 + "subtitleCueIndices": [1, 2] }, { "id": "narrateur_intro_apresprenom", diff --git a/public/sounds/dialogue/subtitles/fr/narrateur.srt b/public/sounds/dialogue/subtitles/fr/narrateur.srt index ab3b902..cbb89c2 100644 --- a/public/sounds/dialogue/subtitles/fr/narrateur.srt +++ b/public/sounds/dialogue/subtitles/fr/narrateur.srt @@ -1,9 +1,9 @@ 1 -00:00:00,000 --> 00:00:02,760 +00:00:00,000 --> 00:00:09,000 Bonjour à toi, futur habitant d'Altéra ! Aujourd'hui tu vas découvrir le rôle de technicien au sein de La Fabrik qui s'occupe des technologies et réparation Low-Tech. 2 -00:00:00,000 --> 00:00:11,592 +00:00:09,000 --> 00:00:11,592 Avant de commencer, comment tu t'appelles ? 3 diff --git a/src/components/ebike/Ebike.tsx b/src/components/ebike/Ebike.tsx index b7104c4..b49e3c4 100644 --- a/src/components/ebike/Ebike.tsx +++ b/src/components/ebike/Ebike.tsx @@ -6,12 +6,14 @@ import { InteractableObject } from "@/components/three/interaction/InteractableO import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useDebugFolder } from "@/hooks/debug/useDebugFolder"; +import { useEbikeSounds } from "@/hooks/ebike/useEbikeSounds"; import { animateCameraTransformTransition } from "@/world/GameCinematics"; import { useGameStore } from "@/managers/stores/useGameStore"; import { PLAYER_EYE_HEIGHT } from "@/data/player/playerConfig"; import { EBIKE_CAMERA_TRANSFORM, EBIKE_DROP_PLAYER_TRANSFORM, + EBIKE_WORLD_ROTATION_Y, } from "@/data/ebike/ebikeConfig"; import type { Vector3Tuple } from "@/types/three/three"; import "@/types/ebike/ebikeWindow"; @@ -31,7 +33,10 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { const model = useClonedObject(scene); const movementMode = useGameStore((state) => state.player.movementMode); const mainState = useGameStore((state) => state.mainState); + const ebikeStep = useGameStore((state) => state.ebike.currentStep); + const setMissionStep = useGameStore((state) => state.setMissionStep); const camera = useThree((state) => state.camera); + const updateEbikeSounds = useEbikeSounds(); // Map active mainState to target repair zone coordinate const destPos = useMemo(() => { @@ -67,7 +72,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { position[1] - PLAYER_EYE_HEIGHT, position[2], ]); - const restingRotationRef = useRef(0); + const restingRotationRef = useRef(EBIKE_WORLD_ROTATION_Y); const forkRef = useRef(null); // State for debug visualization (synced from refs during useFrame) @@ -102,6 +107,12 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { useFrame((_, delta) => { if (groupRef.current) { if (movementMode === "ebike") { + updateEbikeSounds({ + mounted: true, + driving: window.ebikeDriveInputActive === true, + breakdown: window.ebikeBreakdownActive === true, + }); + restingPositionRef.current = [ groupRef.current.position.x, groupRef.current.position.y, @@ -133,6 +144,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { setDebugRestingPosition([...restingPositionRef.current]); } } else { + updateEbikeSounds({ mounted: false, driving: false, breakdown: false }); groupRef.current.position.set(...restingPositionRef.current); groupRef.current.rotation.set(0, restingRotationRef.current, 0); @@ -159,7 +171,14 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { ]; const handleInteract = useCallback((): void => { + if (window.ebikeBreakdownActive === true) return; + if (movementMode === "walk") { + if (mainState === "ebike" && ebikeStep === "waiting") { + setMissionStep("ebike", "inspected"); + return; + } + const cameraOffset = new THREE.Vector3( ...EBIKE_CAMERA_TRANSFORM.position, ); @@ -213,7 +232,7 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { useGameStore.getState().setPlayerMovementMode("walk"); }); } - }, [movementMode, camera, position]); + }, [movementMode, mainState, ebikeStep, setMissionStep, camera, position]); // Store handleInteract in a ref for use in debug folder callback const handleInteractRef = useRef(handleInteract); @@ -239,12 +258,20 @@ export function Ebike({ position }: EbikeProps): React.JSX.Element { return ( <> - + dialogue.voice === voice) - .map((dialogue) => dialogue.subtitleCueIndex); + .flatMap((dialogue) => getDialogueCueIndices(dialogue)); return Math.max(0, ...cueIndexes) + 1; } @@ -93,12 +97,15 @@ async function createFrenchSrtCue( manifest: DialogueManifest, dialogue: DialogueDefinition, ): Promise { + const firstCueIndex = getDialogueFirstCueIndex(dialogue); + if (firstCueIndex === undefined) return; + const srtPath = getFrenchSrtPath(dialogue.voice); const response = await fetch(srtPath); const content = response.ok ? await response.text() : ""; const nextContent = appendSrtCueIfMissing( content, - dialogue.subtitleCueIndex, + firstCueIndex, getVoiceSpeaker(manifest, dialogue.voice), ); @@ -122,7 +129,8 @@ function getManifestErrors(manifest: DialogueManifest | null): string[] { errors.push(`${label}: audio doit commencer par /sounds/dialogue/.`); } - if (!Number.isInteger(dialogue.subtitleCueIndex)) { + const cueIndices = getDialogueCueIndices(dialogue); + if (cueIndices.length === 0) { errors.push(`${label}: cue SRT invalide.`); } @@ -160,9 +168,18 @@ function getPatchedDialogue( id: patch.id ?? dialogue.id, voice: patch.voice ?? dialogue.voice, audio: patch.audio ?? dialogue.audio, - subtitleCueIndex: patch.subtitleCueIndex ?? dialogue.subtitleCueIndex, }; + if (patch.subtitleCueIndex !== undefined) { + nextDialogue.subtitleCueIndex = patch.subtitleCueIndex; + } else if (dialogue.subtitleCueIndex !== undefined) { + nextDialogue.subtitleCueIndex = dialogue.subtitleCueIndex; + } + + if (dialogue.subtitleCueIndices !== undefined) { + nextDialogue.subtitleCueIndices = dialogue.subtitleCueIndices; + } + if ("timecode" in patch) { if (patch.timecode !== undefined) nextDialogue.timecode = patch.timecode; } else if (dialogue.timecode !== undefined) { @@ -252,8 +269,9 @@ export function EditorDialogueManifestPanel(): React.JSX.Element { try { await createFrenchSrtCue(nextManifest, dialogue); + const cueIndex = getDialogueFirstCueIndex(dialogue) ?? "?"; setStatus( - `Nouveau dialogue ajoute avec cue FR ${dialogue.subtitleCueIndex}. Sauvegarde le manifeste pour le garder.`, + `Nouveau dialogue ajoute avec cue FR ${cueIndex}. Sauvegarde le manifeste pour le garder.`, ); } catch (err) { const message = err instanceof Error ? err.message : "Erreur inconnue"; @@ -333,12 +351,13 @@ export function EditorDialogueManifestPanel(): React.JSX.Element { async function handleCreateFrenchSrtCue(): Promise { if (!manifest || !selectedDialogue) return; + const cueIndex = getDialogueFirstCueIndex(selectedDialogue) ?? "?"; setIsCreatingSrtCue(true); - setStatus(`Creation de la cue FR ${selectedDialogue.subtitleCueIndex}...`); + setStatus(`Creation de la cue FR ${cueIndex}...`); try { await createFrenchSrtCue(manifest, selectedDialogue); - setStatus(`Cue FR ${selectedDialogue.subtitleCueIndex} prete.`); + setStatus(`Cue FR ${cueIndex} prete.`); } catch (err) { const message = err instanceof Error ? err.message : "Erreur inconnue"; setStatus(message); @@ -478,7 +497,7 @@ export function EditorDialogueManifestPanel(): React.JSX.Element { type="number" min="1" step="1" - value={selectedDialogue.subtitleCueIndex} + value={getDialogueFirstCueIndex(selectedDialogue) ?? ""} onChange={(event) => updateSelectedDialogue({ subtitleCueIndex: Math.max(1, Number(event.target.value)), diff --git a/src/components/editor/EditorSrtPanel.tsx b/src/components/editor/EditorSrtPanel.tsx index 78950ef..9ff26ec 100644 --- a/src/components/editor/EditorSrtPanel.tsx +++ b/src/components/editor/EditorSrtPanel.tsx @@ -7,6 +7,10 @@ import type { DialogueSpeaker, DialogueVoiceId, } from "@/types/dialogues/dialogues"; +import { + getDialogueCueIndices, + getDialogueFirstCueIndex, +} from "@/types/dialogues/dialogues"; import { logger } from "@/utils/core/Logger"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; import { @@ -181,7 +185,7 @@ function getExpectedCueIndexes( voice: DialogueVoiceId, ): number[] { return getExpectedDialogues(manifest, voice) - .map((dialogue) => dialogue.subtitleCueIndex) + .flatMap((dialogue) => getDialogueCueIndices(dialogue)) .filter( (cueIndex, index, cueIndexes) => cueIndexes.indexOf(cueIndex) === index, ) @@ -196,7 +200,11 @@ function getExpectedDialogues( return [...manifest.dialogues] .filter((dialogue) => dialogue.voice === voice) - .sort((a, b) => a.subtitleCueIndex - b.subtitleCueIndex); + .sort((a, b) => { + const aIndex = getDialogueFirstCueIndex(a) ?? 0; + const bIndex = getDialogueFirstCueIndex(b) ?? 0; + return aIndex - bIndex; + }); } function findCueBlockRange( @@ -577,7 +585,7 @@ export function EditorSrtPanel(): React.JSX.Element { )} {expectedDialogues.map((dialogue) => ( ))} @@ -585,7 +593,7 @@ export function EditorSrtPanel(): React.JSX.Element { {selectedDialogue && (
- Cue {selectedDialogue.subtitleCueIndex} + Cue {getDialogueFirstCueIndex(selectedDialogue) ?? "?"} {selectedDialogue.id}
)} diff --git a/src/components/game/EbikeIntroSequence.tsx b/src/components/game/EbikeIntroSequence.tsx new file mode 100644 index 0000000..b5d0c90 --- /dev/null +++ b/src/components/game/EbikeIntroSequence.tsx @@ -0,0 +1,113 @@ +import { useEffect, useRef, useState } from "react"; +import { MissionNotification } from "@/components/ui/MissionNotification"; +import { + EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS, + EBIKE_BREAKDOWN_DIALOGUE_ID, + EBIKE_INTRO_RIDE_DURATION_MS, + EBIKE_SOUNDS, +} from "@/data/ebike/ebikeConfig"; +import { AudioManager } from "@/managers/AudioManager"; +import { useGameStore } from "@/managers/stores/useGameStore"; +import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; +import { playDialogueById } from "@/utils/dialogues/playDialogue"; + +export function EbikeIntroSequence(): React.JSX.Element | null { + const introStep = useGameStore((state) => state.intro.currentStep); + const movementMode = useGameStore((state) => state.player.movementMode); + const setIntroStep = useGameStore((state) => state.setIntroStep); + const completeIntro = useGameStore((state) => state.completeIntro); + const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false); + const hasStartedBreakdown = useRef(false); + + useEffect(() => { + if (introStep !== "await-ebike-mount" || movementMode !== "ebike") return; + + setIntroStep("ebike-intro-ride"); + }, [introStep, movementMode, setIntroStep]); + + useEffect(() => { + if (introStep !== "ebike-intro-ride") return undefined; + + const timeoutId = window.setTimeout(() => { + setIntroStep("ebike-breakdown"); + }, EBIKE_INTRO_RIDE_DURATION_MS); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [introStep, setIntroStep]); + + useEffect(() => { + if (introStep !== "ebike-breakdown" || hasStartedBreakdown.current) { + return undefined; + } + + hasStartedBreakdown.current = true; + setBreakdownDialogueDone(false); + window.ebikeBreakdownActive = true; + AudioManager.getInstance().playSound(EBIKE_SOUNDS.panne, 0.95, { + category: "sfx", + }); + + let isCancelled = false; + const dialogueTimeoutId = window.setTimeout(() => { + void (async () => { + const manifest = await loadDialogueManifest(); + if (isCancelled || !manifest) { + setBreakdownDialogueDone(true); + return; + } + + const audio = await playDialogueById( + manifest, + EBIKE_BREAKDOWN_DIALOGUE_ID, + ); + if (isCancelled || !audio) { + setBreakdownDialogueDone(true); + return; + } + + audio.addEventListener( + "ended", + () => { + setBreakdownDialogueDone(true); + }, + { once: true }, + ); + })(); + }, EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS); + + return () => { + isCancelled = true; + window.clearTimeout(dialogueTimeoutId); + }; + }, [introStep]); + + useEffect(() => { + if (introStep !== "ebike-breakdown") return; + if (!breakdownDialogueDone || movementMode !== "walk") return; + + window.ebikeBreakdownActive = false; + completeIntro(); + }, [breakdownDialogueDone, completeIntro, introStep, movementMode]); + + useEffect(() => { + if (introStep === "ebike-breakdown") return; + + window.ebikeBreakdownActive = false; + if (introStep !== "completed") { + hasStartedBreakdown.current = false; + } + }, [introStep]); + + if (introStep !== "await-ebike-mount" && introStep !== "ebike-intro-ride") { + return null; + } + + return ( + + ); +} diff --git a/src/components/game/GameFlow.tsx b/src/components/game/GameFlow.tsx deleted file mode 100644 index 2720a22..0000000 --- a/src/components/game/GameFlow.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useEffect, useRef } from "react"; -import { AudioManager } from "@/managers/AudioManager"; -import { useGameStore } from "@/managers/stores/useGameStore"; -import { AUDIO_PATHS } from "@/data/audioConfig"; - -export function GameFlow(): null { - const step = useGameStore((state) => state.intro.currentStep); - const setStep = useGameStore((state) => state.setIntroStep); - const setActivityCity = useGameStore((state) => state.setActivityCity); - const setCanMove = useGameStore((state) => state.setCanMove); - const completeIntro = useGameStore((state) => state.completeIntro); - const hasInitialized = useRef(false); - - useEffect(() => { - if (!hasInitialized.current && step === "intro") { - hasInitialized.current = true; - setStep("start-intro"); - } - }, [step, setStep]); - - useEffect(() => { - if (step === "start-intro") { - const audio = AudioManager.getInstance(); - audio.playSoundWithCallback(AUDIO_PATHS.intro, 0.5, () => { - setStep("naming"); - }); - - return () => {}; - } - - if (step === "bienvenue") { - const audio = AudioManager.getInstance(); - audio.playSoundWithCallback(AUDIO_PATHS.bienvenue, 0.5, () => { - setCanMove(true); - setStep("star-move"); - }); - - return () => {}; - } - - if (step === "mission2") { - setActivityCity(false); - const audio = AudioManager.getInstance(); - audio.playSound(AUDIO_PATHS.alertCentral, 0.5); - } - - if (step === "searching") { - const audio = AudioManager.getInstance(); - audio.playSound(AUDIO_PATHS.searching, 0.5); - } - - if (step === "helped") { - const audio = AudioManager.getInstance(); - audio.playSound(AUDIO_PATHS.helped, 0.5); - } - - if (step === "manipulation") { - setCanMove(false); - const timeoutId = window.setTimeout(() => { - completeIntro(); - }, 1000); - - return () => { - window.clearTimeout(timeoutId); - }; - } - - return undefined; - }, [completeIntro, step, setStep, setActivityCity, setCanMove]); - - return null; -} diff --git a/src/components/site/SiteButton.tsx b/src/components/site/SiteButton.tsx new file mode 100644 index 0000000..f1ceb38 --- /dev/null +++ b/src/components/site/SiteButton.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; + +interface SiteButtonProps { + label: string; + disabled?: boolean; + onClick: () => void; +} + +export function SiteButton({ + label, + disabled = false, + onClick, +}: SiteButtonProps): React.JSX.Element { + const [isPressed, setIsPressed] = useState(false); + + return ( + + ); +} diff --git a/src/components/site/SiteCard.tsx b/src/components/site/SiteCard.tsx new file mode 100644 index 0000000..97ae7d1 --- /dev/null +++ b/src/components/site/SiteCard.tsx @@ -0,0 +1,72 @@ +import type { SiteCardConfig } from "@/data/site/siteConfig"; + +interface SiteCardProps { + config: SiteCardConfig; + selected: boolean; + onSelect: () => void; + variant?: "default" | "situation"; +} + +export function SiteCard({ + config, + selected, + onSelect, + variant = "default", +}: SiteCardProps): React.JSX.Element { + const { label, imagePath, disabled } = config; + const isSituation = variant === "situation"; + + const getBackground = (): string => { + if (imagePath) return `url(${imagePath}) center/cover`; + if (disabled) return "rgba(255, 255, 255, 0.42)"; + return "#b8b8b8"; + }; + + const borderColor = selected ? "#a8d5a2" : "rgba(255, 255, 255, 0.55)"; + + const textColor = disabled ? "rgba(77, 77, 77, 0.72)" : "#4d4d4d"; + + return ( + + ); +} diff --git a/src/components/site/SiteDisclaimerScreen.tsx b/src/components/site/SiteDisclaimerScreen.tsx new file mode 100644 index 0000000..29c42b5 --- /dev/null +++ b/src/components/site/SiteDisclaimerScreen.tsx @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSiteStore } from "@/managers/stores/useSiteStore"; +import { usePrefersReducedMotion } from "@/hooks/ui/usePrefersReducedMotion"; + +const DISCLAIMER_TEXT = + "Ce site a été conçu pour être utilisé sur ordinateur.\nPour une meilleure expérience, assurez-vous d'avoir une bonne connexion internet et une machine performante."; + +const TEXT_DISPLAY_DURATION = 5000; +const FADE_OUT_DURATION = 1000; +const TRANSITION_DELAY = 250; +const SKIP_KEYS = new Set(["Enter", " ", "Escape"]); + +/** + * Screen 0: Disclaimer + */ +export function SiteDisclaimerScreen(): React.JSX.Element { + const setStep = useSiteStore((state) => state.setStep); + const prefersReducedMotion = usePrefersReducedMotion(); + const [textOpacity, setTextOpacity] = useState(prefersReducedMotion ? 1 : 0); + const hasSkipped = useRef(false); + + const handleSkip = useCallback(() => { + if (hasSkipped.current) return; + hasSkipped.current = true; + setStep("welcome"); + }, [setStep]); + + useEffect(() => { + const fadeInTimeout = window.setTimeout(() => { + setTextOpacity(1); + }, 100); + + const fadeOutTimeout = window.setTimeout(() => { + setTextOpacity(0); + }, TEXT_DISPLAY_DURATION); + + const transitionTimeout = window.setTimeout( + handleSkip, + TEXT_DISPLAY_DURATION + FADE_OUT_DURATION + TRANSITION_DELAY, + ); + + const handleKeyDown = (event: KeyboardEvent): void => { + if (SKIP_KEYS.has(event.key)) { + event.preventDefault(); + handleSkip(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.clearTimeout(fadeInTimeout); + window.clearTimeout(fadeOutTimeout); + window.clearTimeout(transitionTimeout); + window.removeEventListener("keydown", handleKeyDown); + }; + }, [handleSkip]); + + return ( +
+

+ {DISCLAIMER_TEXT} +

+
+ ); +} diff --git a/src/components/site/SiteLayout.tsx b/src/components/site/SiteLayout.tsx new file mode 100644 index 0000000..7af128d --- /dev/null +++ b/src/components/site/SiteLayout.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; +import { SITE_BACKGROUND_STYLE } from "@/data/site/siteConfig"; +import { Subtitles } from "@/components/ui/Subtitles"; + +interface SiteLayoutProps { + children: ReactNode; +} + +export function SiteLayout({ children }: SiteLayoutProps): React.JSX.Element { + return ( +
+ {children} + +
+ ); +} diff --git a/src/components/site/SiteMobileBlocker.tsx b/src/components/site/SiteMobileBlocker.tsx new file mode 100644 index 0000000..acce806 --- /dev/null +++ b/src/components/site/SiteMobileBlocker.tsx @@ -0,0 +1,44 @@ +import { SITE_BACKGROUND_STYLE } from "@/data/site/siteConfig"; + +const MOBILE_TEXT = + "Ce site a été conçu pour être utilisé sur ordinateur. Veuillez réessayer sur votre ordinateur pour une expérience optimale."; + +export function SiteMobileBlocker(): React.JSX.Element { + return ( +
+ Logo Altera +

+ {MOBILE_TEXT} +

+
+ ); +} diff --git a/src/components/site/SiteNamingScreen.tsx b/src/components/site/SiteNamingScreen.tsx new file mode 100644 index 0000000..1f04212 --- /dev/null +++ b/src/components/site/SiteNamingScreen.tsx @@ -0,0 +1,158 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useGameStore } from "@/managers/stores/useGameStore"; +import { useSiteStore } from "@/managers/stores/useSiteStore"; +import { SiteButton } from "@/components/site/SiteButton"; +import { SITE_CONFIG } from "@/data/site/siteConfig"; +import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds"; +import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; +import { + playDialogueById, + stopCurrentDialogue, +} from "@/utils/dialogues/playDialogue"; + +/** + * Screen 3: Name input + * The displayed name is forced to SITE_CONFIG.presetPlayerName — the + * field reveals one letter per keystroke until the preset name is complete. + */ +export function SiteNamingScreen(): React.JSX.Element { + const setStep = useSiteStore((state) => state.setStep); + const setPlayerName = useGameStore((state) => state.setPlayerName); + const [charIndex, setCharIndex] = useState(0); + const inputRef = useRef(null); + + const presetPlayerName = SITE_CONFIG.presetPlayerName; + const displayValue = presetPlayerName.slice(0, charIndex); + const isComplete = charIndex >= presetPlayerName.length; + + useEffect(() => { + let cancelled = false; + + void (async () => { + const manifest = await loadDialogueManifest(); + if (cancelled || !manifest) return; + await playDialogueById(manifest, SITE_DIALOGUE_IDS.naming); + })(); + + return () => { + cancelled = true; + stopCurrentDialogue(); + }; + }, []); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const handleNameChange = useCallback( + (event: React.ChangeEvent): void => { + const nextLength = Math.min( + event.target.value.length, + presetPlayerName.length, + ); + setCharIndex(nextLength); + }, + [presetPlayerName.length], + ); + + const handleConfirm = (): void => { + if (isComplete) { + setPlayerName(presetPlayerName); + setStep("transition"); + } + }; + + return ( +
+
+

+ Quel est votre prénom ? +

+ + + + Votre personnage s'appelle {presetPlayerName}. Tapez{" "} + {presetPlayerName.length} caractères pour révéler son nom. + +
+ + +
+ ); +} diff --git a/src/components/site/SiteSituationScreen.tsx b/src/components/site/SiteSituationScreen.tsx new file mode 100644 index 0000000..deaba42 --- /dev/null +++ b/src/components/site/SiteSituationScreen.tsx @@ -0,0 +1,86 @@ +import { useSiteStore } from "@/managers/stores/useSiteStore"; +import { SiteCard } from "@/components/site/SiteCard"; +import { SiteButton } from "@/components/site/SiteButton"; +import { SITUATION_CARDS } from "@/data/site/siteConfig"; + +/** + * Screen 2: Situation selection + */ +export function SiteSituationScreen(): React.JSX.Element { + const selectedSituationIndex = useSiteStore( + (state) => state.selectedSituationIndex, + ); + const setSelectedSituationIndex = useSiteStore( + (state) => state.setSelectedSituationIndex, + ); + const setStep = useSiteStore((state) => state.setStep); + + const canProceed = selectedSituationIndex !== null; + + const handleConfirm = (): void => { + if (canProceed) { + setStep("naming"); + } + }; + + return ( +
+

+ Quelle est votre situation ? +

+ +
+ {SITUATION_CARDS.map((card, index) => ( + { + if (!card.disabled) { + setSelectedSituationIndex(index); + } + }} + /> + ))} +
+ + +
+ ); +} diff --git a/src/components/site/SiteTransitionOverlay.tsx b/src/components/site/SiteTransitionOverlay.tsx new file mode 100644 index 0000000..314133c --- /dev/null +++ b/src/components/site/SiteTransitionOverlay.tsx @@ -0,0 +1,125 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { useSiteStore } from "@/managers/stores/useSiteStore"; +import { Subtitles } from "@/components/ui/Subtitles"; +import { setSiteVisited } from "@/utils/cookies/siteVisitCookie"; +import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; +import { + playDialogueById, + stopCurrentDialogue, +} from "@/utils/dialogues/playDialogue"; +import { SITE_DIALOGUE_IDS } from "@/data/site/dialogueIds"; +import { usePrefersReducedMotion } from "@/hooks/ui/usePrefersReducedMotion"; + +const FADE_DURATION_MS = 1000; +const DIALOGUE_FALLBACK_TIMEOUT_MS = 12000; +const NO_DIALOGUE_FALLBACK_MS = 3000; + +/** + * Transition overlay: black screen with transition dialogue and subtitles, + * then redirect to /. A safety timeout guarantees the redirect happens even if + * the dialogue audio fails to fire `ended`. + */ +export function SiteTransitionOverlay(): React.JSX.Element { + const navigate = useNavigate(); + const reset = useSiteStore((state) => state.reset); + const prefersReducedMotion = usePrefersReducedMotion(); + const [screenOpacity, setScreenOpacity] = useState(0); + + useEffect(() => { + setSiteVisited(); + + let isCancelled = false; + const timeoutIds: number[] = []; + + // Defer the opacity flip one tick so the CSS transition has an + // initial frame at opacity 0 before flipping to 1. + const fadeInId = window.setTimeout(() => { + setScreenOpacity(1); + }, 0); + timeoutIds.push(fadeInId); + + const redirectToGame = (): void => { + if (isCancelled) return; + const id = window.setTimeout(() => { + if (isCancelled) return; + reset(); + navigate({ to: "/" }); + }, FADE_DURATION_MS); + timeoutIds.push(id); + }; + + void (async () => { + const manifest = await loadDialogueManifest(); + if (isCancelled) return; + + const dialogueAudio = manifest + ? await playDialogueById(manifest, SITE_DIALOGUE_IDS.transition) + : null; + if (isCancelled) return; + + if (dialogueAudio) { + const safetyId = window.setTimeout( + redirectToGame, + DIALOGUE_FALLBACK_TIMEOUT_MS, + ); + timeoutIds.push(safetyId); + + dialogueAudio.addEventListener( + "ended", + () => { + window.clearTimeout(safetyId); + redirectToGame(); + }, + { once: true }, + ); + return; + } + + const fallbackId = window.setTimeout( + redirectToGame, + NO_DIALOGUE_FALLBACK_MS, + ); + timeoutIds.push(fallbackId); + })(); + + return () => { + isCancelled = true; + timeoutIds.forEach(window.clearTimeout); + stopCurrentDialogue(); + }; + }, [navigate, reset]); + + const fadeTransition = prefersReducedMotion + ? "none" + : `opacity ${FADE_DURATION_MS}ms ease-in-out`; + + return ( +
+
+ {/* Subtitles must live inside this overlay's stacking context + (z-index 1000) so they render above the black screen. The + in SiteLayout sits behind this overlay. */} + +
+ ); +} diff --git a/src/components/site/SiteWelcomeScreen.tsx b/src/components/site/SiteWelcomeScreen.tsx new file mode 100644 index 0000000..d51a3b6 --- /dev/null +++ b/src/components/site/SiteWelcomeScreen.tsx @@ -0,0 +1,122 @@ +import { useSiteStore } from "@/managers/stores/useSiteStore"; +import { SiteCard } from "@/components/site/SiteCard"; +import { SiteButton } from "@/components/site/SiteButton"; +import { EXPERIENCE_CARDS } from "@/data/site/siteConfig"; + +/** + * Screen 1: Welcome + */ +export function SiteWelcomeScreen(): React.JSX.Element { + const selectedExperienceIndex = useSiteStore( + (state) => state.selectedExperienceIndex, + ); + const setSelectedExperienceIndex = useSiteStore( + (state) => state.setSelectedExperienceIndex, + ); + const setStep = useSiteStore((state) => state.setStep); + + const canProceed = selectedExperienceIndex !== null; + + const handleNext = (): void => { + if (canProceed) { + setStep("situation"); + } + }; + + return ( +
+
+

+ BIENVENUE A ALTERA +

+

+ Communauté convivialiste +

+
+ +

+ Choisissez une expérience : +

+ +
+ {EXPERIENCE_CARDS.map((card, index) => ( + { + if (!card.disabled) { + setSelectedExperienceIndex(index); + } + }} + /> + ))} +
+ + +
+ ); +} diff --git a/src/components/three/gameplay/RepairGame.tsx b/src/components/three/gameplay/RepairGame.tsx index 81529ae..c6e0a8b 100644 --- a/src/components/three/gameplay/RepairGame.tsx +++ b/src/components/three/gameplay/RepairGame.tsx @@ -112,7 +112,7 @@ export function RepairGame({ - {step === "waiting" ? ( + {step === "waiting" && mission !== "ebike" ? ( state.intro.currentStep); - const setStep = useGameStore((state) => state.setIntroStep); - const debug = Debug.getInstance(); - - const handlePress = (): void => { - if (step === "searching") { - setStep("helped"); - } - }; - - const shouldShow = step === "searching" || debug.active; - - if (!shouldShow) { - return <>; - } - - return ( - - - - - - - - - ); -} diff --git a/src/components/three/interaction/PyloneDestroyed.tsx b/src/components/three/interaction/PyloneDestroyed.tsx deleted file mode 100644 index f7c567c..0000000 --- a/src/components/three/interaction/PyloneDestroyed.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { InteractableObject } from "@/components/three/interaction/InteractableObject"; -import { useGameStore } from "@/managers/stores/useGameStore"; -import { Debug } from "@/utils/debug/Debug"; -import type { Vector3Tuple } from "@/types/three/three"; - -interface PyloneDestroyedProps { - position: Vector3Tuple; -} - -export function PyloneDestroyed({ - position, -}: PyloneDestroyedProps): React.JSX.Element { - const step = useGameStore((state) => state.intro.currentStep); - const setStep = useGameStore((state) => state.setIntroStep); - const setCanMove = useGameStore((state) => state.setCanMove); - const showDialog = useGameStore((state) => state.showDialog); - const debug = Debug.getInstance(); - - const handlePress = (): void => { - if (step === "helped") { - setCanMove(false); - setStep("manipulation"); - } else if (step === "searching") { - showDialog( - "Cet objet est trop lourd pour le porter tout seul, trouve de l'aide", - ); - } - }; - - const shouldShow = - step === "helped" || step === "manipulation" || debug.active; - - if (!shouldShow) { - return <>; - } - - return ( - - - - - - - - - ); -} diff --git a/src/components/ui/IntroUI.tsx b/src/components/ui/IntroUI.tsx deleted file mode 100644 index 45ace4e..0000000 --- a/src/components/ui/IntroUI.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { useState } from "react"; -import { useGameStore } from "@/managers/stores/useGameStore"; - -export function IntroUI(): React.JSX.Element | null { - const step = useGameStore((state) => state.intro.currentStep); - const setPlayerName = useGameStore((state) => state.setPlayerName); - const setStep = useGameStore((state) => state.setIntroStep); - const [inputValue, setInputValue] = useState(""); - - if (step !== "naming") return null; - - const handleSubmit = (): void => { - if (inputValue.trim() === "") return; - - setPlayerName(inputValue.trim()); - setStep("bienvenue"); - }; - - const handleKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === "Enter") { - handleSubmit(); - } - }; - - return ( -
-
-

- Quel est votre prenom ? -

- setInputValue(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Votre prenom" - autoFocus - style={{ - padding: "0.75rem", - fontSize: "1rem", - borderRadius: "6px", - border: "1px solid #444", - backgroundColor: "#2a2a2a", - color: "#fff", - outline: "none", - }} - /> - -
-
- ); -} - -export function BienvenueDisplay(): React.JSX.Element | null { - const step = useGameStore((state) => state.intro.currentStep); - const playerName = useGameStore((state) => state.missionFlow.playerName); - - if (step !== "bienvenue") return null; - - return ( -
-

- Bienvenue {playerName} ! -

-
- ); -} diff --git a/src/components/ui/MissionNotification.tsx b/src/components/ui/MissionNotification.tsx new file mode 100644 index 0000000..8b2d968 --- /dev/null +++ b/src/components/ui/MissionNotification.tsx @@ -0,0 +1,28 @@ +import { MISSION_NOTIFICATION_IMAGE_PATHS } from "@/data/gameplay/missionNotifications"; +import type { RepairMissionId } from "@/types/gameplay/repairMission"; + +interface MissionNotificationProps { + mission: RepairMissionId; + visible?: boolean; +} + +export function MissionNotification({ + mission, + visible = true, +}: MissionNotificationProps): React.JSX.Element { + return ( +
+
+ + Nouvel objectif de mission + +
+ ); +} diff --git a/src/components/ui/SceneLoadingOverlay.tsx b/src/components/ui/SceneLoadingOverlay.tsx index d5c66c9..b58dbd8 100644 --- a/src/components/ui/SceneLoadingOverlay.tsx +++ b/src/components/ui/SceneLoadingOverlay.tsx @@ -1,5 +1,13 @@ import type { SceneLoadingState } from "@/types/world/sceneLoading"; +const LOADING_BACKGROUND_PATH = "/assets/bg-site.png"; +const LOADING_LOGO_PATH = "/assets/logo/logo.jpg"; + +for (const path of [LOADING_BACKGROUND_PATH, LOADING_LOGO_PATH]) { + const image = new Image(); + image.src = path; +} + interface SceneLoadingOverlayProps { state: SceneLoadingState; } @@ -15,11 +23,47 @@ export function SceneLoadingOverlay({ className={`scene-loading-overlay${isReady ? " scene-loading-overlay--ready" : ""}`} aria-live="polite" > -
- {state.currentStep} + +
+ La Fabrik Durable +
+
+
+ Loading... + +
+ {progress}% +
- {progress}%
diff --git a/src/components/ui/intro/FadeToVideoOverlay.tsx b/src/components/ui/intro/FadeToVideoOverlay.tsx new file mode 100644 index 0000000..dfe341b --- /dev/null +++ b/src/components/ui/intro/FadeToVideoOverlay.tsx @@ -0,0 +1,14 @@ +export function FadeToVideoOverlay(): React.JSX.Element { + return ( +