feat(debug): persist and reset debug game state
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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