From bff8a162905fe73edfc33aa61f7974f5bd6494b9 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sun, 31 May 2026 10:42:46 +0200 Subject: [PATCH] feat(intro): add ebike onboarding sequence --- .../world/UI/ebike-mission-notification.png | 3 + .../world/UI/farm-mission-notification.png | 3 + .../world/UI/pylon-mission-notification.png | 3 + public/map.json | 229 +----------------- public/sounds/dialogue/dialogues.json | 3 +- src/components/ebike/Ebike.tsx | 35 ++- src/components/game/EbikeIntroSequence.tsx | 113 +++++++++ src/components/three/gameplay/RepairGame.tsx | 2 +- src/components/ui/MissionNotification.tsx | 28 +++ .../ui/intro/IntroRevealOverlay.tsx | 12 +- src/components/ui/intro/IntroVideoPlayer.tsx | 29 ++- src/data/ebike/ebikeConfig.ts | 19 ++ src/data/game/gameStateConfig.ts | 3 + src/data/gameplay/missionNotifications.ts | 8 + src/data/gameplay/repairMissionAnchors.ts | 5 +- src/data/player/playerConfig.ts | 6 +- src/data/world/characters/characterConfig.ts | 4 +- src/hooks/ebike/useEbikeSounds.ts | 110 +++++++++ src/index.css | 188 ++++++++++++++ src/pages/page.tsx | 2 + src/types/ebike/ebikeWindow.ts | 3 + src/types/game.ts | 3 + src/world/GameStageContent.tsx | 3 +- src/world/World.tsx | 2 +- src/world/player/PlayerController.tsx | 60 ++++- 25 files changed, 620 insertions(+), 256 deletions(-) create mode 100644 public/assets/world/UI/ebike-mission-notification.png create mode 100644 public/assets/world/UI/farm-mission-notification.png create mode 100644 public/assets/world/UI/pylon-mission-notification.png create mode 100644 src/components/game/EbikeIntroSequence.tsx create mode 100644 src/components/ui/MissionNotification.tsx create mode 100644 src/data/gameplay/missionNotifications.ts create mode 100644 src/hooks/ebike/useEbikeSounds.ts 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/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/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/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/sounds/dialogue/dialogues.json b/public/sounds/dialogue/dialogues.json index 0fd116f..2f30ef1 100644 --- a/public/sounds/dialogue/dialogues.json +++ b/public/sounds/dialogue/dialogues.json @@ -31,8 +31,7 @@ "id": "narrateur_bienvenueaaltera", "voice": "narrateur", "audio": "/sounds/dialogue/narrateur_bienvenueaaltera.mp3", - "subtitleCueIndex": 1, - "timecode": 0 + "subtitleCueIndex": 1 }, { "id": "narrateur_intro_prenom", diff --git a/src/components/ebike/Ebike.tsx b/src/components/ebike/Ebike.tsx index 38f7f24..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 ( <> - + 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/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" ? ( +
+ + Nouvel objectif de mission + +
+ ); +} diff --git a/src/components/ui/intro/IntroRevealOverlay.tsx b/src/components/ui/intro/IntroRevealOverlay.tsx index 74b697d..c4b39dd 100644 --- a/src/components/ui/intro/IntroRevealOverlay.tsx +++ b/src/components/ui/intro/IntroRevealOverlay.tsx @@ -6,11 +6,12 @@ const REVEAL_DURATION_MS = 2000; /** * Fade-out overlay revealing the game world. - * Calls completeIntro() when the fade is done — completeIntro also marks - * intro.currentStep as "completed" so no separate setIntroStep call is needed. + * Moves to the ebike onboarding step when the fade is done. The intro only + * completes after the player rides the ebike and triggers the breakdown. */ export function IntroRevealOverlay(): React.JSX.Element { - const completeIntro = useGameStore((state) => state.completeIntro); + const setIntroStep = useGameStore((state) => state.setIntroStep); + const setCanMove = useGameStore((state) => state.setCanMove); const prefersReducedMotion = usePrefersReducedMotion(); const [opacity, setOpacity] = useState(1); @@ -20,14 +21,15 @@ export function IntroRevealOverlay(): React.JSX.Element { }, 100); const completeTimeout = window.setTimeout(() => { - completeIntro(); + setCanMove(true); + setIntroStep("await-ebike-mount"); }, REVEAL_DURATION_MS); return () => { window.clearTimeout(fadeTimeout); window.clearTimeout(completeTimeout); }; - }, [completeIntro]); + }, [setCanMove, setIntroStep]); return (
(null); + const hideHintTimeoutRef = useRef(null); + const [showSkipHint, setShowSkipHint] = useState(false); const setIntroStep = useGameStore((state) => state.setIntroStep); const handleVideoEnd = useCallback(() => { @@ -33,11 +36,33 @@ export function IntroVideoPlayer(): React.JSX.Element { return () => window.removeEventListener("keydown", handleKeyDown); }, [handleSkip]); + useEffect(() => { + return () => { + if (hideHintTimeoutRef.current !== null) { + window.clearTimeout(hideHintTimeoutRef.current); + } + }; + }, []); + + const handleMouseMove = useCallback(() => { + setShowSkipHint(true); + + if (hideHintTimeoutRef.current !== null) { + window.clearTimeout(hideHintTimeoutRef.current); + } + + hideHintTimeoutRef.current = window.setTimeout(() => { + setShowSkipHint(false); + hideHintTimeoutRef.current = null; + }, SKIP_HINT_HIDE_DELAY_MS); + }, []); + return (
Appuyez pour passer diff --git a/src/data/ebike/ebikeConfig.ts b/src/data/ebike/ebikeConfig.ts index 686b29f..583afc1 100644 --- a/src/data/ebike/ebikeConfig.ts +++ b/src/data/ebike/ebikeConfig.ts @@ -14,3 +14,22 @@ export const EBIKE_DROP_PLAYER_TRANSFORM: CameraTransform = { position: [0, 1.5, -3], rotation: [0, 0, 0], }; + +export const EBIKE_WORLD_POSITION: Vector3Tuple = [61.5, 10, 62.4]; +export const EBIKE_WORLD_ROTATION_Y = 2.4107; + +export const EBIKE_INTRO_RIDE_DURATION_MS = 5000; +export const EBIKE_BREAKDOWN_DIALOGUE_DELAY_MS = 250; + +export const EBIKE_MAX_SPEED = 3; +export const EBIKE_ACCELERATION_DURATION_MS = 2000; +export const EBIKE_DECELERATION_DURATION_MS = 2000; + +export const EBIKE_SOUNDS = { + depart: "/sounds/effect/ebike-depart.mp3", + roule: "/sounds/effect/ebike-roule.mp3", + ralenti: "/sounds/effect/ebike-ralenti.mp3", + panne: "/sounds/effect/ebike-panne.mp3", +} as const; + +export const EBIKE_BREAKDOWN_DIALOGUE_ID = "narrateur_ebikecasse"; diff --git a/src/data/game/gameStateConfig.ts b/src/data/game/gameStateConfig.ts index 766388a..e8a8531 100644 --- a/src/data/game/gameStateConfig.ts +++ b/src/data/game/gameStateConfig.ts @@ -19,6 +19,9 @@ export const GAME_STEPS: readonly GameStep[] = [ "video", "dialogue-intro", "reveal", + "await-ebike-mount", + "ebike-intro-ride", + "ebike-breakdown", "completed", ]; diff --git a/src/data/gameplay/missionNotifications.ts b/src/data/gameplay/missionNotifications.ts new file mode 100644 index 0000000..82caf13 --- /dev/null +++ b/src/data/gameplay/missionNotifications.ts @@ -0,0 +1,8 @@ +import type { RepairMissionId } from "@/types/gameplay/repairMission"; + +export const MISSION_NOTIFICATION_IMAGE_PATHS: Record = + { + ebike: "/assets/world/UI/ebike-mission-notification.png", + pylon: "/assets/world/UI/pylon-mission-notification.png", + farm: "/assets/world/UI/farm-mission-notification.png", + }; diff --git a/src/data/gameplay/repairMissionAnchors.ts b/src/data/gameplay/repairMissionAnchors.ts index c7aa4cd..373dec1 100644 --- a/src/data/gameplay/repairMissionAnchors.ts +++ b/src/data/gameplay/repairMissionAnchors.ts @@ -3,6 +3,7 @@ import type { RepairMissionId, RepairMissionTriggerConfig, } from "@/types/gameplay/repairMission"; +import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig"; export const REPAIR_MISSION_ANCHOR_IDS: Partial< Record @@ -10,9 +11,7 @@ export const REPAIR_MISSION_ANCHOR_IDS: Partial< pylon: "repair:pylon", }; -const EBIKE_REPAIR_POSITION = [ - 42.2399, 4.5484, 34.6468, -] as const satisfies Vector3Tuple; +const EBIKE_REPAIR_POSITION = EBIKE_WORLD_POSITION satisfies Vector3Tuple; const REPAIR_MISSION_POSITIONS = { ebike: EBIKE_REPAIR_POSITION, diff --git a/src/data/player/playerConfig.ts b/src/data/player/playerConfig.ts index 65c0648..7ce0ca2 100644 --- a/src/data/player/playerConfig.ts +++ b/src/data/player/playerConfig.ts @@ -3,8 +3,8 @@ import type { Vector3Tuple } from "@/types/three/three"; export const PLAYER_EYE_HEIGHT = 1.75; export const PLAYER_CAPSULE_RADIUS = 0.35; -export const PLAYER_WALK_SPEED = 11; -export const PLAYER_EBIKE_SPEED = 25; +export const PLAYER_WALK_SPEED = 5; +export const PLAYER_EBIKE_SPEED = 20; export const PLAYER_AIR_CONTROL_FACTOR = 0.35; export const PLAYER_JUMP_SPEED = 9; export const PLAYER_GRAVITY = 30; @@ -14,5 +14,5 @@ export const PLAYER_XZ_DAMPING_FACTOR = 8; export const PLAYER_FALL_RESPAWN_Y = -20; export const PLAYER_FALL_RESPAWN_DELAY = 3; -export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [0, 50, 0]; +export const PLAYER_SPAWN_POSITION_GAME: Vector3Tuple = [59.5, 10, 64.64]; export const PLAYER_SPAWN_POSITION_PHYSICS: Vector3Tuple = [0, 3, 0]; diff --git a/src/data/world/characters/characterConfig.ts b/src/data/world/characters/characterConfig.ts index 73848ba..fce3d29 100644 --- a/src/data/world/characters/characterConfig.ts +++ b/src/data/world/characters/characterConfig.ts @@ -28,8 +28,8 @@ export const CHARACTER_CONFIGS = { id: "gerant", label: "Gerant", modelPath: "/models/gerant-animated/model.gltf", - position: [45.2, 0, 45.5], - rotation: [0, -1.55, 0], + position: [59.5, 0, 64.64], + rotation: [0, 2.41, 0], scale: [1, 1, 1], animations: ["idle", "walk"], defaultAnimation: "idle", diff --git a/src/hooks/ebike/useEbikeSounds.ts b/src/hooks/ebike/useEbikeSounds.ts new file mode 100644 index 0000000..cce2bcd --- /dev/null +++ b/src/hooks/ebike/useEbikeSounds.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useRef } from "react"; +import { EBIKE_SOUNDS } from "@/data/ebike/ebikeConfig"; +import { AudioManager } from "@/managers/AudioManager"; + +type EbikeSoundState = "idle" | "depart" | "roule" | "ralenti"; + +interface UpdateEbikeSoundsOptions { + mounted: boolean; + driving: boolean; + breakdown: boolean; +} + +function stopAudio(audio: HTMLAudioElement | null): void { + if (!audio) return; + audio.pause(); + audio.currentTime = 0; + audio.loop = false; +} + +export function useEbikeSounds(): (options: UpdateEbikeSoundsOptions) => void { + const stateRef = useRef("idle"); + const audioRef = useRef(null); + + const stopCurrent = useCallback(() => { + stopAudio(audioRef.current); + audioRef.current = null; + stateRef.current = "idle"; + }, []); + + const playDepart = useCallback(() => { + stopCurrent(); + const audio = AudioManager.getInstance().playSound( + EBIKE_SOUNDS.depart, + 0.8, + { + category: "sfx", + }, + ); + audioRef.current = audio; + stateRef.current = "depart"; + audio.addEventListener( + "ended", + () => { + if (stateRef.current !== "depart") return; + if (window.ebikeDriveInputActive !== true) { + stateRef.current = "idle"; + audioRef.current = null; + return; + } + + const rollingAudio = AudioManager.getInstance().playSound( + EBIKE_SOUNDS.roule, + 0.72, + { category: "sfx" }, + ); + rollingAudio.loop = true; + audioRef.current = rollingAudio; + stateRef.current = "roule"; + }, + { once: true }, + ); + }, [stopCurrent]); + + const playRalenti = useCallback(() => { + stopCurrent(); + const audio = AudioManager.getInstance().playSound( + EBIKE_SOUNDS.ralenti, + 0.72, + { + category: "sfx", + }, + ); + audioRef.current = audio; + stateRef.current = "ralenti"; + audio.addEventListener( + "ended", + () => { + if (stateRef.current !== "ralenti") return; + audioRef.current = null; + stateRef.current = "idle"; + }, + { once: true }, + ); + }, [stopCurrent]); + + const update = useCallback( + ({ mounted, driving, breakdown }: UpdateEbikeSoundsOptions) => { + if (!mounted || breakdown) { + stopCurrent(); + return; + } + + if (driving) { + if (stateRef.current === "idle" || stateRef.current === "ralenti") { + playDepart(); + } + return; + } + + if (stateRef.current === "depart" || stateRef.current === "roule") { + playRalenti(); + } + }, + [playDepart, playRalenti, stopCurrent], + ); + + useEffect(() => stopCurrent, [stopCurrent]); + + return update; +} diff --git a/src/index.css b/src/index.css index c981d71..aa2b6c8 100644 --- a/src/index.css +++ b/src/index.css @@ -982,6 +982,194 @@ canvas { } } +/* Mission notification */ +.mission-notification { + position: fixed; + top: clamp(18px, 4vh, 42px); + left: clamp(18px, 4vw, 48px); + z-index: 20; + width: min(280px, calc(100vw - 36px)); + pointer-events: none; + opacity: 1; + filter: drop-shadow(0 0 12px rgba(96, 165, 250, 0.36)); + transform: translate3d(0, 0, 0) scale(1); + transition: + opacity 420ms ease, + filter 420ms ease, + transform 420ms ease; + animation: mission-notification-enter 900ms ease-out both; +} + +.mission-notification--hidden { + opacity: 0; + filter: drop-shadow(0 0 4px rgba(96, 165, 250, 0.12)); + transform: translate3d(-8px, -2px, 0) scale(0.985); +} + +.mission-notification::after { + position: absolute; + inset: 0; + content: ""; + pointer-events: none; +} + +.mission-notification::after { + background: linear-gradient( + 180deg, + transparent 0%, + rgba(96, 165, 250, 0.16) 48%, + transparent 52%, + transparent 100% + ); + background-size: 100% 10px; + opacity: 0.22; + clip-path: polygon(0 0, 100% 0, 100% 69%, 88% 100%, 0 100%); + mix-blend-mode: screen; +} + +.mission-notification__glow { + position: absolute; + inset: -14px; + background: radial-gradient( + circle at 22% 22%, + rgba(96, 165, 250, 0.36), + transparent 58% + ); + opacity: 0.7; + filter: blur(12px); + animation: mission-notification-glow 10s ease-in-out 1s infinite; +} + +.mission-notification__image-wrap { + position: relative; + display: block; + overflow: hidden; + clip-path: polygon(0 0, 100% 0, 100% 69%, 88% 100%, 0 100%); +} + +.mission-notification__image-wrap::before { + position: absolute; + top: 0; + bottom: 0; + left: -35%; + z-index: 2; + width: 28%; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(191, 219, 254, 0.08) 18%, + rgba(125, 211, 252, 0.52) 50%, + rgba(191, 219, 254, 0.08) 82%, + transparent 100% + ); + content: ""; + mix-blend-mode: screen; + opacity: 0; + pointer-events: none; + transform: skewX(-16deg); + animation: mission-notification-scan 3.8s ease-in-out 1.2s infinite; +} + +.mission-notification__image { + position: relative; + display: block; + width: 100%; + height: auto; + opacity: 0.92; + filter: sepia(0.08) saturate(1.18) hue-rotate(155deg) contrast(1.04) + brightness(1.03) blur(0.18px); + animation: mission-notification-flicker 10s ease-in-out 1s infinite; +} + +@keyframes mission-notification-enter { + 0% { + opacity: 0; + transform: translate3d(-12px, -4px, 0) scale(0.985); + } + + 12% { + opacity: 0.85; + } + + 18% { + opacity: 0.22; + } + + 26% { + opacity: 0.95; + } + + 34% { + opacity: 0.5; + } + + 48%, + 100% { + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); + } +} + +@keyframes mission-notification-flicker { + 0%, + 7%, + 100% { + opacity: 0.92; + filter: saturate(1) brightness(1); + } + + 1.5% { + opacity: 0.58; + filter: saturate(1.25) brightness(1.18); + } + + 3% { + opacity: 1; + } + + 4.5% { + opacity: 0.74; + } +} + +@keyframes mission-notification-scan { + 0%, + 22% { + left: -35%; + opacity: 0; + } + + 32% { + opacity: 0.78; + } + + 52% { + left: 108%; + opacity: 0; + } + + 100% { + left: 108%; + opacity: 0; + } +} + +@keyframes mission-notification-glow { + 0%, + 7%, + 100% { + opacity: 0.55; + } + + 2.5% { + opacity: 0.95; + } + + 4.5% { + opacity: 0.35; + } +} + /* Subtitles */ .subtitles { position: fixed; diff --git a/src/pages/page.tsx b/src/pages/page.tsx index e4dcd72..d8b2a84 100644 --- a/src/pages/page.tsx +++ b/src/pages/page.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "@tanstack/react-router"; import { Canvas } from "@react-three/fiber"; import * as THREE from "three"; import { DebugPerf } from "@/components/debug/DebugPerf"; +import { EbikeIntroSequence } from "@/components/game/EbikeIntroSequence"; import { DialogMessage } from "@/components/ui/DialogMessage"; import { GameUI } from "@/components/ui/GameUI"; import { @@ -176,6 +177,7 @@ export function HomePage(): React.JSX.Element | null { )} {renderIntroOverlay()} + ); } diff --git a/src/types/ebike/ebikeWindow.ts b/src/types/ebike/ebikeWindow.ts index 90cd966..cf7e467 100644 --- a/src/types/ebike/ebikeWindow.ts +++ b/src/types/ebike/ebikeWindow.ts @@ -7,5 +7,8 @@ declare global { ebikeParkedPosition: Vector3Tuple | null; ebikeParkedRotation: number | null; ebikeSteerFactor: number | undefined; + ebikeBreakdownActive: boolean | undefined; + ebikeDriveInputActive: boolean | undefined; + ebikeSpeedFactor: number | undefined; } } diff --git a/src/types/game.ts b/src/types/game.ts index 86c8a27..01bdc77 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -19,6 +19,9 @@ export type GameStep = | "video" // Vidéo intro.mp4 | "dialogue-intro" // Dialogues post-vidéo (écran noir) | "reveal" // Fondu noir → jeu visible + | "await-ebike-mount" // Attente interaction pour monter sur l'e-bike + | "ebike-intro-ride" // Courte conduite avant la panne + | "ebike-breakdown" // Panne + dialogue avant mission réparation | "completed"; // Intro terminée export type MainGameState = "intro" | RepairMissionId | "outro"; diff --git a/src/world/GameStageContent.tsx b/src/world/GameStageContent.tsx index de8102a..016a864 100644 --- a/src/world/GameStageContent.tsx +++ b/src/world/GameStageContent.tsx @@ -14,6 +14,7 @@ import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionA import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission"; import type { Vector3Tuple } from "@/types/three/three"; import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition"; +import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig"; interface StageAnchorProps { color: string; @@ -81,7 +82,7 @@ export function GameStageContent(): React.JSX.Element { return ( <> {mainState === "intro" ? : null} - + {REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => { const position = getRepairMissionPosition(mission, anchors); if (!position) return null; diff --git a/src/world/World.tsx b/src/world/World.tsx index b5ce42d..87778d5 100644 --- a/src/world/World.tsx +++ b/src/world/World.tsx @@ -89,7 +89,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element { <> {mainState === "outro" ? : null} - + {mainState !== "intro" ? : null} ) : null} diff --git a/src/world/player/PlayerController.tsx b/src/world/player/PlayerController.tsx index 2298f9c..4c74d52 100644 --- a/src/world/player/PlayerController.tsx +++ b/src/world/player/PlayerController.tsx @@ -29,7 +29,12 @@ import { InteractionManager } from "@/managers/InteractionManager"; import { useGameStore } from "@/managers/stores/useGameStore"; import { useSettingsStore } from "@/managers/stores/useSettingsStore"; import type { Vector3Tuple } from "@/types/three/three"; -import { EBIKE_CAMERA_TRANSFORM } from "@/data/ebike/ebikeConfig"; +import { + EBIKE_ACCELERATION_DURATION_MS, + EBIKE_CAMERA_TRANSFORM, + EBIKE_DECELERATION_DURATION_MS, + EBIKE_MAX_SPEED, +} from "@/data/ebike/ebikeConfig"; /** Global window properties used for ebike communication */ interface EbikeGlobalState { @@ -39,6 +44,9 @@ interface EbikeGlobalState { ebikeVisualGroup?: React.RefObject; playerPos?: Vector3Tuple; ebikeAngle?: number; + ebikeBreakdownActive?: boolean; + ebikeDriveInputActive?: boolean; + ebikeSpeedFactor?: number; } declare global { @@ -156,6 +164,7 @@ export function PlayerController({ const movementModeRef = useRef(movementMode); const prevMovementModeRef = useRef(movementMode); const ebikeAngle = useRef(0); + const ebikeSpeedFactor = useRef(0); const capsule = useRef(createSpawnCapsule(spawnPosition)); useEffect(() => { @@ -175,6 +184,7 @@ export function PlayerController({ velocity.current.set(0, 0, 0); onFloor.current = false; wantsJump.current = false; + ebikeSpeedFactor.current = 0; ebikeAngle.current = targetRot; @@ -215,6 +225,7 @@ export function PlayerController({ const shift = rightDir.multiplyScalar(3); capsule.current.translate(shift); camera.position.copy(capsule.current.end); + ebikeSpeedFactor.current = 0; } prevMovementModeRef.current = movementMode; }, [movementMode, camera]); @@ -347,7 +358,10 @@ export function PlayerController({ return; } - if (movementModeRef.current === "ebike") { + const isEbikeMounted = movementModeRef.current === "ebike"; + const isEbikeBreakdown = window.ebikeBreakdownActive === true; + + if (isEbikeMounted && !isEbikeBreakdown) { const turnSpeed = 1.8; if (keys.current.left) { ebikeAngle.current += turnSpeed * dt; @@ -365,19 +379,41 @@ export function PlayerController({ } _wishDir.set(0, 0, 0); - if (!movementLocked) { + if (!movementLocked && !isEbikeBreakdown) { if (keys.current.forward) _wishDir.add(_forward); if (keys.current.backward) _wishDir.sub(_forward); - if (movementModeRef.current !== "ebike") { + if (!isEbikeMounted) { if (keys.current.left) _wishDir.sub(_right); if (keys.current.right) _wishDir.add(_right); } } if (_wishDir.lengthSq() > 0) _wishDir.normalize(); + if (isEbikeMounted) { + const isDriveInputActive = _wishDir.lengthSq() > 0 && !isEbikeBreakdown; + const durationMs = isDriveInputActive + ? EBIKE_ACCELERATION_DURATION_MS + : EBIKE_DECELERATION_DURATION_MS; + const factorDelta = durationMs > 0 ? (dt * 1000) / durationMs : 1; + ebikeSpeedFactor.current = THREE.MathUtils.clamp( + ebikeSpeedFactor.current + + (isDriveInputActive ? factorDelta : -factorDelta), + 0, + 1, + ); + window.ebikeDriveInputActive = isDriveInputActive; + window.ebikeSpeedFactor = ebikeSpeedFactor.current; + } else { + window.ebikeDriveInputActive = false; + window.ebikeSpeedFactor = 0; + } + + const movementSpeed = isEbikeMounted + ? EBIKE_MAX_SPEED * ebikeSpeedFactor.current + : currentSpeed; const accel = onFloor.current - ? currentSpeed - : currentSpeed * PLAYER_AIR_CONTROL_FACTOR; + ? movementSpeed + : movementSpeed * PLAYER_AIR_CONTROL_FACTOR; velocity.current.x += _wishDir.x * accel * dt * PLAYER_ACCELERATION_MULTIPLIER; velocity.current.z += @@ -387,6 +423,18 @@ export function PlayerController({ velocity.current.x *= damping; velocity.current.z *= damping; + if ( + isEbikeMounted && + isEbikeBreakdown && + ebikeSpeedFactor.current <= 0.001 && + Math.hypot(velocity.current.x, velocity.current.z) <= 0.05 + ) { + velocity.current.setX(0); + velocity.current.setZ(0); + useGameStore.getState().setPlayerMovementMode("walk"); + return; + } + if (onFloor.current) { velocity.current.y = Math.max(0, velocity.current.y); if (wantsJump.current) {