feat(intro): add ebike onboarding sequence
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
-227
@@ -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]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<number>(0);
|
||||
const restingRotationRef = useRef<number>(EBIKE_WORLD_ROTATION_Y);
|
||||
const forkRef = useRef<THREE.Object3D | null>(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 (
|
||||
<>
|
||||
<group ref={groupRef} position={position}>
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={position}
|
||||
rotation={[0, EBIKE_WORLD_ROTATION_Y, 0]}
|
||||
>
|
||||
<primitive object={model} />
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={
|
||||
movementMode === "walk" ? "Monter sur le bike" : "Descendre du bike"
|
||||
mainState === "ebike" && ebikeStep === "waiting"
|
||||
? "Inspecter l'e-bike"
|
||||
: movementMode === "walk"
|
||||
? "Monter sur le bike"
|
||||
: "Descendre du bike"
|
||||
}
|
||||
position={position}
|
||||
radius={15}
|
||||
|
||||
@@ -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 (
|
||||
<MissionNotification
|
||||
mission="ebike"
|
||||
visible={introStep === "await-ebike-mount"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -112,7 +112,7 @@ export function RepairGame({
|
||||
<RepairMissionAssetPreloader config={config} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
{step === "waiting" ? (
|
||||
{step === "waiting" && mission !== "ebike" ? (
|
||||
<RepairInspectionObject
|
||||
config={config}
|
||||
worldPosition={snappedPosition}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`mission-notification${visible ? "" : " mission-notification--hidden"}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="mission-notification__glow" />
|
||||
<span className="mission-notification__image-wrap">
|
||||
<img
|
||||
className="mission-notification__image"
|
||||
src={MISSION_NOTIFICATION_IMAGE_PATHS[mission]}
|
||||
alt="Nouvel objectif de mission"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
|
||||
const INTRO_VIDEO_PATH = "/cinematics/intro.mp4";
|
||||
const SKIP_KEYS = new Set(["Enter", " "]);
|
||||
const SKIP_HINT_HIDE_DELAY_MS = 1000;
|
||||
|
||||
/**
|
||||
* Full-screen video player for the intro cinematic.
|
||||
@@ -10,6 +11,8 @@ const SKIP_KEYS = new Set(["Enter", " "]);
|
||||
*/
|
||||
export function IntroVideoPlayer(): React.JSX.Element {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hideHintTimeoutRef = useRef<number | null>(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 (
|
||||
<div
|
||||
role="region"
|
||||
aria-label="Vidéo d'introduction. Appuyez sur Entrée pour passer."
|
||||
onClick={handleSkip}
|
||||
onMouseMove={handleMouseMove}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
@@ -71,6 +96,8 @@ export function IntroVideoPlayer(): React.JSX.Element {
|
||||
color: "rgba(255, 255, 255, 0.6)",
|
||||
fontSize: 14,
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
opacity: showSkipHint ? 1 : 0,
|
||||
transition: "opacity 240ms ease",
|
||||
}}
|
||||
>
|
||||
Appuyez pour passer
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -19,6 +19,9 @@ export const GAME_STEPS: readonly GameStep[] = [
|
||||
"video",
|
||||
"dialogue-intro",
|
||||
"reveal",
|
||||
"await-ebike-mount",
|
||||
"ebike-intro-ride",
|
||||
"ebike-breakdown",
|
||||
"completed",
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
|
||||
export const MISSION_NOTIFICATION_IMAGE_PATHS: Record<RepairMissionId, string> =
|
||||
{
|
||||
ebike: "/assets/world/UI/ebike-mission-notification.png",
|
||||
pylon: "/assets/world/UI/pylon-mission-notification.png",
|
||||
farm: "/assets/world/UI/farm-mission-notification.png",
|
||||
};
|
||||
@@ -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<RepairMissionId, string>
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<EbikeSoundState>("idle");
|
||||
const audioRef = useRef<HTMLAudioElement | null>(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;
|
||||
}
|
||||
+188
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
<SceneLoadingOverlay state={sceneLoadingState} />
|
||||
)}
|
||||
{renderIntroOverlay()}
|
||||
<EbikeIntroSequence />
|
||||
</HandTrackingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,5 +7,8 @@ declare global {
|
||||
ebikeParkedPosition: Vector3Tuple | null;
|
||||
ebikeParkedRotation: number | null;
|
||||
ebikeSteerFactor: number | undefined;
|
||||
ebikeBreakdownActive: boolean | undefined;
|
||||
ebikeDriveInputActive: boolean | undefined;
|
||||
ebikeSpeedFactor: number | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
|
||||
<Ebike position={[0, 10, 0]} />
|
||||
<Ebike position={EBIKE_WORLD_POSITION} />
|
||||
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
|
||||
const position = getRepairMissionPosition(mission, anchors);
|
||||
if (!position) return null;
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
<>
|
||||
<GameMusic />
|
||||
{mainState === "outro" ? <GameCinematics /> : null}
|
||||
<GameDialogues />
|
||||
{mainState !== "intro" ? <GameDialogues /> : null}
|
||||
<Player octree={octree} spawnPosition={playerSpawnPosition} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -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<THREE.Group>;
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user