wip mission 2 refine
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { 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";
|
||||
|
||||
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 { scene } = useGLTF(PYLON_MODEL_PATH);
|
||||
|
||||
useFrame(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
if (!isStraightening || straightenStartRef.current === null) {
|
||||
const targetRotation =
|
||||
step === "narrator-outro"
|
||||
? PYLON_UPRIGHT_ROTATION
|
||||
: PYLON_DOWNED_ROTATION;
|
||||
group.rotation.set(...targetRotation);
|
||||
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),
|
||||
);
|
||||
});
|
||||
|
||||
if (mainState !== "pylon") return null;
|
||||
|
||||
if (
|
||||
step === "approaching" ||
|
||||
step === "waiting" ||
|
||||
step === "inspected" ||
|
||||
step === "fragmented" ||
|
||||
step === "scanning" ||
|
||||
step === "repairing" ||
|
||||
step === "reassembling" ||
|
||||
step === "done"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPylonInteractive = step === "arrived" || step === "npc-return";
|
||||
|
||||
const beginStraighten = (): void => {
|
||||
setIsStraightening(true);
|
||||
straightenStartRef.current = performance.now();
|
||||
setCanMove(false);
|
||||
if (groupRef.current) {
|
||||
groupRef.current.rotation.set(...PYLON_DOWNED_ROTATION);
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
setIsStraightening(false);
|
||||
setCanMove(true);
|
||||
setMissionStep("pylon", "waiting");
|
||||
}, 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") {
|
||||
void (async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (!manifest) return;
|
||||
await playDialogueById(
|
||||
manifest,
|
||||
PYLON_NARRATIVE_DIALOGUES.brokenPylon,
|
||||
);
|
||||
})();
|
||||
} 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,107 @@
|
||||
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_POSITION,
|
||||
PYLON_NARRATIVE_DIALOGUES,
|
||||
PYLON_NARRATIVE_INTERACT_RADIUS,
|
||||
} from "@/data/gameplay/pylonConfig";
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (mainState !== "pylon" || step !== "arrived") return;
|
||||
|
||||
if (!groupRef.current) return;
|
||||
(groupRef.current.userData as Record<string, unknown>).startTime =
|
||||
undefined;
|
||||
}, [mainState, step]);
|
||||
|
||||
useFrame(() => {
|
||||
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);
|
||||
} else {
|
||||
group.position.set(...PYLON_FARMER_NPC_POSITION);
|
||||
}
|
||||
});
|
||||
|
||||
if (mainState !== "pylon") return null;
|
||||
if (step !== "arrived") 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>
|
||||
<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);
|
||||
setMissionStep("pylon", "npc-return");
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<mesh>
|
||||
<sphereGeometry args={[1, 8, 8]} />
|
||||
<meshBasicMaterial transparent opacity={0} depthWrite={false} />
|
||||
</mesh>
|
||||
</InteractableObject>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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_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 === "approaching") {
|
||||
return (
|
||||
<ZoneDetection
|
||||
zone={PYLON_ARRIVED_ZONE}
|
||||
onEnter={() => setMissionStep("pylon", "arrived")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "arrived") {
|
||||
return (
|
||||
<>
|
||||
<PylonDownedPylon />
|
||||
<PylonFarmerNPC />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "npc-return") {
|
||||
return <PylonDownedPylon />;
|
||||
}
|
||||
|
||||
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,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();
|
||||
|
||||
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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user