4 Commits

Author SHA1 Message Date
math-pixel 5177f43d96 Merge branch 'develop' into feat/polish-mission-2
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
2026-06-02 20:43:00 +02:00
math-pixel ff1ec56729 Merge branch 'develop' into feat/polish-mission-2 2026-06-02 20:27:48 +02:00
math-pixel cd0afcda8c feat mission-2 2026-06-01 14:40:17 +02:00
math-pixel 813c10f3f7 wip mission 2 refine 2026-06-01 11:49:48 +02:00
24 changed files with 719 additions and 14 deletions
+9 -3
View File
@@ -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_courantparé.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",
@@ -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,148 @@
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.gltf";
export function PylonDownedPylon(): 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 [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);
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 =
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;
straightenStartRef.current = performance.now();
setCanMove(false);
if (groupRef.current) {
groupRef.current.rotation.set(...PYLON_DOWNED_ROTATION);
}
window.setTimeout(() => {
setIsStraightening(false);
pylonStraighteningSignal.started = false;
setCanMove(true);
setMissionStep("pylon", "inspected");
}, 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,111 @@
import { useEffect, useRef } from "react";
import * as THREE from "three";
import { useFrame } from "@react-three/fiber";
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_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 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 (step === "arrived") {
currentPosRef.current.set(...PYLON_FARMER_NPC_POSITION);
}
}, [step]);
useFrame((_, delta) => {
const group = groupRef.current;
if (!group) return;
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" && step !== "npc-return" && step !== "inspected") return null;
return (
<group ref={groupRef} position={PYLON_FARMER_NPC_POSITION}>
<mesh position={[0, 1, 0]}>
<capsuleGeometry args={[0.4, 1.2, 6, 12]} />
<meshStandardMaterial color="#a16207" />
</mesh>
<mesh position={[0, 1.95, 0]}>
<sphereGeometry args={[0.28, 12, 12]} />
<meshStandardMaterial color="#fde68a" />
</mesh>
{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");
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>
);
}
@@ -0,0 +1,62 @@
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);
const completeMission = useGameStore((state) => state.completeMission);
useDialoguePlayback({
enabled: mainState === "pylon" && step === "approaching",
dialogueId: PYLON_NARRATIVE_DIALOGUES.electricOutage,
});
useDialoguePlayback({
enabled: mainState === "pylon" && step === "arrived",
dialogueId: PYLON_NARRATIVE_DIALOGUES.searchCentral,
});
useDialoguePlayback({
enabled: mainState === "pylon" && step === "narrator-outro",
dialogueId: PYLON_NARRATIVE_DIALOGUES.powerRestored,
onComplete: () => completeMission("pylon"),
});
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,11 @@
import { useGameStore } from "@/managers/stores/useGameStore";
export function PylonNarratorOutro(): React.JSX.Element | null {
const mainState = useGameStore((state) => state.mainState);
const step = useGameStore((state) => state.pylon.currentStep);
if (mainState !== "pylon") return null;
if (step !== "narrator-outro") return null;
return null;
}
@@ -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 };
+83
View File
@@ -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} />;
}
+48
View File
@@ -0,0 +1,48 @@
import type { Vector3Tuple } from "@/types/three/three";
export const PYLON_WORLD_POSITION: Vector3Tuple = [43, 5, 45];
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 = [
PYLON_WORLD_POSITION[0] - 6,
PYLON_WORLD_POSITION[1],
PYLON_WORLD_POSITION[2] + 4,
];
export const PYLON_FARMER_NPC_AFTER_POSITION: Vector3Tuple = [
PYLON_WORLD_POSITION[0] + 3,
PYLON_WORLD_POSITION[1],
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;
/** 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",
powerRestored: "narrateur_courantrepare",
} as const;
+2 -1
View File
@@ -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>;
+32 -4
View File
@@ -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";
}
}
+14 -1
View File
@@ -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",
+26
View File
@@ -0,0 +1,26 @@
import type { ZoneConfig } from "@/types/gameplay/zone";
import { PYLON_WORLD_POSITION } from "@/data/gameplay/pylonConfig";
export const PYLON_APPROACH_ZONE: ZoneConfig = {
id: "pylon-approach",
position: [
PYLON_WORLD_POSITION[0],
PYLON_WORLD_POSITION[1]- 5,
PYLON_WORLD_POSITION[2],
],
radius: 5,
height: 18,
oneShot: true,
};
export const PYLON_ARRIVED_ZONE: ZoneConfig = {
id: "pylon-arrived",
position: [
PYLON_WORLD_POSITION[0] + 5,
PYLON_WORLD_POSITION[1] - 5,
PYLON_WORLD_POSITION[2] + 5,
],
radius: 5,
height: 15,
oneShot: true,
};
+29
View File
@@ -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();
});
}
+53
View File
@@ -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]);
}
+3 -3
View File
@@ -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),
);
}
+30 -1
View File
@@ -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);
}
+9
View File
@@ -0,0 +1,9 @@
import type { Vector3Tuple } from "@/types/three/three";
export interface ZoneConfig {
id: string;
position: Vector3Tuple;
radius: number;
height: number;
oneShot: boolean;
}
+20 -1
View File
@@ -1,6 +1,11 @@
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 { 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 +16,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 +89,28 @@ 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} />
<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} />
);
+2
View File
@@ -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();