feat mission-2

This commit is contained in:
math-pixel
2026-06-01 14:40:17 +02:00
parent 813c10f3f7
commit cd0afcda8c
13 changed files with 191 additions and 113 deletions
@@ -12,8 +12,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);
@@ -100,6 +102,16 @@ 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 (introStep !== "await-ebike-mount" && introStep !== "ebike-intro-ride") {
return null;
}
@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
@@ -14,6 +14,7 @@ import {
PYLON_UPRIGHT_ROTATION,
PYLON_WORLD_POSITION,
} from "@/data/gameplay/pylonConfig";
import { pylonStraighteningSignal } from "@/components/gameplay/pylon/pylonSignals";
const PYLON_MODEL_PATH = "/models/pylone/model.gltf";
@@ -25,6 +26,11 @@ export function PylonDownedPylon(): React.JSX.Element | null {
const [isStraightening, setIsStraightening] = 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;
}, [step]);
const { scene } = useGLTF(PYLON_MODEL_PATH);
@@ -33,11 +39,7 @@ export function PylonDownedPylon(): React.JSX.Element | null {
if (!group) return;
if (!isStraightening || straightenStartRef.current === null) {
const targetRotation =
step === "narrator-outro"
? PYLON_UPRIGHT_ROTATION
: PYLON_DOWNED_ROTATION;
group.rotation.set(...targetRotation);
group.rotation.set(...(showUpright ? PYLON_UPRIGHT_ROTATION : PYLON_DOWNED_ROTATION));
return;
}
@@ -53,25 +55,22 @@ export function PylonDownedPylon(): React.JSX.Element | null {
);
});
if (mainState !== "pylon") return null;
if (
step === "approaching" ||
const showUpright =
mainState !== "pylon" ||
step === "waiting" ||
step === "inspected" ||
step === "fragmented" ||
step === "scanning" ||
step === "repairing" ||
step === "reassembling" ||
step === "done"
) {
return null;
}
step === "done" ||
step === "narrator-outro";
const isPylonInteractive = step === "arrived" || step === "npc-return";
const beginStraighten = (): void => {
setIsStraightening(true);
pylonStraighteningSignal.started = true;
straightenStartRef.current = performance.now();
setCanMove(false);
if (groupRef.current) {
@@ -79,8 +78,9 @@ export function PylonDownedPylon(): React.JSX.Element | null {
}
window.setTimeout(() => {
setIsStraightening(false);
pylonStraighteningSignal.started = false;
setCanMove(true);
setMissionStep("pylon", "waiting");
setMissionStep("pylon", "inspected");
}, PYLON_STRAIGHTEN_ANIMATION_DURATION_MS);
};
@@ -101,14 +101,35 @@ export function PylonDownedPylon(): React.JSX.Element | null {
radius={PYLON_NARRATIVE_INTERACT_RADIUS}
onPress={() => {
if (step === "arrived") {
void (async () => {
const manifest = await loadDialogueManifest();
if (!manifest) return;
await playDialogueById(
manifest,
PYLON_NARRATIVE_DIALOGUES.brokenPylon,
);
})();
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();
}
@@ -7,51 +7,58 @@ import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue";
import {
PYLON_FARMER_NPC_AFTER_POSITION,
PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight,
PYLON_FARMER_NPC_AFTER_ROTATION,
PYLON_FARMER_NPC_AFTER_SCALE,
PYLON_FARMER_NPC_POSITION,
PYLON_FARMER_NPC_WALK_SPEED,
PYLON_NARRATIVE_DIALOGUES,
PYLON_NARRATIVE_INTERACT_RADIUS,
} from "@/data/gameplay/pylonConfig";
import { pylonStraighteningSignal } from "@/components/gameplay/pylon/pylonSignals";
const _target = new THREE.Vector3();
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 setCanMove = useGameStore((state) => state.setCanMove);
const groupRef = useRef<THREE.Group>(null);
const currentPosRef = useRef(
new THREE.Vector3(...PYLON_FARMER_NPC_POSITION),
);
// Reset position when entering arrived, set target when entering npc-return
useEffect(() => {
if (mainState !== "pylon" || step !== "arrived") return;
if (step === "arrived") {
currentPosRef.current.set(...PYLON_FARMER_NPC_POSITION);
}
}, [step]);
if (!groupRef.current) return;
(groupRef.current.userData as Record<string, unknown>).startTime =
undefined;
}, [mainState, step]);
useFrame(() => {
useFrame((_, delta) => {
const group = groupRef.current;
if (!group) return;
if (
step === "npc-return" ||
step === "waiting" ||
step === "narrator-outro"
) {
const startTime = (group.userData as Record<string, unknown>)
.startTime as number | undefined;
if (startTime === undefined) {
(group.userData as Record<string, unknown>).startTime =
performance.now();
group.position.set(...PYLON_FARMER_NPC_AFTER_POSITION);
return;
}
group.position.set(...PYLON_FARMER_NPC_AFTER_POSITION);
if (step === "npc-return") {
const targetPos = pylonStraighteningSignal.started
? PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight
: PYLON_FARMER_NPC_AFTER_POSITION;
_target.set(...targetPos);
currentPosRef.current.lerp(_target, Math.min(PYLON_FARMER_NPC_WALK_SPEED * delta, 1));
group.position.copy(currentPosRef.current);
group.rotation.set(...PYLON_FARMER_NPC_AFTER_ROTATION);
group.scale.setScalar(PYLON_FARMER_NPC_AFTER_SCALE);
} else if (step === "inspected") {
group.position.set(...PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight);
group.rotation.set(...PYLON_FARMER_NPC_AFTER_ROTATION);
group.scale.setScalar(PYLON_FARMER_NPC_AFTER_SCALE);
} else {
group.position.set(...PYLON_FARMER_NPC_POSITION);
}
});
if (mainState !== "pylon") return null;
if (step !== "arrived") return null;
if (step !== "arrived" && step !== "npc-return" && step !== "inspected") return null;
return (
<group ref={groupRef} position={PYLON_FARMER_NPC_POSITION}>
@@ -63,45 +70,42 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
<sphereGeometry args={[0.28, 12, 12]} />
<meshStandardMaterial color="#fde68a" />
</mesh>
<InteractableObject
kind="trigger"
label="Parler au fermier"
position={PYLON_FARMER_NPC_POSITION}
radius={PYLON_NARRATIVE_INTERACT_RADIUS}
onPress={() => {
setCanMove(false);
void (async () => {
const manifest = await loadDialogueManifest();
if (!manifest) {
setCanMove(true);
setMissionStep("pylon", "npc-return");
return;
}
const audio = await playDialogueById(
manifest,
PYLON_NARRATIVE_DIALOGUES.farmerHelp,
);
if (!audio) {
setCanMove(true);
setMissionStep("pylon", "npc-return");
return;
}
audio.addEventListener(
"ended",
() => {
setCanMove(true);
{step === "arrived" ? (
<InteractableObject
kind="trigger"
label="Parler au fermier"
position={PYLON_FARMER_NPC_POSITION}
radius={PYLON_NARRATIVE_INTERACT_RADIUS}
onPress={() => {
void (async () => {
const manifest = await loadDialogueManifest();
if (!manifest) {
setMissionStep("pylon", "npc-return");
},
{ once: true },
);
})();
}}
>
<mesh>
<sphereGeometry args={[1, 8, 8]} />
<meshBasicMaterial transparent opacity={0} depthWrite={false} />
</mesh>
</InteractableObject>
return;
}
const audio = await playDialogueById(
manifest,
PYLON_NARRATIVE_DIALOGUES.farmerHelp,
);
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>
);
}
@@ -1,10 +1,9 @@
import { useGameStore } from "@/managers/stores/useGameStore";
import { useDialoguePlayback } from "@/hooks/gameplay/useDialoguePlayback";
import { ZoneDetection } from "@/components/zone/ZoneDetection";
import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon";
import { PylonFarmerNPC } from "@/components/gameplay/pylon/PylonFarmerNPC";
import { PylonNarratorOutro } from "@/components/gameplay/pylon/PylonNarratorOutro";
import { PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones";
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 {
@@ -31,26 +30,28 @@ export function PylonNarrativeFlow(): React.JSX.Element | null {
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") {
return (
<>
<PylonDownedPylon />
<PylonFarmerNPC />
</>
);
}
if (step === "npc-return") {
return <PylonDownedPylon />;
if (step === "arrived" || step === "npc-return" || step === "inspected") {
return <PylonFarmerNPC />;
}
if (step === "narrator-outro") {
@@ -0,0 +1,5 @@
/**
* Shared runtime signal set by PylonDownedPylon when the straighten
* animation starts, so PylonFarmerNPC can switch its lerp target.
*/
export const pylonStraighteningSignal = { started: false };
+1 -1
View File
@@ -12,7 +12,7 @@ interface ZoneDetectionProps {
const _cameraPos = new THREE.Vector3();
function ZoneDebugVisual({
export function ZoneDebugVisual({
zone,
active,
}: {