feat(debug): persist and reset debug game state
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user