Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a3966a339 | |||
| eb5d4076d1 | |||
| 5177f43d96 | |||
| ff1ec56729 | |||
| cd0afcda8c | |||
| 813c10f3f7 |
Binary file not shown.
Binary file not shown.
@@ -78,19 +78,19 @@
|
||||
{
|
||||
"id": "narrateur_coupureelec",
|
||||
"voice": "narrateur",
|
||||
"audio": "/sounds/dialogue/narrateur_coupureélec.mp3",
|
||||
"audio": "/sounds/dialogue/narrateur_coupure_elec.mp3",
|
||||
"subtitleCueIndex": 9
|
||||
},
|
||||
{
|
||||
"id": "narrateur_poteaueleccasse",
|
||||
"voice": "narrateur",
|
||||
"audio": "/sounds/dialogue/narrateur_poteauéleccassé.mp3",
|
||||
"audio": "/sounds/dialogue/narrateur_poteau_elec_casse.mp3",
|
||||
"subtitleCueIndex": 10
|
||||
},
|
||||
{
|
||||
"id": "narrateur_courantrepare",
|
||||
"voice": "narrateur",
|
||||
"audio": "/sounds/dialogue/narrateur_courantréparé.mp3",
|
||||
"audio": "/sounds/dialogue/narrateur_courant_repare.mp3",
|
||||
"subtitleCueIndex": 11
|
||||
},
|
||||
{
|
||||
@@ -165,6 +165,12 @@
|
||||
"audio": "/sounds/dialogue/narrateur_histoireelectricienne.mp3",
|
||||
"subtitleCueIndex": 23
|
||||
},
|
||||
{
|
||||
"id": "narrateur_demande_aide",
|
||||
"voice": "narrateur",
|
||||
"audio": "/sounds/dialogue/narrateur_demande_aide.mp3",
|
||||
"subtitleCueIndex": 24
|
||||
},
|
||||
{
|
||||
"id": "fermier_coupdemain",
|
||||
"voice": "fermier",
|
||||
@@ -182,6 +188,24 @@
|
||||
"voice": "fermier",
|
||||
"audio": "/sounds/dialogue/fermier_findemission.mp3",
|
||||
"subtitleCueIndex": 3
|
||||
},
|
||||
{
|
||||
"id": "electricienne_welcome",
|
||||
"voice": "electricienne",
|
||||
"audio": "/sounds/dialogue/electricienne_welcome.mp3",
|
||||
"subtitleCueIndex": 1
|
||||
},
|
||||
{
|
||||
"id": "electricienne_apresMontage",
|
||||
"voice": "electricienne",
|
||||
"audio": "/sounds/dialogue/electricienne_aprèsmontage.mp3",
|
||||
"subtitleCueIndex": 2
|
||||
},
|
||||
{
|
||||
"id": "electricienne_aurevoir",
|
||||
"voice": "electricienne",
|
||||
"audio": "/sounds/dialogue/electricienne_aurevoir.mp3",
|
||||
"subtitleCueIndex": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,8 +14,10 @@ import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
|
||||
export function EbikeIntroSequence(): React.JSX.Element | null {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const introStep = useGameStore((state) => state.intro.currentStep);
|
||||
const movementMode = useGameStore((state) => state.player.movementMode);
|
||||
const pylonStep = useGameStore((state) => state.pylon.currentStep);
|
||||
const setIntroStep = useGameStore((state) => state.setIntroStep);
|
||||
const completeIntro = useGameStore((state) => state.completeIntro);
|
||||
const [breakdownDialogueDone, setBreakdownDialogueDone] = useState(false);
|
||||
@@ -134,6 +136,26 @@ export function EbikeIntroSequence(): React.JSX.Element | null {
|
||||
}
|
||||
}, [introStep]);
|
||||
|
||||
if (mainState === "pylon") {
|
||||
if (pylonStep === "approaching") {
|
||||
return <MissionNotification mission="pylon" visible />;
|
||||
}
|
||||
if (pylonStep === "narrator-outro") {
|
||||
return <MissionNotification mission="farm" visible />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mainState == "pylon") {
|
||||
if (pylonStep === "approaching") {
|
||||
return <MissionNotification mission="pylon" visible />;
|
||||
}
|
||||
if (pylonStep === "narrator-outro") {
|
||||
return <MissionNotification mission="farm" visible />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
introStep !== "reveal" &&
|
||||
introStep !== "await-ebike-mount" &&
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import * as THREE from "three";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
import {
|
||||
PYLON_DOWNED_ROTATION,
|
||||
PYLON_NARRATIVE_INTERACT_RADIUS,
|
||||
PYLON_NARRATIVE_DIALOGUES,
|
||||
PYLON_STRAIGHTEN_ANIMATION_DURATION_MS,
|
||||
PYLON_UPRIGHT_ROTATION,
|
||||
PYLON_WORLD_POSITION,
|
||||
} from "@/data/gameplay/pylonConfig";
|
||||
import { pylonStraighteningSignal } from "@/components/gameplay/pylon/pylonSignals";
|
||||
|
||||
const PYLON_MODEL_PATH = "/models/pylone/model.glb";
|
||||
|
||||
export function PylonDownedPylon(): React.JSX.Element | null {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const step = useGameStore((state) => state.pylon.currentStep);
|
||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||
const [isStraightening, setIsStraightening] = useState(false);
|
||||
// Keeps the pylon upright after the animation completes while
|
||||
// PylonFarmerNPC plays the post-raise audio sequence.
|
||||
const [isRaised, setIsRaised] = useState(false);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const straightenStartRef = useRef<number | null>(null);
|
||||
const hasPlayedFirstAudioRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "arrived") {
|
||||
hasPlayedFirstAudioRef.current = false;
|
||||
setIsRaised(false);
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
const { scene } = useGLTF(PYLON_MODEL_PATH);
|
||||
|
||||
useFrame(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
if (!isStraightening || straightenStartRef.current === null) {
|
||||
group.rotation.set(...(showUpright ? PYLON_UPRIGHT_ROTATION : PYLON_DOWNED_ROTATION));
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = performance.now() - straightenStartRef.current;
|
||||
const t = Math.min(elapsed / PYLON_STRAIGHTEN_ANIMATION_DURATION_MS, 1);
|
||||
const eased = 1 - Math.pow(1 - t, 3);
|
||||
const startEuler = new THREE.Euler(...PYLON_DOWNED_ROTATION);
|
||||
|
||||
group.rotation.set(
|
||||
THREE.MathUtils.lerp(startEuler.x, 0, eased),
|
||||
startEuler.y,
|
||||
THREE.MathUtils.lerp(startEuler.z, 0, eased),
|
||||
);
|
||||
});
|
||||
|
||||
const showUpright =
|
||||
isRaised ||
|
||||
mainState !== "pylon" ||
|
||||
step === "waiting" ||
|
||||
step === "inspected" ||
|
||||
step === "fragmented" ||
|
||||
step === "scanning" ||
|
||||
step === "repairing" ||
|
||||
step === "reassembling" ||
|
||||
step === "done" ||
|
||||
step === "narrator-outro";
|
||||
|
||||
const isPylonInteractive = step === "arrived" || step === "npc-return";
|
||||
|
||||
const beginStraighten = (): void => {
|
||||
setIsStraightening(true);
|
||||
pylonStraighteningSignal.started = true;
|
||||
pylonStraighteningSignal.completed = false;
|
||||
straightenStartRef.current = performance.now();
|
||||
setCanMove(false);
|
||||
if (groupRef.current) {
|
||||
groupRef.current.rotation.set(...PYLON_DOWNED_ROTATION);
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
setIsStraightening(false);
|
||||
pylonStraighteningSignal.started = false;
|
||||
// Keep pylon upright while PylonFarmerNPC plays the audio sequence.
|
||||
// PylonFarmerNPC will call setMissionStep("pylon", "inspected") once done.
|
||||
setIsRaised(true);
|
||||
setCanMove(true);
|
||||
pylonStraighteningSignal.completed = true;
|
||||
}, PYLON_STRAIGHTEN_ANIMATION_DURATION_MS);
|
||||
};
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={PYLON_WORLD_POSITION}
|
||||
rotation={PYLON_DOWNED_ROTATION}
|
||||
>
|
||||
<primitive object={scene.clone(true)} />
|
||||
{isPylonInteractive ? (
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={
|
||||
step === "arrived" ? "Inspecter le pylône" : "Redresser le pylône"
|
||||
}
|
||||
position={PYLON_WORLD_POSITION}
|
||||
radius={PYLON_NARRATIVE_INTERACT_RADIUS}
|
||||
onPress={() => {
|
||||
if (step === "arrived") {
|
||||
if (!hasPlayedFirstAudioRef.current) {
|
||||
hasPlayedFirstAudioRef.current = true;
|
||||
void (async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (!manifest) return;
|
||||
const audio = await playDialogueById(
|
||||
manifest,
|
||||
PYLON_NARRATIVE_DIALOGUES.brokenPylon,
|
||||
);
|
||||
if (!audio) return;
|
||||
audio.addEventListener(
|
||||
"ended",
|
||||
() => {
|
||||
void (async () => {
|
||||
const m = await loadDialogueManifest();
|
||||
if (!m) return;
|
||||
await playDialogueById(m, PYLON_NARRATIVE_DIALOGUES.demandeAide);
|
||||
})();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
})();
|
||||
} else {
|
||||
void (async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (!manifest) return;
|
||||
await playDialogueById(manifest, PYLON_NARRATIVE_DIALOGUES.demandeAide);
|
||||
})();
|
||||
}
|
||||
} else if (step === "npc-return" && !isStraightening) {
|
||||
beginStraighten();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<mesh>
|
||||
<sphereGeometry args={[1, 8, 8]} />
|
||||
<meshBasicMaterial transparent opacity={0} depthWrite={false} />
|
||||
</mesh>
|
||||
</InteractableObject>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(PYLON_MODEL_PATH);
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { useAnimations } from "@react-three/drei";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { SkeletonUtils } from "three-stdlib";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import {
|
||||
PYLON_FARMER_NPC_AFTER_POSITION,
|
||||
PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight,
|
||||
PYLON_FARMER_NPC_AFTER_SCALE,
|
||||
PYLON_FARMER_NPC_POSITION,
|
||||
PYLON_FARMER_NPC_WALK_LOOK_AT,
|
||||
PYLON_FARMER_NPC_WALK_SPEED,
|
||||
PYLON_NARRATIVE_DIALOGUES,
|
||||
PYLON_NARRATIVE_INTERACT_RADIUS,
|
||||
PYLON_WORLD_POSITION,
|
||||
} from "@/data/gameplay/pylonConfig";
|
||||
import { pylonStraighteningSignal } from "@/components/gameplay/pylon/pylonSignals";
|
||||
|
||||
const ELECTRICIENNE_MODEL_PATH = "/models/electricienne-animated/model.gltf";
|
||||
const ANIM_FADE = 0.3;
|
||||
const ARRIVE_THRESHOLD = 0.12;
|
||||
|
||||
type NPCAnimation = "idle" | "walk" | "push";
|
||||
|
||||
const _target = new THREE.Vector3();
|
||||
|
||||
/**
|
||||
* Compute the Y rotation (radians) for a model whose default forward
|
||||
* direction is +Z, so that it faces from `from` toward `to`.
|
||||
*/
|
||||
function faceToward(from: THREE.Vector3, to: readonly [number, number, number]): number {
|
||||
const dx = to[0] - from.x;
|
||||
const dz = to[2] - from.z;
|
||||
return Math.atan2(dx, dz);
|
||||
}
|
||||
|
||||
export function PylonFarmerNPC(): React.JSX.Element | null {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const step = useGameStore((state) => state.pylon.currentStep);
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
const camera = useThree((state) => state.camera);
|
||||
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const currentPosRef = useRef(new THREE.Vector3(...PYLON_FARMER_NPC_POSITION));
|
||||
|
||||
// Animation state guard — null forces playAnim to always trigger
|
||||
const currentAnimRef = useRef<NPCAnimation | null>(null);
|
||||
|
||||
// Signal edge tracking
|
||||
const wasStraighteningRef = useRef(false);
|
||||
const wasCompletedRef = useRef(false);
|
||||
|
||||
// Saved Y rotation used whenever the NPC is stationary
|
||||
const savedRotationYRef = useRef<number>(0);
|
||||
|
||||
const { scene, animations } = useLoggedGLTF(ELECTRICIENNE_MODEL_PATH, {
|
||||
scope: "PylonFarmerNPC",
|
||||
});
|
||||
const model = useMemo(() => SkeletonUtils.clone(scene), [scene]);
|
||||
|
||||
// actions is in deps of playAnim: when useAnimations populates it (async useState
|
||||
// inside drei), playAnim recreates → useEffect([step, playAnim]) re-fires → animation plays.
|
||||
const { actions } = useAnimations(animations, model);
|
||||
|
||||
// ─── playAnim ─────────────────────────────────────────────────────────────
|
||||
// NOTE: actions is intentionally in the dep array so this callback is
|
||||
// recreated when drei's internal state populates the actions map.
|
||||
const playAnim = useCallback(
|
||||
(name: NPCAnimation, fade = ANIM_FADE): void => {
|
||||
if (currentAnimRef.current === name) return;
|
||||
currentAnimRef.current = name;
|
||||
|
||||
Object.values(actions).forEach((a) => a?.fadeOut(fade));
|
||||
|
||||
const action = actions[name];
|
||||
if (!action) return;
|
||||
|
||||
if (name === "push") {
|
||||
action.setLoop(THREE.LoopOnce, 1);
|
||||
action.clampWhenFinished = true;
|
||||
}
|
||||
action.reset().fadeIn(fade).play();
|
||||
},
|
||||
[actions],
|
||||
);
|
||||
|
||||
// ─── Async audio after pylon is raised ────────────────────────────────────
|
||||
const playPostRaiseAudioAndAdvance = useCallback(async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (manifest) {
|
||||
// "N'hésite pas, si tu as besoin d'autre chose !"
|
||||
const audio = await playDialogueById(
|
||||
manifest,
|
||||
PYLON_NARRATIVE_DIALOGUES.electricienneApresMontage,
|
||||
);
|
||||
if (audio) {
|
||||
await new Promise<void>((resolve) => {
|
||||
audio.addEventListener("ended", () => resolve(), { once: true });
|
||||
audio.addEventListener("error", () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
pylonStraighteningSignal.completed = false;
|
||||
setMissionStep("pylon", "inspected");
|
||||
}, [setMissionStep]);
|
||||
|
||||
// ─── Step-driven animation ────────────────────────────────────────────────
|
||||
// Fires when step changes OR when playAnim changes (i.e. when actions load).
|
||||
useEffect(() => {
|
||||
currentAnimRef.current = null;
|
||||
if (step === "arrived") {
|
||||
currentPosRef.current.set(...PYLON_FARMER_NPC_POSITION);
|
||||
wasStraighteningRef.current = false;
|
||||
wasCompletedRef.current = false;
|
||||
savedRotationYRef.current = 0;
|
||||
playAnim("idle");
|
||||
} else if (step === "npc-return") {
|
||||
playAnim("walk");
|
||||
} else if (step === "inspected") {
|
||||
playAnim("idle");
|
||||
}
|
||||
}, [step, playAnim]);
|
||||
|
||||
// ─── Per-frame: movement + rotation + signal detection ───────────────────
|
||||
useFrame((_, delta) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
const isStraightening = pylonStraighteningSignal.started;
|
||||
const isCompleted = pylonStraighteningSignal.completed;
|
||||
|
||||
// Rising edge: pylon straightening starts → push
|
||||
if (isStraightening && !wasStraighteningRef.current) {
|
||||
wasStraighteningRef.current = true;
|
||||
currentAnimRef.current = null;
|
||||
playAnim("push");
|
||||
}
|
||||
|
||||
// Rising edge: straightening completed → idle + face player + audio
|
||||
if (isCompleted && !wasCompletedRef.current) {
|
||||
wasCompletedRef.current = true;
|
||||
currentAnimRef.current = null;
|
||||
playAnim("idle");
|
||||
savedRotationYRef.current = faceToward(currentPosRef.current, [
|
||||
camera.position.x,
|
||||
camera.position.y,
|
||||
camera.position.z,
|
||||
]);
|
||||
void playPostRaiseAudioAndAdvance();
|
||||
}
|
||||
|
||||
// ── Position ──────────────────────────────────────────────────────────
|
||||
if (step === "npc-return" && !isCompleted) {
|
||||
const targetPos = isStraightening
|
||||
? PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight
|
||||
: PYLON_FARMER_NPC_AFTER_POSITION;
|
||||
_target.set(...targetPos);
|
||||
|
||||
const dist = currentPosRef.current.distanceTo(_target);
|
||||
if (dist > ARRIVE_THRESHOLD) {
|
||||
const t = Math.min((PYLON_FARMER_NPC_WALK_SPEED * delta) / dist, 1);
|
||||
currentPosRef.current.lerp(_target, t);
|
||||
} else if (!isStraightening && currentAnimRef.current === "walk") {
|
||||
playAnim("idle");
|
||||
savedRotationYRef.current = faceToward(currentPosRef.current, PYLON_WORLD_POSITION);
|
||||
}
|
||||
group.position.copy(currentPosRef.current);
|
||||
} else if (step === "inspected") {
|
||||
group.position.set(...PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight);
|
||||
} else if (isCompleted) {
|
||||
group.position.copy(currentPosRef.current);
|
||||
} else {
|
||||
group.position.set(...PYLON_FARMER_NPC_POSITION);
|
||||
}
|
||||
|
||||
// ── Rotation ──────────────────────────────────────────────────────────
|
||||
if (step === "npc-return" && !isCompleted && currentAnimRef.current === "walk") {
|
||||
const walkRotY = faceToward(currentPosRef.current, PYLON_FARMER_NPC_WALK_LOOK_AT);
|
||||
group.rotation.set(0, walkRotY, 0);
|
||||
} else {
|
||||
group.rotation.set(0, savedRotationYRef.current, 0);
|
||||
}
|
||||
|
||||
group.scale.setScalar(PYLON_FARMER_NPC_AFTER_SCALE);
|
||||
});
|
||||
|
||||
if (mainState !== "pylon") return null;
|
||||
if (step !== "arrived" && step !== "npc-return" && step !== "inspected")
|
||||
return null;
|
||||
|
||||
return (
|
||||
<group ref={groupRef} position={PYLON_FARMER_NPC_POSITION}>
|
||||
<primitive object={model} />
|
||||
{step === "arrived" ? (
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label="Parler à l'électricienne"
|
||||
position={PYLON_FARMER_NPC_POSITION}
|
||||
radius={PYLON_NARRATIVE_INTERACT_RADIUS}
|
||||
onPress={() => {
|
||||
void (async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (!manifest) {
|
||||
setMissionStep("pylon", "npc-return");
|
||||
return;
|
||||
}
|
||||
const audio = await playDialogueById(
|
||||
manifest,
|
||||
PYLON_NARRATIVE_DIALOGUES.electricienneWelcome,
|
||||
);
|
||||
if (!audio) {
|
||||
setMissionStep("pylon", "npc-return");
|
||||
return;
|
||||
}
|
||||
audio.addEventListener(
|
||||
"ended",
|
||||
() => setMissionStep("pylon", "npc-return"),
|
||||
{ once: true },
|
||||
);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<mesh>
|
||||
<sphereGeometry args={[1, 8, 8]} />
|
||||
<meshBasicMaterial transparent opacity={0} depthWrite={false} />
|
||||
</mesh>
|
||||
</InteractableObject>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
useGLTF.preload(ELECTRICIENNE_MODEL_PATH);
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { LIGHTING_STATE } from "@/world/lightingState";
|
||||
import { LIGHTING_DEFAULTS } from "@/data/world/lightingConfig";
|
||||
|
||||
// ─── Pylon atmosphere colours ─────────────────────────────────────────────────
|
||||
// Applied from "approaching" until the pylon mission ends.
|
||||
const PYLON_AMBIENT_COLOR = "#7b87c8"; // blue-violet
|
||||
const PYLON_SUN_COLOR = "#a882d4"; // lavender-purple
|
||||
|
||||
// Lerp speed (1 = full transition in ~1 s at 60 fps)
|
||||
const TRANSITION_SPEED = 0.8;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function PylonLightingEffect(): null {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const step = useGameStore((state) => state.pylon.currentStep);
|
||||
|
||||
// True from "approaching" until narrator-outro (lighting resets before the outro audio)
|
||||
const isActive = mainState === "pylon" && step !== "locked" && step !== "narrator-outro";
|
||||
|
||||
// Working THREE.Color instances — lerped every frame
|
||||
const ambientRef = useRef(new THREE.Color(LIGHTING_STATE.ambientColor));
|
||||
const sunRef = useRef(new THREE.Color(LIGHTING_STATE.sunColor));
|
||||
|
||||
// Target colours — updated reactively when isActive changes
|
||||
const targetAmbientRef = useRef(new THREE.Color(LIGHTING_DEFAULTS.ambientColor));
|
||||
const targetSunRef = useRef(new THREE.Color(LIGHTING_DEFAULTS.sunColor));
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
targetAmbientRef.current.set(PYLON_AMBIENT_COLOR);
|
||||
targetSunRef.current.set(PYLON_SUN_COLOR);
|
||||
} else {
|
||||
targetAmbientRef.current.set(LIGHTING_DEFAULTS.ambientColor);
|
||||
targetSunRef.current.set(LIGHTING_DEFAULTS.sunColor);
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const t = Math.min(TRANSITION_SPEED * delta, 1);
|
||||
|
||||
ambientRef.current.lerp(targetAmbientRef.current, t);
|
||||
sunRef.current.lerp(targetSunRef.current, t);
|
||||
|
||||
LIGHTING_STATE.ambientColor = `#${ambientRef.current.getHexString()}`;
|
||||
LIGHTING_STATE.sunColor = `#${sunRef.current.getHexString()}`;
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useDialoguePlayback } from "@/hooks/gameplay/useDialoguePlayback";
|
||||
import { ZoneDetection } from "@/components/zone/ZoneDetection";
|
||||
import { PylonFarmerNPC } from "@/components/gameplay/pylon/PylonFarmerNPC";
|
||||
import { PylonNarratorOutro } from "@/components/gameplay/pylon/PylonNarratorOutro";
|
||||
import { PYLON_APPROACH_ZONE, PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones";
|
||||
import { PYLON_NARRATIVE_DIALOGUES } from "@/data/gameplay/pylonConfig";
|
||||
|
||||
export function PylonNarrativeFlow(): React.JSX.Element | null {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const step = useGameStore((state) => state.pylon.currentStep);
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
|
||||
useDialoguePlayback({
|
||||
enabled: mainState === "pylon" && step === "approaching",
|
||||
dialogueId: PYLON_NARRATIVE_DIALOGUES.electricOutage,
|
||||
});
|
||||
|
||||
useDialoguePlayback({
|
||||
enabled: mainState === "pylon" && step === "arrived",
|
||||
dialogueId: PYLON_NARRATIVE_DIALOGUES.searchCentral,
|
||||
});
|
||||
|
||||
// narrator-outro audio sequence + completeMission are handled in PylonNarratorOutro
|
||||
|
||||
if (mainState !== "pylon") return null;
|
||||
|
||||
if (step === "locked") {
|
||||
return (
|
||||
<ZoneDetection
|
||||
key="pylon-approach"
|
||||
zone={PYLON_APPROACH_ZONE}
|
||||
onEnter={() => setMissionStep("pylon", "approaching")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "approaching") {
|
||||
return (
|
||||
<ZoneDetection
|
||||
key="pylon-arrived"
|
||||
zone={PYLON_ARRIVED_ZONE}
|
||||
onEnter={() => setMissionStep("pylon", "arrived")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "arrived" || step === "npc-return" || step === "inspected") {
|
||||
return <PylonFarmerNPC />;
|
||||
}
|
||||
|
||||
if (step === "narrator-outro") {
|
||||
return <PylonNarratorOutro />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect } from "react";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
import { PYLON_NARRATIVE_DIALOGUES } from "@/data/gameplay/pylonConfig";
|
||||
|
||||
/**
|
||||
* Plays the narrator-outro audio sequence:
|
||||
* 1. electricienne_aurevoir ("À la prochaine !")
|
||||
* 2. narrateur_courantrepare ("powerRestored")
|
||||
* then completes the pylon mission.
|
||||
*/
|
||||
export function PylonNarratorOutro(): null {
|
||||
const completeMission = useGameStore((state) => state.completeMission);
|
||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCanMove(false);
|
||||
|
||||
void (async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (cancelled || !manifest) {
|
||||
setCanMove(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Électricienne : "À la prochaine !"
|
||||
const audio1 = await playDialogueById(
|
||||
manifest,
|
||||
PYLON_NARRATIVE_DIALOGUES.electricienneAurevoir,
|
||||
);
|
||||
if (audio1 && !cancelled) {
|
||||
await new Promise<void>((resolve) => {
|
||||
audio1.addEventListener("ended", () => resolve(), { once: true });
|
||||
audio1.addEventListener("error", () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelled) {
|
||||
setCanMove(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Narrateur : "Le courant est réparé"
|
||||
const audio2 = await playDialogueById(
|
||||
manifest,
|
||||
PYLON_NARRATIVE_DIALOGUES.powerRestored,
|
||||
);
|
||||
if (audio2 && !cancelled) {
|
||||
audio2.addEventListener(
|
||||
"ended",
|
||||
() => {
|
||||
setCanMove(true);
|
||||
completeMission("pylon");
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
} else {
|
||||
setCanMove(true);
|
||||
completeMission("pylon");
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
setCanMove(true);
|
||||
};
|
||||
}, [completeMission, setCanMove]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Shared runtime signal set by PylonDownedPylon when the straighten
|
||||
* animation starts, so PylonFarmerNPC can switch its lerp target.
|
||||
*
|
||||
* `completed` is set after the straighten animation finishes so
|
||||
* PylonFarmerNPC can play the post-raise audio sequence before
|
||||
* transitioning to the repair game.
|
||||
*/
|
||||
export const pylonStraighteningSignal = { started: false, completed: false };
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
||||
import type { ZoneConfig } from "@/types/gameplay/zone";
|
||||
|
||||
interface ZoneDetectionProps {
|
||||
zone: ZoneConfig;
|
||||
onEnter: () => void;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const _cameraPos = new THREE.Vector3();
|
||||
|
||||
export function ZoneDebugVisual({
|
||||
zone,
|
||||
active,
|
||||
}: {
|
||||
zone: ZoneConfig;
|
||||
active: boolean;
|
||||
}): React.JSX.Element | null {
|
||||
if (!isDebugEnabled()) return null;
|
||||
return (
|
||||
<group position={zone.position}>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[zone.radius - 0.2, zone.radius, 32]} />
|
||||
<meshBasicMaterial
|
||||
color={active ? "#22c55e" : "#fbbf24"}
|
||||
transparent
|
||||
opacity={0.6}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh>
|
||||
<cylinderGeometry
|
||||
args={[zone.radius, zone.radius, zone.height, 16, 1, true]}
|
||||
/>
|
||||
<meshBasicMaterial
|
||||
color={active ? "#22c55e" : "#fbbf24"}
|
||||
transparent
|
||||
opacity={0.08}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export function ZoneDetection({
|
||||
zone,
|
||||
onEnter,
|
||||
height,
|
||||
}: ZoneDetectionProps): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const hasTriggeredRef = useRef(false);
|
||||
const onEnterRef = useRef(onEnter);
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onEnterRef.current = onEnter;
|
||||
}, [onEnter]);
|
||||
|
||||
useFrame(() => {
|
||||
if (hasTriggeredRef.current) return;
|
||||
|
||||
camera.getWorldPosition(_cameraPos);
|
||||
const dx = _cameraPos.x - zone.position[0];
|
||||
const dz = _cameraPos.z - zone.position[2];
|
||||
const horizontalDist = Math.sqrt(dx * dx + dz * dz);
|
||||
|
||||
if (horizontalDist > zone.radius) return;
|
||||
|
||||
const zoneHeight = height ?? zone.height;
|
||||
if (_cameraPos.y < zone.position[1] - zoneHeight / 2) return;
|
||||
if (_cameraPos.y > zone.position[1] + zoneHeight / 2) return;
|
||||
|
||||
hasTriggeredRef.current = true;
|
||||
setIsActive(true);
|
||||
onEnterRef.current();
|
||||
});
|
||||
|
||||
return <ZoneDebugVisual zone={zone} active={isActive} />;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export const PYLON_WORLD_POSITION: Vector3Tuple = [-31.5, 3.5, 36.04];
|
||||
|
||||
export const PYLON_DOWNED_ROTATION: Vector3Tuple = [0, 0, -0.9];
|
||||
|
||||
export const PYLON_UPRIGHT_ROTATION: Vector3Tuple = [0, 0, 0];
|
||||
|
||||
export const PYLON_FARMER_NPC_POSITION: Vector3Tuple = [
|
||||
-16.13,
|
||||
3.2,
|
||||
52.46
|
||||
];
|
||||
|
||||
export const PYLON_FARMER_NPC_AFTER_POSITION: Vector3Tuple = [
|
||||
PYLON_WORLD_POSITION[0] + 3,
|
||||
PYLON_WORLD_POSITION[1] + 0.2,
|
||||
PYLON_WORLD_POSITION[2],
|
||||
];
|
||||
|
||||
/** Point vers lequel l'électricienne regarde pendant sa marche vers le pylône (ajustable) */
|
||||
export const PYLON_FARMER_NPC_WALK_LOOK_AT: Vector3Tuple = [
|
||||
PYLON_WORLD_POSITION[0] + 3,
|
||||
PYLON_WORLD_POSITION[1] + 0.2,
|
||||
PYLON_WORLD_POSITION[2],
|
||||
];
|
||||
|
||||
/** Position finale du PNJ quand le pylône se redresse */
|
||||
export const PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight: Vector3Tuple = [
|
||||
PYLON_WORLD_POSITION[0] + 1,
|
||||
PYLON_WORLD_POSITION[1],
|
||||
PYLON_WORLD_POSITION[2],
|
||||
];
|
||||
|
||||
/** Rotation (X Y Z radians) du PNJ une fois arrivé sous le pylône */
|
||||
export const PYLON_FARMER_NPC_AFTER_ROTATION: Vector3Tuple = [0, 0, 0];
|
||||
|
||||
/** Scale uniforme du PNJ une fois arrivé sous le pylône */
|
||||
export const PYLON_FARMER_NPC_AFTER_SCALE = 1.55;
|
||||
|
||||
/** Vitesse du lerp de déplacement du PNJ (unités/s) */
|
||||
export const PYLON_FARMER_NPC_WALK_SPEED = 2;
|
||||
|
||||
export const PYLON_NARRATIVE_INTERACT_RADIUS = 3.5;
|
||||
|
||||
export const PYLON_STRAIGHTEN_ANIMATION_DURATION_MS = 2200;
|
||||
|
||||
export const PYLON_NARRATIVE_DIALOGUES = {
|
||||
electricOutage: "narrateur_coupureelec",
|
||||
searchCentral: "narrateur_fouillelecentre",
|
||||
brokenPylon: "narrateur_poteaueleccasse",
|
||||
demandeAide: "narrateur_demande_aide",
|
||||
farmerHelp: "fermier_coupdemain",
|
||||
electricienneWelcome: "electricienne_welcome",
|
||||
electricienneApresMontage: "electricienne_apresMontage",
|
||||
electricienneAurevoir: "electricienne_aurevoir",
|
||||
powerRestored: "narrateur_courantrepare",
|
||||
} as const;
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
RepairMissionTriggerConfig,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
import { EBIKE_WORLD_POSITION } from "@/data/ebike/ebikeConfig";
|
||||
import { PYLON_WORLD_POSITION } from "@/data/gameplay/pylonConfig";
|
||||
|
||||
export const REPAIR_MISSION_ANCHOR_IDS: Partial<
|
||||
Record<RepairMissionId, string>
|
||||
@@ -15,7 +16,7 @@ const EBIKE_REPAIR_POSITION = EBIKE_WORLD_POSITION satisfies Vector3Tuple;
|
||||
|
||||
const REPAIR_MISSION_POSITIONS = {
|
||||
ebike: EBIKE_REPAIR_POSITION,
|
||||
pylon: [64, 0, -66],
|
||||
pylon: PYLON_WORLD_POSITION,
|
||||
farm: [-24, 0, 42],
|
||||
} as const satisfies Record<RepairMissionId, Vector3Tuple>;
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ const REPAIR_MISSION_ID_VALUES: ReadonlySet<string> = new Set(
|
||||
|
||||
export const MISSION_STEPS = [
|
||||
"locked",
|
||||
"approaching",
|
||||
"arrived",
|
||||
"npc-return",
|
||||
"waiting",
|
||||
"inspected",
|
||||
"fragmented",
|
||||
@@ -17,6 +20,7 @@ export const MISSION_STEPS = [
|
||||
"repairing",
|
||||
"reassembling",
|
||||
"done",
|
||||
"narrator-outro",
|
||||
] as const satisfies readonly MissionStep[];
|
||||
const MISSION_STEP_VALUES: ReadonlySet<string> = new Set(MISSION_STEPS);
|
||||
|
||||
@@ -28,9 +32,18 @@ export function isMissionStep(value: string): value is MissionStep {
|
||||
return MISSION_STEP_VALUES.has(value);
|
||||
}
|
||||
|
||||
export function getNextMissionStep(step: MissionStep): MissionStep {
|
||||
export function getNextMissionStep(
|
||||
step: MissionStep,
|
||||
mission?: RepairMissionId,
|
||||
): MissionStep {
|
||||
switch (step) {
|
||||
case "locked":
|
||||
return mission === "pylon" ? "approaching" : "waiting";
|
||||
case "approaching":
|
||||
return "arrived";
|
||||
case "arrived":
|
||||
return "npc-return";
|
||||
case "npc-return":
|
||||
return "waiting";
|
||||
case "waiting":
|
||||
return "inspected";
|
||||
@@ -43,16 +56,29 @@ export function getNextMissionStep(step: MissionStep): MissionStep {
|
||||
case "repairing":
|
||||
return "reassembling";
|
||||
case "reassembling":
|
||||
case "done":
|
||||
return "done";
|
||||
case "done":
|
||||
return mission === "pylon" ? "narrator-outro" : "done";
|
||||
case "narrator-outro":
|
||||
return "narrator-outro";
|
||||
}
|
||||
}
|
||||
|
||||
export function getPreviousMissionStep(step: MissionStep): MissionStep {
|
||||
export function getPreviousMissionStep(
|
||||
step: MissionStep,
|
||||
mission?: RepairMissionId,
|
||||
): MissionStep {
|
||||
switch (step) {
|
||||
case "locked":
|
||||
case "waiting":
|
||||
return "locked";
|
||||
case "approaching":
|
||||
return "locked";
|
||||
case "arrived":
|
||||
return "approaching";
|
||||
case "npc-return":
|
||||
return "arrived";
|
||||
case "waiting":
|
||||
return mission === "pylon" ? "npc-return" : "locked";
|
||||
case "inspected":
|
||||
return "waiting";
|
||||
case "fragmented":
|
||||
@@ -65,5 +91,7 @@ export function getPreviousMissionStep(step: MissionStep): MissionStep {
|
||||
return "repairing";
|
||||
case "done":
|
||||
return "reassembling";
|
||||
case "narrator-outro":
|
||||
return "done";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,20 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
"pylon-cable-left-replacement",
|
||||
],
|
||||
scanPartSeconds: 1.4,
|
||||
brokenParts: [],
|
||||
brokenParts: [
|
||||
{
|
||||
id: "pylon-grid-relay",
|
||||
label: "Grid relay",
|
||||
nodeName: "lampe",
|
||||
caseSlotName: "placeholder_1",
|
||||
},
|
||||
{
|
||||
id: "pylon-damaged-panel",
|
||||
label: "Damaged solar panel",
|
||||
nodeName: "pylone",
|
||||
caseSlotName: "placeholder_2",
|
||||
},
|
||||
],
|
||||
replacementParts: [
|
||||
{
|
||||
id: "pylon-cable-right-replacement",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ZoneConfig } from "@/types/gameplay/zone";
|
||||
import { PYLON_WORLD_POSITION } from "@/data/gameplay/pylonConfig";
|
||||
|
||||
// Zones qui active la coupure de courant
|
||||
export const PYLON_APPROACH_ZONE: ZoneConfig = {
|
||||
id: "pylon-approach",
|
||||
position: [
|
||||
5,
|
||||
4,
|
||||
-21.5
|
||||
],
|
||||
radius: 10,
|
||||
height: 18,
|
||||
oneShot: true,
|
||||
};
|
||||
|
||||
// Zone qui active la cinématique d'arrivée du pylône
|
||||
export const PYLON_ARRIVED_ZONE: ZoneConfig = {
|
||||
id: "pylon-arrived",
|
||||
position: [
|
||||
PYLON_WORLD_POSITION[0],
|
||||
PYLON_WORLD_POSITION[1],
|
||||
PYLON_WORLD_POSITION[2],
|
||||
],
|
||||
radius: 30,
|
||||
height: 15,
|
||||
oneShot: true,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import type GUI from "lil-gui";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
|
||||
export function usePlayerPositionDebug(): void {
|
||||
const pos = useRef({ x: 0, y: 0, z: 0 });
|
||||
const controllers = useRef<{ updateDisplay: () => void }[]>([]);
|
||||
|
||||
useDebugFolder("Game", (folder: GUI) => {
|
||||
const sub = folder.addFolder("Player Position");
|
||||
sub.open();
|
||||
|
||||
controllers.current = [
|
||||
sub.add(pos.current, "x").name("X").decimals(2).disable(),
|
||||
sub.add(pos.current, "y").name("Y").decimals(2).disable(),
|
||||
sub.add(pos.current, "z").name("Z").decimals(2).disable(),
|
||||
];
|
||||
});
|
||||
|
||||
useFrame(() => {
|
||||
const p = window.playerPos;
|
||||
if (!p) return;
|
||||
pos.current.x = p[0];
|
||||
pos.current.y = p[1];
|
||||
pos.current.z = p[2];
|
||||
for (const c of controllers.current) c.updateDisplay();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect } from "react";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
|
||||
interface UseDialoguePlaybackOptions {
|
||||
enabled: boolean;
|
||||
dialogueId: string | null;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export function useDialoguePlayback({
|
||||
enabled,
|
||||
dialogueId,
|
||||
onComplete,
|
||||
}: UseDialoguePlaybackOptions): void {
|
||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !dialogueId) return undefined;
|
||||
|
||||
let isCancelled = false;
|
||||
setCanMove(false);
|
||||
|
||||
void (async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (isCancelled || !manifest) {
|
||||
setCanMove(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = await playDialogueById(manifest, dialogueId);
|
||||
if (isCancelled || !audio) {
|
||||
setCanMove(true);
|
||||
return;
|
||||
}
|
||||
|
||||
audio.addEventListener(
|
||||
"ended",
|
||||
() => {
|
||||
setCanMove(true);
|
||||
onComplete?.();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
setCanMove(true);
|
||||
};
|
||||
}, [enabled, dialogueId, onComplete, setCanMove]);
|
||||
}
|
||||
@@ -146,7 +146,7 @@ function completeEbikeState(state: GameState): GameStateUpdate {
|
||||
},
|
||||
pylon: {
|
||||
...state.pylon,
|
||||
currentStep: "waiting",
|
||||
currentStep: "approaching",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -212,7 +212,7 @@ function advanceRepairMissionState(
|
||||
state: GameState,
|
||||
mission: RepairMissionId,
|
||||
): GameStateUpdate {
|
||||
const nextStep = getNextMissionStep(state[mission].currentStep);
|
||||
const nextStep = getNextMissionStep(state[mission].currentStep, mission);
|
||||
if (nextStep === "done") {
|
||||
return completeMissionState(state, mission);
|
||||
}
|
||||
@@ -227,7 +227,7 @@ function rewindRepairMissionState(
|
||||
return setMissionStepState(
|
||||
state,
|
||||
mission,
|
||||
getPreviousMissionStep(state[mission].currentStep),
|
||||
getPreviousMissionStep(state[mission].currentStep, mission),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -83,10 +83,39 @@ export interface RepairMissionConfig {
|
||||
|
||||
export type MissionStep =
|
||||
| "locked"
|
||||
| "approaching"
|
||||
| "arrived"
|
||||
| "npc-return"
|
||||
| "waiting"
|
||||
| "inspected"
|
||||
| "fragmented"
|
||||
| "scanning"
|
||||
| "repairing"
|
||||
| "reassembling"
|
||||
| "done";
|
||||
| "done"
|
||||
| "narrator-outro";
|
||||
|
||||
export const PYLON_NARRATIVE_STEPS = [
|
||||
"approaching",
|
||||
"arrived",
|
||||
"npc-return",
|
||||
"narrator-outro",
|
||||
] as const;
|
||||
|
||||
export const REPAIR_GAME_STEPS = [
|
||||
"waiting",
|
||||
"inspected",
|
||||
"fragmented",
|
||||
"scanning",
|
||||
"repairing",
|
||||
"reassembling",
|
||||
"done",
|
||||
] as const;
|
||||
|
||||
export function isPylonNarrativeStep(step: MissionStep): boolean {
|
||||
return (PYLON_NARRATIVE_STEPS as readonly MissionStep[]).includes(step);
|
||||
}
|
||||
|
||||
export function isRepairGameStep(step: MissionStep): boolean {
|
||||
return (REPAIR_GAME_STEPS as readonly MissionStep[]).includes(step);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export interface ZoneConfig {
|
||||
id: string;
|
||||
position: Vector3Tuple;
|
||||
radius: number;
|
||||
height: number;
|
||||
oneShot: boolean;
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Ebike } from "@/components/ebike/Ebike";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||
import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon";
|
||||
import { PylonLightingEffect } from "@/components/gameplay/pylon/PylonLightingEffect";
|
||||
import { PylonNarrativeFlow } from "@/components/gameplay/pylon/PylonNarrativeFlow";
|
||||
import { ZoneDebugVisual } from "@/components/zone/ZoneDetection";
|
||||
import { PYLON_APPROACH_ZONE, PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones";
|
||||
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
|
||||
import {
|
||||
REPAIR_MISSION_POSITION_ENTRIES,
|
||||
REPAIR_MISSION_TRIGGERS,
|
||||
@@ -11,6 +17,7 @@ import {
|
||||
} from "@/data/gameplay/gameStageAnchors";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useRepairMissionAnchorStore } from "@/managers/stores/useRepairMissionAnchorStore";
|
||||
import { isPylonNarrativeStep } from "@/types/gameplay/repairMission";
|
||||
import type { RepairMissionTriggerConfig } from "@/types/gameplay/repairMission";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { getRepairMissionPosition } from "@/utils/gameplay/repairMissionPosition";
|
||||
@@ -83,15 +90,29 @@ function RepairMissionTrigger({
|
||||
|
||||
export function GameStageContent(): React.JSX.Element {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const pylonStep = useGameStore((state) => state.pylon.currentStep);
|
||||
const anchors = useRepairMissionAnchorStore((state) => state.anchors);
|
||||
|
||||
const pylonInNarrative =
|
||||
mainState === "pylon" && isPylonNarrativeStep(pylonStep);
|
||||
|
||||
return (
|
||||
<>
|
||||
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
|
||||
<Ebike key={EBIKE_CONFIG_KEY} position={EBIKE_WORLD_POSITION} />
|
||||
<Ebike position={EBIKE_WORLD_POSITION} />
|
||||
<PylonLightingEffect />
|
||||
<PylonDownedPylon />
|
||||
{isDebugEnabled() ? (
|
||||
<>
|
||||
<ZoneDebugVisual zone={PYLON_APPROACH_ZONE} active={false} />
|
||||
<ZoneDebugVisual zone={PYLON_ARRIVED_ZONE} active={false} />
|
||||
</>
|
||||
) : null}
|
||||
{mainState === "pylon" ? <PylonNarrativeFlow /> : null}
|
||||
{REPAIR_MISSION_POSITION_ENTRIES.map(({ mission }) => {
|
||||
const position = getRepairMissionPosition(mission, anchors);
|
||||
if (!position) return null;
|
||||
if (mission === "pylon" && pylonInNarrative) return null;
|
||||
return (
|
||||
<RepairGame key={mission} mission={mission} position={position} />
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useEnvironmentDebug } from "@/hooks/debug/useEnvironmentDebug";
|
||||
import { useMapPerformanceDebug } from "@/hooks/debug/useMapPerformanceDebug";
|
||||
import { useCharacterDebug } from "@/hooks/debug/useCharacterDebug";
|
||||
import { usePlayerPositionDebug } from "@/hooks/debug/usePlayerPositionDebug";
|
||||
import { useDebugVisualsDebug } from "@/hooks/debug/useDebugVisualsDebug";
|
||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
@@ -49,6 +50,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||
useEnvironmentDebug();
|
||||
useMapPerformanceDebug();
|
||||
useCharacterDebug();
|
||||
usePlayerPositionDebug();
|
||||
useDebugVisualsDebug();
|
||||
|
||||
const cameraMode = useCameraMode();
|
||||
|
||||
Reference in New Issue
Block a user