2 Commits

Author SHA1 Message Date
tom-boullay cf08062def refactor(world): restore sky fallback chain
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-05-21 12:10:31 +02:00
tom-boullay 4f25b33d3b feat(debug): persist and reset debug game state 2026-05-21 12:09:12 +02:00
7 changed files with 267 additions and 9 deletions
+12 -3
View File
@@ -6,6 +6,7 @@ import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
interface SkyModelProps { interface SkyModelProps {
modelPath: string; modelPath: string;
fallbackColor?: string | undefined;
fallbackModelPath?: string | undefined; fallbackModelPath?: string | undefined;
fallbackScale?: number | undefined; fallbackScale?: number | undefined;
scale?: number | undefined; scale?: number | undefined;
@@ -27,7 +28,7 @@ interface SkyModelErrorBoundaryState {
const SKY_MODEL_SCALE = 1; const SKY_MODEL_SCALE = 1;
const SKY_MODEL_RENDER_ORDER = -1000; const SKY_MODEL_RENDER_ORDER = -1000;
const SKYBOX_MODEL_PATH = "/models/skybox/skybox.glb"; const SKYBOX_MODEL_PATH = "/models/skybox/model.gltf";
const LEGACY_SKY_MODEL_PATH = "/models/sky/model.glb"; const LEGACY_SKY_MODEL_PATH = "/models/sky/model.glb";
class SkyModelErrorBoundary extends Component< class SkyModelErrorBoundary extends Component<
@@ -53,14 +54,22 @@ class SkyModelErrorBoundary extends Component<
} }
export function SkyModel({ export function SkyModel({
fallbackColor,
fallbackModelPath, fallbackModelPath,
fallbackScale = SKY_MODEL_SCALE, fallbackScale = SKY_MODEL_SCALE,
modelPath, modelPath,
scale = SKY_MODEL_SCALE, scale = SKY_MODEL_SCALE,
}: SkyModelProps): React.JSX.Element { }: SkyModelProps): React.JSX.Element {
const fallback = fallbackModelPath ? ( const colorFallback = fallbackColor ? (
<SkyModelContent modelPath={fallbackModelPath} scale={fallbackScale} /> <color attach="background" args={[fallbackColor]} />
) : null; ) : null;
const fallback = fallbackModelPath ? (
<SkyModelErrorBoundary key={fallbackModelPath} fallback={colorFallback}>
<SkyModelContent modelPath={fallbackModelPath} scale={fallbackScale} />
</SkyModelErrorBoundary>
) : (
colorFallback
);
return ( return (
<SkyModelErrorBoundary key={modelPath} fallback={fallback}> <SkyModelErrorBoundary key={modelPath} fallback={fallback}>
+22 -1
View File
@@ -1,10 +1,12 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { X } from "lucide-react"; import { RotateCcw, X } from "lucide-react";
import { useGameStore } from "@/managers/stores/useGameStore";
import { useSettingsStore } from "@/managers/stores/useSettingsStore"; import { useSettingsStore } from "@/managers/stores/useSettingsStore";
import type { import type {
RepairRuntime, RepairRuntime,
SubtitleLanguage, SubtitleLanguage,
} from "@/managers/stores/useSettingsStore"; } from "@/managers/stores/useSettingsStore";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
function formatPercent(value: number): string { function formatPercent(value: number): string {
return `${Math.round(value * 100)}%`; return `${Math.round(value * 100)}%`;
@@ -52,6 +54,7 @@ function VolumeSlider({
} }
export function GameSettingsMenu(): React.JSX.Element | null { export function GameSettingsMenu(): React.JSX.Element | null {
const resetGame = useGameStore((state) => state.resetGame);
const { const {
isSettingsMenuOpen, isSettingsMenuOpen,
musicVolume, musicVolume,
@@ -93,6 +96,13 @@ export function GameSettingsMenu(): React.JSX.Element | null {
window.location.assign("/"); window.location.assign("/");
}; };
const handleRestart = (): void => {
resetGame();
window.location.reload();
};
const showDebugRestart = isDebugEnabled();
return ( return (
<div className="game-settings-menu" role="dialog" aria-modal="true"> <div className="game-settings-menu" role="dialog" aria-modal="true">
<div className="game-settings-menu__panel"> <div className="game-settings-menu__panel">
@@ -190,6 +200,17 @@ export function GameSettingsMenu(): React.JSX.Element | null {
</div> </div>
</section> </section>
{showDebugRestart ? (
<button
className="game-settings-menu__restart"
type="button"
onClick={handleRestart}
>
<RotateCcw size={14} aria-hidden="true" />
Recommencer
</button>
) : null}
<button <button
className="game-settings-menu__quit" className="game-settings-menu__quit"
type="button" type="button"
+2 -1
View File
@@ -1,5 +1,6 @@
export const GAME_SCENE_SKY_MODEL_PATH = "/models/skybox/skybox.glb"; export const GAME_SCENE_SKY_MODEL_PATH = "/models/skybox/model.gltf";
export const GAME_SCENE_FALLBACK_SKY_MODEL_PATH = "/models/sky/model.glb"; export const GAME_SCENE_FALLBACK_SKY_MODEL_PATH = "/models/sky/model.glb";
export const GAME_SCENE_SKY_MODEL_SCALE = 100; export const GAME_SCENE_SKY_MODEL_SCALE = 100;
export const GAME_SCENE_FALLBACK_SKY_MODEL_SCALE = 1; export const GAME_SCENE_FALLBACK_SKY_MODEL_SCALE = 1;
export const GAME_SCENE_FALLBACK_BACKGROUND_COLOR = "#0b1018";
export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018"; export const PHYSICS_SCENE_BACKGROUND_COLOR = "#0b1018";
+11
View File
@@ -662,6 +662,7 @@ canvas {
} }
.game-settings-menu__choice-group button, .game-settings-menu__choice-group button,
.game-settings-menu__restart,
.game-settings-menu__quit { .game-settings-menu__quit {
width: 100%; width: 100%;
padding: 11px 12px; padding: 11px 12px;
@@ -680,6 +681,16 @@ canvas {
color: #050505; color: #050505;
} }
.game-settings-menu__restart {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 8px;
border-color: rgba(96, 165, 250, 0.35);
color: #bfdbfe;
}
.game-settings-menu__quit { .game-settings-menu__quit {
margin-top: 8px; margin-top: 8px;
border-color: rgba(248, 113, 113, 0.35); border-color: rgba(248, 113, 113, 0.35);
+179 -4
View File
@@ -1,12 +1,19 @@
import { create } from "zustand"; import { create } from "zustand";
import type { GameStep } from "@/types/game"; import { GAME_STEPS, type GameStep } from "@/types/game";
import { import {
isRepairMissionId, isRepairMissionId,
isMissionStep,
getNextMissionStep, getNextMissionStep,
getPreviousMissionStep, getPreviousMissionStep,
type MissionStep, type MissionStep,
type RepairMissionId, type RepairMissionId,
} from "@/types/gameplay/repairMission"; } from "@/types/gameplay/repairMission";
import {
clearDebugGameStateCookie,
readDebugGameStateCookie,
writeDebugGameStateCookie,
} from "@/utils/debug/debugGameStateCookie";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro"; export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro";
export type { MissionStep, RepairMissionId }; export type { MissionStep, RepairMissionId };
@@ -30,7 +37,7 @@ interface MissionFlowState {
playerName: string; playerName: string;
} }
interface GameState { export interface GameState {
mainState: MainGameState; mainState: MainGameState;
isCinematicPlaying: boolean; isCinematicPlaying: boolean;
missionFlow: MissionFlowState; missionFlow: MissionFlowState;
@@ -79,6 +86,34 @@ interface GameActions {
type GameStore = GameState & GameActions; type GameStore = GameState & GameActions;
type GameStateUpdate = Partial<GameState>; type GameStateUpdate = Partial<GameState>;
const MAIN_GAME_STATES: readonly MainGameState[] = [
"intro",
"bike",
"pylone",
"ferme",
"outro",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isStringOrNull(value: unknown): value is string | null {
return typeof value === "string" || value === null;
}
function isBoolean(value: unknown): value is boolean {
return typeof value === "boolean";
}
function isMainGameState(value: unknown): value is MainGameState {
return MAIN_GAME_STATES.includes(value as MainGameState);
}
function isGameStep(value: unknown): value is GameStep {
return GAME_STEPS.includes(value as GameStep);
}
function completeIntroState(state: GameState): GameStateUpdate { function completeIntroState(state: GameState): GameStateUpdate {
return { return {
mainState: "bike", mainState: "bike",
@@ -237,8 +272,139 @@ function createInitialGameState(): GameState {
}; };
} }
function hydrateIntroState(initial: IntroState, value: unknown): IntroState {
if (!isRecord(value)) return initial;
return {
currentStep: isGameStep(value.currentStep)
? value.currentStep
: initial.currentStep,
dialogueAudio: isStringOrNull(value.dialogueAudio)
? value.dialogueAudio
: initial.dialogueAudio,
hasCompleted: isBoolean(value.hasCompleted)
? value.hasCompleted
: initial.hasCompleted,
isBikeUnlocked: isBoolean(value.isBikeUnlocked)
? value.isBikeUnlocked
: initial.isBikeUnlocked,
};
}
function hydrateMissionState(
initial: MissionState,
value: unknown,
): MissionState {
if (!isRecord(value)) return initial;
return {
currentStep:
typeof value.currentStep === "string" && isMissionStep(value.currentStep)
? value.currentStep
: initial.currentStep,
dialogueAudio: isStringOrNull(value.dialogueAudio)
? value.dialogueAudio
: initial.dialogueAudio,
};
}
function hydrateMissionFlowState(
initial: MissionFlowState,
value: unknown,
): MissionFlowState {
if (!isRecord(value)) return initial;
return {
activityCity: isBoolean(value.activityCity)
? value.activityCity
: initial.activityCity,
canMove: isBoolean(value.canMove) ? value.canMove : initial.canMove,
dialogMessage: isStringOrNull(value.dialogMessage)
? value.dialogMessage
: initial.dialogMessage,
playerName:
typeof value.playerName === "string"
? value.playerName
: initial.playerName,
};
}
function hydrateDebugGameState(initial: GameState, value: unknown): GameState {
if (!isRecord(value)) return initial;
const bike = hydrateMissionState(initial.bike, value.bike);
const pylone = hydrateMissionState(initial.pylone, value.pylone);
const ferme = hydrateMissionState(initial.ferme, value.ferme);
const outro = isRecord(value.outro) ? value.outro : null;
return {
mainState: isMainGameState(value.mainState)
? value.mainState
: initial.mainState,
isCinematicPlaying: isBoolean(value.isCinematicPlaying)
? value.isCinematicPlaying
: initial.isCinematicPlaying,
missionFlow: hydrateMissionFlowState(
initial.missionFlow,
value.missionFlow,
),
intro: hydrateIntroState(initial.intro, value.intro),
bike: {
...bike,
isRepaired:
isRecord(value.bike) && isBoolean(value.bike.isRepaired)
? value.bike.isRepaired
: initial.bike.isRepaired,
},
pylone: {
...pylone,
isPowered:
isRecord(value.pylone) && isBoolean(value.pylone.isPowered)
? value.pylone.isPowered
: initial.pylone.isPowered,
},
ferme: {
...ferme,
irrigationFixed:
isRecord(value.ferme) && isBoolean(value.ferme.irrigationFixed)
? value.ferme.irrigationFixed
: initial.ferme.irrigationFixed,
},
outro: {
dialogueAudio:
outro && isStringOrNull(outro.dialogueAudio)
? outro.dialogueAudio
: initial.outro.dialogueAudio,
hasStarted:
outro && isBoolean(outro.hasStarted)
? outro.hasStarted
: initial.outro.hasStarted,
},
};
}
function createInitialDebugGameState(): GameState {
const initialState = createInitialGameState();
if (!isDebugEnabled()) return initialState;
return hydrateDebugGameState(initialState, readDebugGameStateCookie());
}
function pickGameState(state: GameStore): GameState {
return {
mainState: state.mainState,
isCinematicPlaying: state.isCinematicPlaying,
missionFlow: state.missionFlow,
intro: state.intro,
bike: state.bike,
pylone: state.pylone,
ferme: state.ferme,
outro: state.outro,
};
}
export const useGameStore = create<GameStore>()((set) => ({ export const useGameStore = create<GameStore>()((set) => ({
...createInitialGameState(), ...createInitialDebugGameState(),
setMainState: (mainState) => set({ mainState }), setMainState: (mainState) => set({ mainState }),
setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }), setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }),
hideDialog: () => hideDialog: () =>
@@ -302,9 +468,18 @@ export const useGameStore = create<GameStore>()((set) => ({
return { outro: { ...state.outro, hasStarted: false } }; return { outro: { ...state.outro, hasStarted: false } };
}), }),
resetGame: () => set(createInitialGameState()), resetGame: () => {
set(createInitialGameState());
clearDebugGameStateCookie();
},
showDialog: (dialogMessage) => showDialog: (dialogMessage) =>
set((state) => ({ set((state) => ({
missionFlow: { ...state.missionFlow, dialogMessage }, missionFlow: { ...state.missionFlow, dialogMessage },
})), })),
})); }));
if (isDebugEnabled()) {
useGameStore.subscribe((state) => {
writeDebugGameStateCookie(pickGameState(state));
});
}
+39
View File
@@ -0,0 +1,39 @@
import type { GameState } from "@/managers/stores/useGameStore";
const DEBUG_GAME_STATE_COOKIE_NAME = "la-fabrik-debug-game-state";
const DEBUG_GAME_STATE_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
function getCookieValue(name: string): string | null {
if (typeof document === "undefined") return null;
const cookie = document.cookie
.split(";")
.map((item) => item.trim())
.find((item) => item.startsWith(`${name}=`));
return cookie ? cookie.slice(name.length + 1) : null;
}
export function readDebugGameStateCookie(): unknown {
const value = getCookieValue(DEBUG_GAME_STATE_COOKIE_NAME);
if (!value) return null;
try {
return JSON.parse(decodeURIComponent(value));
} catch {
return null;
}
}
export function writeDebugGameStateCookie(state: GameState): void {
if (typeof document === "undefined") return;
const value = encodeURIComponent(JSON.stringify(state));
document.cookie = `${DEBUG_GAME_STATE_COOKIE_NAME}=${value}; max-age=${DEBUG_GAME_STATE_COOKIE_MAX_AGE}; path=/; SameSite=Lax`;
}
export function clearDebugGameStateCookie(): void {
if (typeof document === "undefined") return;
document.cookie = `${DEBUG_GAME_STATE_COOKIE_NAME}=; max-age=0; path=/; SameSite=Lax`;
}
+2
View File
@@ -1,4 +1,5 @@
import { import {
GAME_SCENE_FALLBACK_BACKGROUND_COLOR,
GAME_SCENE_FALLBACK_SKY_MODEL_PATH, GAME_SCENE_FALLBACK_SKY_MODEL_PATH,
GAME_SCENE_FALLBACK_SKY_MODEL_SCALE, GAME_SCENE_FALLBACK_SKY_MODEL_SCALE,
GAME_SCENE_SKY_MODEL_PATH, GAME_SCENE_SKY_MODEL_PATH,
@@ -19,6 +20,7 @@ export function Environment(): React.JSX.Element {
return ( return (
<SkyModel <SkyModel
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH} fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE} fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE}
modelPath={GAME_SCENE_SKY_MODEL_PATH} modelPath={GAME_SCENE_SKY_MODEL_PATH}