Compare commits
2 Commits
5177f43d96
...
0a3966a339
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a3966a339 | |||
| eb5d4076d1 |
Binary file not shown.
Binary file not shown.
@@ -188,6 +188,24 @@
|
|||||||
"voice": "fermier",
|
"voice": "fermier",
|
||||||
"audio": "/sounds/dialogue/fermier_findemission.mp3",
|
"audio": "/sounds/dialogue/fermier_findemission.mp3",
|
||||||
"subtitleCueIndex": 3
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,20 +16,25 @@ import {
|
|||||||
} from "@/data/gameplay/pylonConfig";
|
} from "@/data/gameplay/pylonConfig";
|
||||||
import { pylonStraighteningSignal } from "@/components/gameplay/pylon/pylonSignals";
|
import { pylonStraighteningSignal } from "@/components/gameplay/pylon/pylonSignals";
|
||||||
|
|
||||||
const PYLON_MODEL_PATH = "/models/pylone/model.gltf";
|
const PYLON_MODEL_PATH = "/models/pylone/model.glb";
|
||||||
|
|
||||||
export function PylonDownedPylon(): React.JSX.Element | null {
|
export function PylonDownedPylon(): React.JSX.Element | null {
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
const step = useGameStore((state) => state.pylon.currentStep);
|
const step = useGameStore((state) => state.pylon.currentStep);
|
||||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
|
||||||
const setCanMove = useGameStore((state) => state.setCanMove);
|
const setCanMove = useGameStore((state) => state.setCanMove);
|
||||||
const [isStraightening, setIsStraightening] = useState(false);
|
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 groupRef = useRef<THREE.Group>(null);
|
||||||
const straightenStartRef = useRef<number | null>(null);
|
const straightenStartRef = useRef<number | null>(null);
|
||||||
const hasPlayedFirstAudioRef = useRef(false);
|
const hasPlayedFirstAudioRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step === "arrived") hasPlayedFirstAudioRef.current = false;
|
if (step === "arrived") {
|
||||||
|
hasPlayedFirstAudioRef.current = false;
|
||||||
|
setIsRaised(false);
|
||||||
|
}
|
||||||
}, [step]);
|
}, [step]);
|
||||||
|
|
||||||
const { scene } = useGLTF(PYLON_MODEL_PATH);
|
const { scene } = useGLTF(PYLON_MODEL_PATH);
|
||||||
@@ -56,6 +61,7 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const showUpright =
|
const showUpright =
|
||||||
|
isRaised ||
|
||||||
mainState !== "pylon" ||
|
mainState !== "pylon" ||
|
||||||
step === "waiting" ||
|
step === "waiting" ||
|
||||||
step === "inspected" ||
|
step === "inspected" ||
|
||||||
@@ -71,6 +77,7 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
|||||||
const beginStraighten = (): void => {
|
const beginStraighten = (): void => {
|
||||||
setIsStraightening(true);
|
setIsStraightening(true);
|
||||||
pylonStraighteningSignal.started = true;
|
pylonStraighteningSignal.started = true;
|
||||||
|
pylonStraighteningSignal.completed = false;
|
||||||
straightenStartRef.current = performance.now();
|
straightenStartRef.current = performance.now();
|
||||||
setCanMove(false);
|
setCanMove(false);
|
||||||
if (groupRef.current) {
|
if (groupRef.current) {
|
||||||
@@ -79,8 +86,11 @@ export function PylonDownedPylon(): React.JSX.Element | null {
|
|||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
setIsStraightening(false);
|
setIsStraightening(false);
|
||||||
pylonStraighteningSignal.started = 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);
|
setCanMove(true);
|
||||||
setMissionStep("pylon", "inspected");
|
pylonStraighteningSignal.completed = true;
|
||||||
}, PYLON_STRAIGHTEN_ANIMATION_DURATION_MS);
|
}, PYLON_STRAIGHTEN_ANIMATION_DURATION_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,80 +1,206 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { useFrame } from "@react-three/fiber";
|
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 { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||||
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
import {
|
import {
|
||||||
PYLON_FARMER_NPC_AFTER_POSITION,
|
PYLON_FARMER_NPC_AFTER_POSITION,
|
||||||
PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight,
|
PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight,
|
||||||
PYLON_FARMER_NPC_AFTER_ROTATION,
|
|
||||||
PYLON_FARMER_NPC_AFTER_SCALE,
|
PYLON_FARMER_NPC_AFTER_SCALE,
|
||||||
PYLON_FARMER_NPC_POSITION,
|
PYLON_FARMER_NPC_POSITION,
|
||||||
|
PYLON_FARMER_NPC_WALK_LOOK_AT,
|
||||||
PYLON_FARMER_NPC_WALK_SPEED,
|
PYLON_FARMER_NPC_WALK_SPEED,
|
||||||
PYLON_NARRATIVE_DIALOGUES,
|
PYLON_NARRATIVE_DIALOGUES,
|
||||||
PYLON_NARRATIVE_INTERACT_RADIUS,
|
PYLON_NARRATIVE_INTERACT_RADIUS,
|
||||||
|
PYLON_WORLD_POSITION,
|
||||||
} from "@/data/gameplay/pylonConfig";
|
} from "@/data/gameplay/pylonConfig";
|
||||||
import { pylonStraighteningSignal } from "@/components/gameplay/pylon/pylonSignals";
|
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();
|
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 {
|
export function PylonFarmerNPC(): React.JSX.Element | null {
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
const step = useGameStore((state) => state.pylon.currentStep);
|
const step = useGameStore((state) => state.pylon.currentStep);
|
||||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||||
|
const camera = useThree((state) => state.camera);
|
||||||
|
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
const currentPosRef = useRef(
|
const currentPosRef = useRef(new THREE.Vector3(...PYLON_FARMER_NPC_POSITION));
|
||||||
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],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset position when entering arrived, set target when entering npc-return
|
// ─── 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(() => {
|
useEffect(() => {
|
||||||
|
currentAnimRef.current = null;
|
||||||
if (step === "arrived") {
|
if (step === "arrived") {
|
||||||
currentPosRef.current.set(...PYLON_FARMER_NPC_POSITION);
|
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]);
|
}, [step, playAnim]);
|
||||||
|
|
||||||
|
// ─── Per-frame: movement + rotation + signal detection ───────────────────
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
const group = groupRef.current;
|
const group = groupRef.current;
|
||||||
if (!group) return;
|
if (!group) return;
|
||||||
|
|
||||||
if (step === "npc-return") {
|
const isStraightening = pylonStraighteningSignal.started;
|
||||||
const targetPos = 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_pylone_straight
|
||||||
: PYLON_FARMER_NPC_AFTER_POSITION;
|
: PYLON_FARMER_NPC_AFTER_POSITION;
|
||||||
_target.set(...targetPos);
|
_target.set(...targetPos);
|
||||||
currentPosRef.current.lerp(_target, Math.min(PYLON_FARMER_NPC_WALK_SPEED * delta, 1));
|
|
||||||
|
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);
|
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") {
|
} else if (step === "inspected") {
|
||||||
group.position.set(...PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight);
|
group.position.set(...PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight);
|
||||||
group.rotation.set(...PYLON_FARMER_NPC_AFTER_ROTATION);
|
} else if (isCompleted) {
|
||||||
group.scale.setScalar(PYLON_FARMER_NPC_AFTER_SCALE);
|
group.position.copy(currentPosRef.current);
|
||||||
} else {
|
} else {
|
||||||
group.position.set(...PYLON_FARMER_NPC_POSITION);
|
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 (mainState !== "pylon") return null;
|
||||||
if (step !== "arrived" && step !== "npc-return" && step !== "inspected") return null;
|
if (step !== "arrived" && step !== "npc-return" && step !== "inspected")
|
||||||
|
return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={groupRef} position={PYLON_FARMER_NPC_POSITION}>
|
<group ref={groupRef} position={PYLON_FARMER_NPC_POSITION}>
|
||||||
<mesh position={[0, 1, 0]}>
|
<primitive object={model} />
|
||||||
<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" ? (
|
{step === "arrived" ? (
|
||||||
<InteractableObject
|
<InteractableObject
|
||||||
kind="trigger"
|
kind="trigger"
|
||||||
label="Parler au fermier"
|
label="Parler à l'électricienne"
|
||||||
position={PYLON_FARMER_NPC_POSITION}
|
position={PYLON_FARMER_NPC_POSITION}
|
||||||
radius={PYLON_NARRATIVE_INTERACT_RADIUS}
|
radius={PYLON_NARRATIVE_INTERACT_RADIUS}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@@ -86,7 +212,7 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
|
|||||||
}
|
}
|
||||||
const audio = await playDialogueById(
|
const audio = await playDialogueById(
|
||||||
manifest,
|
manifest,
|
||||||
PYLON_NARRATIVE_DIALOGUES.farmerHelp,
|
PYLON_NARRATIVE_DIALOGUES.electricienneWelcome,
|
||||||
);
|
);
|
||||||
if (!audio) {
|
if (!audio) {
|
||||||
setMissionStep("pylon", "npc-return");
|
setMissionStep("pylon", "npc-return");
|
||||||
@@ -109,3 +235,5 @@ export function PylonFarmerNPC(): React.JSX.Element | null {
|
|||||||
</group>
|
</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;
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ export function PylonNarrativeFlow(): React.JSX.Element | null {
|
|||||||
const mainState = useGameStore((state) => state.mainState);
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
const step = useGameStore((state) => state.pylon.currentStep);
|
const step = useGameStore((state) => state.pylon.currentStep);
|
||||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||||
const completeMission = useGameStore((state) => state.completeMission);
|
|
||||||
|
|
||||||
useDialoguePlayback({
|
useDialoguePlayback({
|
||||||
enabled: mainState === "pylon" && step === "approaching",
|
enabled: mainState === "pylon" && step === "approaching",
|
||||||
@@ -22,11 +21,7 @@ export function PylonNarrativeFlow(): React.JSX.Element | null {
|
|||||||
dialogueId: PYLON_NARRATIVE_DIALOGUES.searchCentral,
|
dialogueId: PYLON_NARRATIVE_DIALOGUES.searchCentral,
|
||||||
});
|
});
|
||||||
|
|
||||||
useDialoguePlayback({
|
// narrator-outro audio sequence + completeMission are handled in PylonNarratorOutro
|
||||||
enabled: mainState === "pylon" && step === "narrator-outro",
|
|
||||||
dialogueId: PYLON_NARRATIVE_DIALOGUES.powerRestored,
|
|
||||||
onComplete: () => completeMission("pylon"),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mainState !== "pylon") return null;
|
if (mainState !== "pylon") return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,72 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
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";
|
||||||
|
|
||||||
export function PylonNarratorOutro(): React.JSX.Element | null {
|
/**
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
* Plays the narrator-outro audio sequence:
|
||||||
const step = useGameStore((state) => state.pylon.currentStep);
|
* 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);
|
||||||
|
|
||||||
if (mainState !== "pylon") return null;
|
useEffect(() => {
|
||||||
if (step !== "narrator-outro") return null;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Shared runtime signal set by PylonDownedPylon when the straighten
|
* Shared runtime signal set by PylonDownedPylon when the straighten
|
||||||
* animation starts, so PylonFarmerNPC can switch its lerp target.
|
* 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 };
|
export const pylonStraighteningSignal = { started: false, completed: false };
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
export const PYLON_WORLD_POSITION: Vector3Tuple = [43, 5, 45];
|
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_DOWNED_ROTATION: Vector3Tuple = [0, 0, -0.9];
|
||||||
|
|
||||||
export const PYLON_UPRIGHT_ROTATION: Vector3Tuple = [0, 0, 0];
|
export const PYLON_UPRIGHT_ROTATION: Vector3Tuple = [0, 0, 0];
|
||||||
|
|
||||||
export const PYLON_FARMER_NPC_POSITION: Vector3Tuple = [
|
export const PYLON_FARMER_NPC_POSITION: Vector3Tuple = [
|
||||||
PYLON_WORLD_POSITION[0] - 6,
|
-16.13,
|
||||||
PYLON_WORLD_POSITION[1],
|
3.2,
|
||||||
PYLON_WORLD_POSITION[2] + 4,
|
52.46
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PYLON_FARMER_NPC_AFTER_POSITION: Vector3Tuple = [
|
export const PYLON_FARMER_NPC_AFTER_POSITION: Vector3Tuple = [
|
||||||
PYLON_WORLD_POSITION[0] + 3,
|
PYLON_WORLD_POSITION[0] + 3,
|
||||||
PYLON_WORLD_POSITION[1],
|
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],
|
PYLON_WORLD_POSITION[2],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -29,7 +36,7 @@ export const PYLON_FARMER_NPC_AFTER_POSITION_pylone_straight: Vector3Tuple = [
|
|||||||
export const PYLON_FARMER_NPC_AFTER_ROTATION: Vector3Tuple = [0, 0, 0];
|
export const PYLON_FARMER_NPC_AFTER_ROTATION: Vector3Tuple = [0, 0, 0];
|
||||||
|
|
||||||
/** Scale uniforme du PNJ une fois arrivé sous le pylône */
|
/** Scale uniforme du PNJ une fois arrivé sous le pylône */
|
||||||
export const PYLON_FARMER_NPC_AFTER_SCALE = 1;
|
export const PYLON_FARMER_NPC_AFTER_SCALE = 1.55;
|
||||||
|
|
||||||
/** Vitesse du lerp de déplacement du PNJ (unités/s) */
|
/** Vitesse du lerp de déplacement du PNJ (unités/s) */
|
||||||
export const PYLON_FARMER_NPC_WALK_SPEED = 2;
|
export const PYLON_FARMER_NPC_WALK_SPEED = 2;
|
||||||
@@ -44,5 +51,8 @@ export const PYLON_NARRATIVE_DIALOGUES = {
|
|||||||
brokenPylon: "narrateur_poteaueleccasse",
|
brokenPylon: "narrateur_poteaueleccasse",
|
||||||
demandeAide: "narrateur_demande_aide",
|
demandeAide: "narrateur_demande_aide",
|
||||||
farmerHelp: "fermier_coupdemain",
|
farmerHelp: "fermier_coupdemain",
|
||||||
|
electricienneWelcome: "electricienne_welcome",
|
||||||
|
electricienneApresMontage: "electricienne_apresMontage",
|
||||||
|
electricienneAurevoir: "electricienne_aurevoir",
|
||||||
powerRestored: "narrateur_courantrepare",
|
powerRestored: "narrateur_courantrepare",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
import type { ZoneConfig } from "@/types/gameplay/zone";
|
import type { ZoneConfig } from "@/types/gameplay/zone";
|
||||||
import { PYLON_WORLD_POSITION } from "@/data/gameplay/pylonConfig";
|
import { PYLON_WORLD_POSITION } from "@/data/gameplay/pylonConfig";
|
||||||
|
|
||||||
|
// Zones qui active la coupure de courant
|
||||||
export const PYLON_APPROACH_ZONE: ZoneConfig = {
|
export const PYLON_APPROACH_ZONE: ZoneConfig = {
|
||||||
id: "pylon-approach",
|
id: "pylon-approach",
|
||||||
position: [
|
position: [
|
||||||
PYLON_WORLD_POSITION[0],
|
5,
|
||||||
PYLON_WORLD_POSITION[1]- 5,
|
4,
|
||||||
PYLON_WORLD_POSITION[2],
|
-21.5
|
||||||
],
|
],
|
||||||
radius: 5,
|
radius: 10,
|
||||||
height: 18,
|
height: 18,
|
||||||
oneShot: true,
|
oneShot: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Zone qui active la cinématique d'arrivée du pylône
|
||||||
export const PYLON_ARRIVED_ZONE: ZoneConfig = {
|
export const PYLON_ARRIVED_ZONE: ZoneConfig = {
|
||||||
id: "pylon-arrived",
|
id: "pylon-arrived",
|
||||||
position: [
|
position: [
|
||||||
PYLON_WORLD_POSITION[0] + 5,
|
PYLON_WORLD_POSITION[0],
|
||||||
PYLON_WORLD_POSITION[1] - 5,
|
PYLON_WORLD_POSITION[1],
|
||||||
PYLON_WORLD_POSITION[2] + 5,
|
PYLON_WORLD_POSITION[2],
|
||||||
],
|
],
|
||||||
radius: 5,
|
radius: 30,
|
||||||
height: 15,
|
height: 15,
|
||||||
oneShot: true,
|
oneShot: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Ebike } from "@/components/ebike/Ebike";
|
|||||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||||
import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon";
|
import { PylonDownedPylon } from "@/components/gameplay/pylon/PylonDownedPylon";
|
||||||
|
import { PylonLightingEffect } from "@/components/gameplay/pylon/PylonLightingEffect";
|
||||||
import { PylonNarrativeFlow } from "@/components/gameplay/pylon/PylonNarrativeFlow";
|
import { PylonNarrativeFlow } from "@/components/gameplay/pylon/PylonNarrativeFlow";
|
||||||
import { ZoneDebugVisual } from "@/components/zone/ZoneDetection";
|
import { ZoneDebugVisual } from "@/components/zone/ZoneDetection";
|
||||||
import { PYLON_APPROACH_ZONE, PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones";
|
import { PYLON_APPROACH_ZONE, PYLON_ARRIVED_ZONE } from "@/data/gameplay/zones";
|
||||||
@@ -99,6 +100,7 @@ export function GameStageContent(): React.JSX.Element {
|
|||||||
<>
|
<>
|
||||||
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
|
{mainState === "intro" ? <StageAnchor {...INTRO_STAGE_ANCHOR} /> : null}
|
||||||
<Ebike position={EBIKE_WORLD_POSITION} />
|
<Ebike position={EBIKE_WORLD_POSITION} />
|
||||||
|
<PylonLightingEffect />
|
||||||
<PylonDownedPylon />
|
<PylonDownedPylon />
|
||||||
{isDebugEnabled() ? (
|
{isDebugEnabled() ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user