feat: add shared game state manager with EventEmitter

This commit is contained in:
2026-04-07 21:01:22 +07:00
parent eba56d1ecf
commit b557942124
2 changed files with 230 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
import { Difficulty, GameStatus } from "../types";
import { DIFFICULTY_CONFIGS } from "./constants";
type Listener = () => void;
interface GameState {
status: GameStatus;
difficulty: Difficulty | null;
score: number;
timerSeconds: number;
hintsRemaining: number;
shufflesRemaining: number;
combo: number;
lastMatchTime: number;
}
export class GameStateManager {
private state: GameState;
private listeners: Map<string, Listener[]> = new Map();
constructor() {
this.state = {
status: "menu",
difficulty: null,
score: 0,
timerSeconds: 0,
hintsRemaining: 0,
shufflesRemaining: 0,
combo: 1,
lastMatchTime: 0,
};
}
getState(): Readonly<GameState> {
return { ...this.state };
}
on(event: string, listener: Listener): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event)!.push(listener);
}
off(event: string, listener: Listener): void {
const listeners = this.listeners.get(event);
if (listeners) {
const idx = listeners.indexOf(listener);
if (idx !== -1) listeners.splice(idx, 1);
}
}
emit(event: string): void {
const listeners = this.listeners.get(event);
if (listeners) {
for (const l of listeners) l();
}
}
startGame(difficulty: Difficulty): void {
const config = DIFFICULTY_CONFIGS[difficulty];
this.state = {
status: "playing",
difficulty,
score: 0,
timerSeconds: config.timerSeconds,
hintsRemaining: config.hints,
shufflesRemaining: config.shuffles,
combo: 1,
lastMatchTime: Date.now(),
};
this.emit("stateChange");
}
addScore(points: number): void {
this.state.score += points;
this.emit("stateChange");
}
useHint(): boolean {
if (this.state.hintsRemaining <= 0) return false;
this.state.hintsRemaining--;
this.emit("stateChange");
return true;
}
useShuffle(): boolean {
if (this.state.shufflesRemaining <= 0) return false;
this.state.shufflesRemaining--;
this.emit("stateChange");
return true;
}
tick(): void {
if (this.state.status !== "playing") return;
this.state.timerSeconds--;
if (this.state.timerSeconds <= 0) {
this.state.timerSeconds = 0;
this.state.status = "lost";
}
this.emit("stateChange");
}
incrementCombo(): void {
this.state.combo++;
this.emit("stateChange");
}
resetCombo(): void {
this.state.combo = 1;
this.emit("stateChange");
}
setStatus(status: GameStatus): void {
this.state.status = status;
this.emit("stateChange");
}
setLastMatchTime(time: number): void {
this.state.lastMatchTime = time;
}
}
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GameStateManager } from "../../src/game/state";
describe("GameStateManager", () => {
let state: GameStateManager;
beforeEach(() => {
state = new GameStateManager();
});
it("initializes with menu status", () => {
expect(state.getState().status).toBe("menu");
});
it("startGame sets state for given difficulty", () => {
state.startGame("easy");
const s = state.getState();
expect(s.status).toBe("playing");
expect(s.score).toBe(0);
expect(s.hintsRemaining).toBe(5);
expect(s.shufflesRemaining).toBe(3);
expect(s.timerSeconds).toBe(300);
expect(s.difficulty).toBe("easy");
expect(s.combo).toBe(1);
});
it("addScore increases score", () => {
state.startGame("easy");
state.addScore(150);
expect(state.getState().score).toBe(150);
state.addScore(100);
expect(state.getState().score).toBe(250);
});
it("useHint decrements hints", () => {
state.startGame("easy");
expect(state.useHint()).toBe(true);
expect(state.getState().hintsRemaining).toBe(4);
});
it("useHint returns false when no hints left", () => {
state.startGame("hard");
expect(state.useHint()).toBe(true);
expect(state.useHint()).toBe(false);
expect(state.getState().hintsRemaining).toBe(0);
});
it("useShuffle decrements shuffles", () => {
state.startGame("easy");
expect(state.useShuffle()).toBe(true);
expect(state.getState().shufflesRemaining).toBe(2);
});
it("useShuffle returns false when no shuffles left", () => {
state.startGame("hard");
expect(state.useShuffle()).toBe(true);
expect(state.useShuffle()).toBe(false);
});
it("tick decrements timer", () => {
state.startGame("easy");
state.tick();
expect(state.getState().timerSeconds).toBe(299);
});
it("tick sets status to lost when timer reaches 0", () => {
state.startGame("easy");
for (let i = 0; i < 300; i++) {
state.tick();
}
expect(state.getState().status).toBe("lost");
expect(state.getState().timerSeconds).toBe(0);
});
it("emits events on state change", () => {
const listener = vi.fn();
state.on("stateChange", listener);
state.startGame("easy");
expect(listener).toHaveBeenCalled();
});
it("incrementCombo and resetCombo work", () => {
state.startGame("easy");
state.incrementCombo();
expect(state.getState().combo).toBe(2);
state.incrementCombo();
expect(state.getState().combo).toBe(3);
state.resetCombo();
expect(state.getState().combo).toBe(1);
});
it("setStatus changes status and emits", () => {
state.startGame("easy");
const listener = vi.fn();
state.on("stateChange", listener);
state.setStatus("won");
expect(state.getState().status).toBe("won");
expect(listener).toHaveBeenCalled();
});
it("pause and resume toggle status", () => {
state.startGame("easy");
state.setStatus("paused");
expect(state.getState().status).toBe("paused");
state.setStatus("playing");
expect(state.getState().status).toBe("playing");
});
});