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 {
modelPath: string;
fallbackColor?: string | undefined;
fallbackModelPath?: string | undefined;
fallbackScale?: number | undefined;
scale?: number | undefined;
@@ -27,7 +28,7 @@ interface SkyModelErrorBoundaryState {
const SKY_MODEL_SCALE = 1;
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";
class SkyModelErrorBoundary extends Component<
@@ -53,14 +54,22 @@ class SkyModelErrorBoundary extends Component<
}
export function SkyModel({
fallbackColor,
fallbackModelPath,
fallbackScale = SKY_MODEL_SCALE,
modelPath,
scale = SKY_MODEL_SCALE,
}: SkyModelProps): React.JSX.Element {
const fallback = fallbackModelPath ? (
<SkyModelContent modelPath={fallbackModelPath} scale={fallbackScale} />
const colorFallback = fallbackColor ? (
<color attach="background" args={[fallbackColor]} />
) : null;
const fallback = fallbackModelPath ? (
<SkyModelErrorBoundary key={fallbackModelPath} fallback={colorFallback}>
<SkyModelContent modelPath={fallbackModelPath} scale={fallbackScale} />
</SkyModelErrorBoundary>
) : (
colorFallback
);
return (
<SkyModelErrorBoundary key={modelPath} fallback={fallback}>
+22 -1
View File
@@ -1,10 +1,12 @@
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 type {
RepairRuntime,
SubtitleLanguage,
} from "@/managers/stores/useSettingsStore";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
function formatPercent(value: number): string {
return `${Math.round(value * 100)}%`;
@@ -52,6 +54,7 @@ function VolumeSlider({
}
export function GameSettingsMenu(): React.JSX.Element | null {
const resetGame = useGameStore((state) => state.resetGame);
const {
isSettingsMenuOpen,
musicVolume,
@@ -93,6 +96,13 @@ export function GameSettingsMenu(): React.JSX.Element | null {
window.location.assign("/");
};
const handleRestart = (): void => {
resetGame();
window.location.reload();
};
const showDebugRestart = isDebugEnabled();
return (
<div className="game-settings-menu" role="dialog" aria-modal="true">
<div className="game-settings-menu__panel">
@@ -190,6 +200,17 @@ export function GameSettingsMenu(): React.JSX.Element | null {
</div>
</section>
{showDebugRestart ? (
<button
className="game-settings-menu__restart"
type="button"
onClick={handleRestart}
>
<RotateCcw size={14} aria-hidden="true" />
Recommencer
</button>
) : null}
<button
className="game-settings-menu__quit"
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_SKY_MODEL_SCALE = 100;
export const GAME_SCENE_FALLBACK_SKY_MODEL_SCALE = 1;
export const GAME_SCENE_FALLBACK_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__restart,
.game-settings-menu__quit {
width: 100%;
padding: 11px 12px;
@@ -680,6 +681,16 @@ canvas {
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 {
margin-top: 8px;
border-color: rgba(248, 113, 113, 0.35);
+179 -4
View File
@@ -1,12 +1,19 @@
import { create } from "zustand";
import type { GameStep } from "@/types/game";
import { GAME_STEPS, type GameStep } from "@/types/game";
import {
isRepairMissionId,
isMissionStep,
getNextMissionStep,
getPreviousMissionStep,
type MissionStep,
type RepairMissionId,
} 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 { MissionStep, RepairMissionId };
@@ -30,7 +37,7 @@ interface MissionFlowState {
playerName: string;
}
interface GameState {
export interface GameState {
mainState: MainGameState;
isCinematicPlaying: boolean;
missionFlow: MissionFlowState;
@@ -79,6 +86,34 @@ interface GameActions {
type GameStore = GameState & GameActions;
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 {
return {
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) => ({
...createInitialGameState(),
...createInitialDebugGameState(),
setMainState: (mainState) => set({ mainState }),
setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }),
hideDialog: () =>
@@ -302,9 +468,18 @@ export const useGameStore = create<GameStore>()((set) => ({
return { outro: { ...state.outro, hasStarted: false } };
}),
resetGame: () => set(createInitialGameState()),
resetGame: () => {
set(createInitialGameState());
clearDebugGameStateCookie();
},
showDialog: (dialogMessage) =>
set((state) => ({
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 {
GAME_SCENE_FALLBACK_BACKGROUND_COLOR,
GAME_SCENE_FALLBACK_SKY_MODEL_PATH,
GAME_SCENE_FALLBACK_SKY_MODEL_SCALE,
GAME_SCENE_SKY_MODEL_PATH,
@@ -19,6 +20,7 @@ export function Environment(): React.JSX.Element {
return (
<SkyModel
fallbackColor={GAME_SCENE_FALLBACK_BACKGROUND_COLOR}
fallbackModelPath={GAME_SCENE_FALLBACK_SKY_MODEL_PATH}
fallbackScale={GAME_SCENE_FALLBACK_SKY_MODEL_SCALE}
modelPath={GAME_SCENE_SKY_MODEL_PATH}