From cf20aa8ea4f2470ffe7d34b8ad2246ee46a28e98 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Thu, 30 Apr 2026 14:24:59 +0200 Subject: [PATCH] connect game progression state to world --- src/components/three/AnimatedModel.tsx | 2 +- src/components/ui/GameStateHUD.tsx | 70 ++++++++++ src/index.css | 71 ++++++++++ src/managers/stores/useGameStore.ts | 184 ++++++++++++++++++++++++- src/pages/page.tsx | 2 + src/world/GameStageContent.tsx | 44 ++++++ src/world/World.tsx | 6 +- 7 files changed, 372 insertions(+), 7 deletions(-) create mode 100644 src/components/ui/GameStateHUD.tsx create mode 100644 src/world/GameStageContent.tsx diff --git a/src/components/three/AnimatedModel.tsx b/src/components/three/AnimatedModel.tsx index f9c5991..099f14c 100644 --- a/src/components/three/AnimatedModel.tsx +++ b/src/components/three/AnimatedModel.tsx @@ -151,7 +151,7 @@ export function AnimatedModel({ defaultAction.play(); // eslint-disable-next-line react-hooks/set-state-in-effect setIsReady(true); - // eslint-disable-next-line react-hooks/set-state-in-effect + setCurrentAnim(defaultAction.getClip().name); onLoaded?.(); } else { diff --git a/src/components/ui/GameStateHUD.tsx b/src/components/ui/GameStateHUD.tsx new file mode 100644 index 0000000..eec8a3d --- /dev/null +++ b/src/components/ui/GameStateHUD.tsx @@ -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 ( + + ); +} diff --git a/src/index.css b/src/index.css index a32c152..763ef06 100644 --- a/src/index.css +++ b/src/index.css @@ -391,6 +391,77 @@ canvas { 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-container { position: fixed; diff --git a/src/managers/stores/useGameStore.ts b/src/managers/stores/useGameStore.ts index 6d0e8fd..78e683e 100644 --- a/src/managers/stores/useGameStore.ts +++ b/src/managers/stores/useGameStore.ts @@ -1,7 +1,14 @@ import { create } from "zustand"; 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 { dialogueAudio: string | null; @@ -39,31 +46,54 @@ interface GameActions { setPyloneState: (pylone: Partial) => void; setFermeState: (ferme: Partial) => void; setOutroState: (outro: Partial) => void; + completeIntro: () => void; + completeBike: () => void; + completePylone: () => void; + completeFerme: () => void; + startOutro: () => void; + advanceGameState: () => void; resetGame: () => void; } 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 { return { mainState: "intro", intro: { - dialogueAudio: ""null, + dialogueAudio: null, hasCompleted: false, isBikeUnlocked: false, }, bike: { - currentStep: "locked", + currentStep: "waiting", dialogueAudio: null, isRepaired: false, }, pylone: { - currentStep: "locked", + currentStep: "waiting", dialogueAudio: null, isPowered: false, }, ferme: { - currentStep: "locked", + currentStep: "waiting", dialogueAudio: null, irrigationFixed: false, }, @@ -87,5 +117,149 @@ export const useGameStore = create()((set) => ({ set((state) => ({ ferme: { ...state.ferme, ...ferme } })), setOutroState: (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()), })); diff --git a/src/pages/page.tsx b/src/pages/page.tsx index 2635e63..a601019 100644 --- a/src/pages/page.tsx +++ b/src/pages/page.tsx @@ -1,6 +1,7 @@ import { Suspense } from "react"; import { Canvas } from "@react-three/fiber"; import { Crosshair } from "@/components/ui/Crosshair"; +import { GameStateHUD } from "@/components/ui/GameStateHUD"; import { InteractPrompt } from "@/components/ui/InteractPrompt"; import { DebugPerf } from "@/components/debug/DebugPerf"; import { World } from "@/world/World"; @@ -14,6 +15,7 @@ export function HomePage(): React.JSX.Element { + diff --git a/src/world/GameStageContent.tsx b/src/world/GameStageContent.tsx new file mode 100644 index 0000000..ca16da8 --- /dev/null +++ b/src/world/GameStageContent.tsx @@ -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 ( + + + + + + + ); +} + +export function GameStageContent(): React.JSX.Element { + const mainState = useGameStore((state) => state.mainState); + + switch (mainState) { + case "intro": + return ; + case "bike": + return ; + case "pylone": + return ; + case "ferme": + return ; + case "outro": + return ; + } +} diff --git a/src/world/World.tsx b/src/world/World.tsx index b945c38..fe0344b 100644 --- a/src/world/World.tsx +++ b/src/world/World.tsx @@ -11,6 +11,7 @@ import { DebugHelpers } from "@/components/debug/scene/DebugHelpers"; import { Environment } from "@/world/Environment"; import { Lighting } from "@/world/Lighting"; import { GameMap } from "@/world/GameMap"; +import { GameStageContent } from "@/world/GameStageContent"; import { Player } from "@/world/player/Player"; import { TestScene } from "@/world/debug/TestScene"; @@ -31,7 +32,10 @@ export function World(): React.JSX.Element { {cameraMode === "debug" ? : null} {sceneMode === "game" ? ( - + <> + + + ) : ( )}