connect game progression state to world

This commit is contained in:
Tom Boullay
2026-04-30 14:24:59 +02:00
parent 2696289483
commit 7c7dbdb588
7 changed files with 372 additions and 7 deletions
+1 -1
View File
@@ -151,7 +151,7 @@ export function AnimatedModel({
defaultAction.play(); defaultAction.play();
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setIsReady(true); setIsReady(true);
// eslint-disable-next-line react-hooks/set-state-in-effect
setCurrentAnim(defaultAction.getClip().name); setCurrentAnim(defaultAction.getClip().name);
onLoaded?.(); onLoaded?.();
} else { } else {
+70
View File
@@ -0,0 +1,70 @@
import { Debug } from "@/utils/debug/Debug";
import {
type MainGameState,
useGameStore,
} from "@/managers/stores/useGameStore";
const MAIN_STATES: MainGameState[] = [
"intro",
"bike",
"pylone",
"ferme",
"outro",
];
export function GameStateHUD(): React.JSX.Element | null {
const debug = Debug.getInstance();
const mainState = useGameStore((state) => state.mainState);
const detail = useGameStore((state) => {
switch (state.mainState) {
case "intro":
return state.intro.hasCompleted ? "completed" : "waiting";
case "bike":
return state.bike.currentStep;
case "pylone":
return state.pylone.currentStep;
case "ferme":
return state.ferme.currentStep;
case "outro":
return state.outro.hasStarted ? "started" : "waiting";
}
});
const setMainState = useGameStore((state) => state.setMainState);
const advanceGameState = useGameStore((state) => state.advanceGameState);
const resetGame = useGameStore((state) => state.resetGame);
if (!debug.active) return null;
return (
<aside className="game-state-hud" aria-label="Game state debug panel">
<div className="game-state-hud__header">
<span>Game State</span>
<strong>{mainState}</strong>
</div>
<p className="game-state-hud__detail">Sub state: {detail}</p>
<div className="game-state-hud__states" aria-label="Main states">
{MAIN_STATES.map((state) => (
<button
key={state}
className={state === mainState ? "is-active" : undefined}
type="button"
onClick={() => setMainState(state)}
>
{state}
</button>
))}
</div>
<div className="game-state-hud__actions">
<button type="button" onClick={advanceGameState}>
Next step
</button>
<button type="button" onClick={resetGame}>
Reset
</button>
</div>
</aside>
);
}
+71
View File
@@ -391,6 +391,77 @@ canvas {
letter-spacing: 0.03em; letter-spacing: 0.03em;
} }
.game-state-hud {
position: fixed;
top: 18px;
right: 18px;
z-index: 20;
display: grid;
gap: 12px;
width: min(320px, calc(100vw - 36px));
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 18px;
background: rgba(4, 7, 13, 0.78);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.35);
color: #f8fafc;
backdrop-filter: blur(16px);
}
.game-state-hud__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.game-state-hud__header span,
.game-state-hud__detail {
color: rgba(248, 250, 252, 0.68);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.game-state-hud__header strong {
font-size: 16px;
letter-spacing: -0.03em;
text-transform: uppercase;
}
.game-state-hud__detail {
margin: 0;
text-transform: none;
}
.game-state-hud__states,
.game-state-hud__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.game-state-hud button {
min-height: 32px;
padding: 0 10px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #f8fafc;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.game-state-hud button:hover,
.game-state-hud button:focus-visible,
.game-state-hud button.is-active {
border-color: rgba(125, 211, 252, 0.75);
background: rgba(125, 211, 252, 0.18);
outline: none;
}
/* Editor page */ /* Editor page */
.editor-container { .editor-container {
position: fixed; position: fixed;
+179 -5
View File
@@ -1,7 +1,14 @@
import { create } from "zustand"; import { create } from "zustand";
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro"; export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
export type MissionStep = "locked" | "inspect" | "repair" | "done"; export type MissionStep =
| "locked"
| "waiting"
| "inspected"
| "fragmented"
| "scanning"
| "repairing"
| "done";
export interface IntroState { export interface IntroState {
dialogueAudio: string | null; dialogueAudio: string | null;
@@ -39,31 +46,54 @@ interface GameActions {
setPyloneState: (pylone: Partial<GameState["pylone"]>) => void; setPyloneState: (pylone: Partial<GameState["pylone"]>) => void;
setFermeState: (ferme: Partial<GameState["ferme"]>) => void; setFermeState: (ferme: Partial<GameState["ferme"]>) => void;
setOutroState: (outro: Partial<GameState["outro"]>) => void; setOutroState: (outro: Partial<GameState["outro"]>) => void;
completeIntro: () => void;
completeBike: () => void;
completePylone: () => void;
completeFerme: () => void;
startOutro: () => void;
advanceGameState: () => void;
resetGame: () => void; resetGame: () => void;
} }
export type GameStore = GameState & GameActions; export type GameStore = GameState & GameActions;
function getNextMissionStep(step: MissionStep): MissionStep {
switch (step) {
case "locked":
case "waiting":
return "inspected";
case "inspected":
return "fragmented";
case "fragmented":
return "scanning";
case "scanning":
return "repairing";
case "repairing":
case "done":
return "done";
}
}
function createInitialGameState(): GameState { function createInitialGameState(): GameState {
return { return {
mainState: "intro", mainState: "intro",
intro: { intro: {
dialogueAudio: ""null, dialogueAudio: null,
hasCompleted: false, hasCompleted: false,
isBikeUnlocked: false, isBikeUnlocked: false,
}, },
bike: { bike: {
currentStep: "locked", currentStep: "waiting",
dialogueAudio: null, dialogueAudio: null,
isRepaired: false, isRepaired: false,
}, },
pylone: { pylone: {
currentStep: "locked", currentStep: "waiting",
dialogueAudio: null, dialogueAudio: null,
isPowered: false, isPowered: false,
}, },
ferme: { ferme: {
currentStep: "locked", currentStep: "waiting",
dialogueAudio: null, dialogueAudio: null,
irrigationFixed: false, irrigationFixed: false,
}, },
@@ -87,5 +117,149 @@ export const useGameStore = create<GameStore>()((set) => ({
set((state) => ({ ferme: { ...state.ferme, ...ferme } })), set((state) => ({ ferme: { ...state.ferme, ...ferme } })),
setOutroState: (outro) => setOutroState: (outro) =>
set((state) => ({ outro: { ...state.outro, ...outro } })), set((state) => ({ outro: { ...state.outro, ...outro } })),
completeIntro: () =>
set((state) => ({
mainState: "bike",
intro: {
...state.intro,
hasCompleted: true,
isBikeUnlocked: true,
},
bike: {
...state.bike,
currentStep: "inspected",
},
})),
completeBike: () =>
set((state) => ({
mainState: "pylone",
bike: {
...state.bike,
currentStep: "done",
isRepaired: true,
},
pylone: {
...state.pylone,
currentStep: "inspected",
},
})),
completePylone: () =>
set((state) => ({
mainState: "ferme",
pylone: {
...state.pylone,
currentStep: "done",
isPowered: true,
},
ferme: {
...state.ferme,
currentStep: "inspected",
},
})),
completeFerme: () =>
set((state) => ({
mainState: "outro",
ferme: {
...state.ferme,
currentStep: "done",
irrigationFixed: true,
},
outro: {
...state.outro,
hasStarted: true,
},
})),
startOutro: () =>
set((state) => ({
mainState: "outro",
outro: {
...state.outro,
hasStarted: true,
},
})),
advanceGameState: () =>
set((state) => {
if (state.mainState === "intro") {
return {
mainState: "bike",
intro: {
...state.intro,
hasCompleted: true,
isBikeUnlocked: true,
},
bike: {
...state.bike,
currentStep: "inspected",
},
};
}
if (state.mainState === "bike") {
const nextStep = getNextMissionStep(state.bike.currentStep);
if (nextStep === "done") {
return {
mainState: "pylone",
bike: {
...state.bike,
currentStep: "done",
isRepaired: true,
},
pylone: {
...state.pylone,
currentStep: "inspected",
},
};
}
return { bike: { ...state.bike, currentStep: nextStep } };
}
if (state.mainState === "pylone") {
const nextStep = getNextMissionStep(state.pylone.currentStep);
if (nextStep === "done") {
return {
mainState: "ferme",
pylone: {
...state.pylone,
currentStep: "done",
isPowered: true,
},
ferme: {
...state.ferme,
currentStep: "inspected",
},
};
}
return { pylone: { ...state.pylone, currentStep: nextStep } };
}
if (state.mainState === "ferme") {
const nextStep = getNextMissionStep(state.ferme.currentStep);
if (nextStep === "done") {
return {
mainState: "outro",
ferme: {
...state.ferme,
currentStep: "done",
irrigationFixed: true,
},
outro: {
...state.outro,
hasStarted: true,
},
};
}
return { ferme: { ...state.ferme, currentStep: nextStep } };
}
return {
outro: {
...state.outro,
hasStarted: true,
},
};
}),
resetGame: () => set(createInitialGameState()), resetGame: () => set(createInitialGameState()),
})); }));
+2
View File
@@ -1,6 +1,7 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { Canvas } from "@react-three/fiber"; import { Canvas } from "@react-three/fiber";
import { Crosshair } from "@/components/ui/Crosshair"; import { Crosshair } from "@/components/ui/Crosshair";
import { GameStateHUD } from "@/components/ui/GameStateHUD";
import { InteractPrompt } from "@/components/ui/InteractPrompt"; import { InteractPrompt } from "@/components/ui/InteractPrompt";
import { DebugPerf } from "@/components/debug/DebugPerf"; import { DebugPerf } from "@/components/debug/DebugPerf";
import { World } from "@/world/World"; import { World } from "@/world/World";
@@ -14,6 +15,7 @@ export function HomePage(): React.JSX.Element {
<DebugPerf /> <DebugPerf />
</Suspense> </Suspense>
</Canvas> </Canvas>
<GameStateHUD />
<Crosshair /> <Crosshair />
<InteractPrompt /> <InteractPrompt />
</> </>
+44
View File
@@ -0,0 +1,44 @@
import { useGameStore } from "@/managers/stores/useGameStore";
import type { Vector3Tuple } from "@/types/three";
interface StageAnchorProps {
color: string;
position: Vector3Tuple;
scale?: number;
}
function StageAnchor({
color,
position,
scale = 1,
}: StageAnchorProps): React.JSX.Element {
return (
<group position={position} scale={scale}>
<mesh>
<octahedronGeometry args={[1.2, 0]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={0.25}
/>
</mesh>
</group>
);
}
export function GameStageContent(): React.JSX.Element {
const mainState = useGameStore((state) => state.mainState);
switch (mainState) {
case "intro":
return <StageAnchor color="#7dd3fc" position={[0, 4, 0]} />;
case "bike":
return <StageAnchor color="#facc15" position={[8, 3, -6]} />;
case "pylone":
return <StageAnchor color="#a78bfa" position={[64, 6, -66]} />;
case "ferme":
return <StageAnchor color="#86efac" position={[-24, 5, 42]} />;
case "outro":
return <StageAnchor color="#fb7185" position={[0, 6, 10]} scale={1.25} />;
}
}
+5 -1
View File
@@ -11,6 +11,7 @@ import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
import { Environment } from "@/world/Environment"; import { Environment } from "@/world/Environment";
import { Lighting } from "@/world/Lighting"; import { Lighting } from "@/world/Lighting";
import { GameMap } from "@/world/GameMap"; import { GameMap } from "@/world/GameMap";
import { GameStageContent } from "@/world/GameStageContent";
import { Player } from "@/world/player/Player"; import { Player } from "@/world/player/Player";
import { TestScene } from "@/world/debug/TestScene"; import { TestScene } from "@/world/debug/TestScene";
@@ -31,7 +32,10 @@ export function World(): React.JSX.Element {
{cameraMode === "debug" ? <DebugCameraControls /> : null} {cameraMode === "debug" ? <DebugCameraControls /> : null}
{sceneMode === "game" ? ( {sceneMode === "game" ? (
<GameMap onOctreeReady={setOctree} /> <>
<GameMap onOctreeReady={setOctree} />
<GameStageContent />
</>
) : ( ) : (
<TestScene onOctreeReady={setOctree} /> <TestScene onOctreeReady={setOctree} />
)} )}