diff --git a/CLAUDE.md b/CLAUDE.md index 1574e052..924d659b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,17 +27,21 @@ React + TypeScript PacMan game using Create React App. ### State Management -- **MobX** for reactive state -- `Store` (src/model/Store.ts) - root store containing `Game` and `DebugState` -- `Game` (src/model/Game.ts) - game state: PacMan, Ghosts, Maze, score, timers -- React context provides store access via `useStore()` +- **Zustand** for reactive state with Immer middleware for immutable updates +- `src/model/store/` - Zustand store directory: + - `gameStore.ts` - main store with state and actions + - `types.ts` - TypeScript type definitions for state + - `initialState.ts` - factory functions for initial state + - `ghostHelpers.ts` - helper functions for ghost computed values + - `constants.ts` - game constants (timers, speeds) +- `useGameStore` hook provides state access with selectors ### State Machines -- **XState** for PacMan and Ghost behavior -- `PacManStateChart` - states: eating, chasing, dead -- `GhostStateChart` - states: chase, scatter, frightened, dead -- Events trigger transitions (ENERGIZER_EATEN, COLLISION_WITH_GHOST, etc.) +State machine logic is implemented directly in the Zustand store: +- `sendPacManEvent` - PacMan state transitions: eating → chasing → dead +- `sendGhostEvent` - Ghost state transitions: scatter ↔ chase ↔ frightened → dead +- Events: ENERGIZER_EATEN, COLLISION_WITH_GHOST, PHASE_END, REVIVED, etc. ### Game Loop @@ -54,13 +58,18 @@ React + TypeScript PacMan game using Create React App. ### Key Directories - `src/model/` - game logic, state machines, movement, collision +- `src/model/store/` - Zustand store and state management - `src/pages/GamePage/` - main game UI components - `src/components/` - shared components (Board, Sprite, Grid) - `src/mapData/` - maze tile data ### Tech Stack -- React 18, TypeScript, MobX 5, XState, styled-components, Ant Design, react-router-dom +- React 18, TypeScript, Zustand, Immer, styled-components, Ant Design, react-router-dom + +### Legacy Code + +Some test files still import from the old MobX-based classes (Store.ts, Game.ts, PacMan.ts, Ghost.ts) for isolated unit testing. The main application uses Zustand exclusively. ## Deployment diff --git a/package.json b/package.json index 59500ebd..53bbb4a3 100644 --- a/package.json +++ b/package.json @@ -15,16 +15,15 @@ "@types/react-router-dom": "^5.1.7", "antd": "^4.24.0", "classnames": "^2.2.6", + "immer": "^11.1.3", "lodash": "^4.17.15", - "mobx": "^6.12.0", - "mobx-react-lite": "^4.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^5.2.0", "react-scripts": "5.0.1", "styled-components": "^5.2.1", "typescript": "^4.9.5", - "xstate": "^4.7.5" + "zustand": "^5.0.10" }, "scripts": { "compile": "tsc --noEmit", diff --git a/src/App.tsx b/src/App.tsx index b60eb6f6..4832e954 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,22 +5,17 @@ import './GlobalStyles.css'; import { BrowserRouter } from 'react-router-dom'; import { Routes } from './Routes'; import { AppMenu } from './components/AppMenu'; -import { Store } from './model/Store'; -import { StoreProvider } from './components/StoreContext'; -const App: FC<{ store?: Store; Router?: ComponentType }> = ({ - store = new Store(), +const App: FC<{ Router?: ComponentType }> = ({ Router = BrowserRouter, }) => { return ( - - -
- - -
-
-
+ +
+ + +
+
); }; diff --git a/src/components/StoreContext.ts b/src/components/StoreContext.ts index 9d972cd9..2e9a138e 100644 --- a/src/components/StoreContext.ts +++ b/src/components/StoreContext.ts @@ -1,19 +1,17 @@ -import { createContext, useContext } from 'react'; -import { Game } from '../model/Game'; -import { assert } from '../util/assert'; -import { Store } from '../model/Store'; +import { useGameStore, Store, GameState, DebugState } from '../model/store'; -export const StoreContext = createContext(null); +// Re-export the Zustand store hook as the main store access +export { useGameStore }; -export const StoreProvider = StoreContext.Provider; +// Compatibility hooks that match the old API +export const useStore = (): Store => useGameStore(); +export const useGame = (): GameState => useGameStore((state) => state.game); +export const useDebugState = (): DebugState => useGameStore((state) => state.debugState); -export const useStore = (): Store => { - const store = useContext(StoreContext); - assert(store, 'Store not provided - use '); - return store; +// For components that need to select specific state +export const useGameState = (selector: (state: Store) => T): T => { + return useGameStore(selector); }; -export const useGame = (): Game => { - const store = useStore(); - return store.game; -}; +// Re-export Store type for backwards compatibility +export type { Store, GameState, DebugState }; diff --git a/src/model/DebugState.ts b/src/model/DebugState.ts deleted file mode 100644 index 3d93b056..00000000 --- a/src/model/DebugState.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { observable, makeObservable } from 'mobx'; -import { Store } from './Store'; -import { GhostViewOptions } from './GhostViewOptions'; -import { PacManViewOptions } from '../pages/GamePage/components/PacManViewOptions'; -import { GameViewOptions } from './GameViewOptions'; - -export class DebugState { - constructor(store: Store) { - makeObservable(this); - this.store = store; - } - - store: Store; - - @observable - gameViewOptions: GameViewOptions = { - hitBox: false, - }; - - @observable - ghostViewOptions: GhostViewOptions = { - target: false, - wayPoints: false, - }; - - @observable - pacManViewOptions: PacManViewOptions = { - somePlaceholder: false, - }; -} diff --git a/src/model/Game.test.ts b/src/model/Game.test.ts deleted file mode 100644 index a7e30ce6..00000000 --- a/src/model/Game.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { SCREEN_TILE_SIZE } from './Coordinates'; -import { DEFAULT_SPEED } from './Game'; - -describe('Game', () => { - describe('DEFAULT_SPEED', () => { - it('DEFAULT_SPEED must be a divisor of SCREEN_TILE_SIZE. Otherwise our logic breaks.', () => { - expect(SCREEN_TILE_SIZE % DEFAULT_SPEED).toBe(0); - }); - }); -}); diff --git a/src/model/Game.ts b/src/model/Game.ts deleted file mode 100644 index f099c804..00000000 --- a/src/model/Game.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { action, computed, observable, makeObservable } from 'mobx'; -import { Ghost } from './Ghost'; -import { makeGhosts, resetGhosts } from './makeGhosts'; -import { Maze } from './Maze'; -import { PacMan, resetPacMan } from './PacMan'; -import { MilliSeconds, PixelsPerFrame } from './Types'; -import { Store } from './Store'; -import { TimeoutTimer } from './TimeoutTimer'; - -export const DEFAULT_SPEED = 2; - -const ENERGIZER_DURATION: MilliSeconds = 5000; - -export class Game { - constructor(store: Store) { - makeObservable(this); - this.store = store; - this.pacMan = new PacMan(this); - this.ghosts = makeGhosts(this); - } - - store: Store; - - //** The timestamp we got from requestAnimationFrame(). - @observable - externalTimeStamp: MilliSeconds | null = null; - - @observable - timestamp: MilliSeconds = 0; - - @observable - lastFrameLength: MilliSeconds = 17; - - @observable - frameCount = 0; - - @observable - gamePaused = false; - - speed: PixelsPerFrame = DEFAULT_SPEED; - - ghosts: Ghost[]; - - pacMan: PacMan; - - @observable - score = 0; - - @observable - killedGhosts = 0; - - maze = new Maze(); - - @action.bound - revivePacMan() { - this.pacMan.send('REVIVED'); - this.timestamp = 0; - resetPacMan(this.pacMan); - resetGhosts(this.ghosts); - } - - @computed - get gameOver(): boolean { - const pacMan = this.pacMan; - return pacMan.dead && pacMan.extraLivesLeft === 0; - } - - energizerTimer = new TimeoutTimer(ENERGIZER_DURATION, () => { - this.handleEnergizerTimedOut(); - }); - - @action - handleEnergizerTimedOut() { - this.pacMan.send('ENERGIZER_TIMED_OUT'); - for (const ghost of this.ghosts) { - ghost.send('ENERGIZER_TIMED_OUT'); - } - } - - readyGameForPlay() { - resetPacMan(this.pacMan); - } -} diff --git a/src/model/Ghost.test.ts b/src/model/Ghost.test.ts deleted file mode 100644 index 7ddb938a..00000000 --- a/src/model/Ghost.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Game } from './Game'; -import { Ghost } from './Ghost'; -import { Store } from './Store'; - -describe('Ghost', () => { - describe('when killing pac man', () => { - it('pauses the ghost', () => { - const store = new Store(); - const game = new Game(store); - const ghost = game.ghosts[0]; - ghost.ghostPaused = false; - expect(ghost.state).toBe('scatter'); - expect(ghost.ghostPaused).toBeFalsy(); - game.pacMan.send('COLLISION_WITH_GHOST'); - expect(ghost.ghostPaused).toBeFalsy(); - }); - }); - - describe('when dying', () => { - let game: Game; - - let ghost: Ghost; - - beforeEach(() => { - const store = new Store(); - game = new Game(store); - ghost = game.ghosts[0]; - ghost.ghostPaused = false; - ghost.send('ENERGIZER_EATEN'); - expect(ghost.state).toBe('frightened'); - expect(ghost.ghostPaused).toBeFalsy(); - ghost.send('ENERGIZER_EATEN'); - ghost.send('COLLISION_WITH_PAC_MAN'); - }); - - it('is dead', () => { - expect(ghost.state).toBe('dead'); - }); - - it('increments killedGhosts', () => { - expect(game.killedGhosts).toBe(1); - }); - - it('score increases 100, 200, 400, 800', () => { - expect(game.score).toBe(100); - }); - }); -}); diff --git a/src/model/Ghost.ts b/src/model/Ghost.ts deleted file mode 100644 index 94a9585e..00000000 --- a/src/model/Ghost.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { action, computed, observable, makeObservable } from 'mobx'; -import { changeDirectionToOpposite } from './changeDirectionToOpposite'; -import { - ScreenCoordinates, - screenFromTile, - TileCoordinates, - tileFromScreen, -} from './Coordinates'; -import { findWayPoints } from './findWayPoints'; -import { Game } from './Game'; -import { - GhostEventType, - makeGhostStateChart, - GhostState, -} from './GhostStateChart'; -import { Direction, MilliSeconds } from './Types'; -import { - isTileInBox as isTileInBoxWalls, - isTileCenter, - isTileInBoxSpace, -} from './Ways'; -import { StateValue } from 'xstate'; -import { TimeoutTimer } from './TimeoutTimer'; -import { getStatePhaseLength } from './updateGhostStatePhase'; - -export type GhostNumber = 0 | 1 | 2 | 3; -export const GhostNumbers: GhostNumber[] = [0, 1, 2, 3]; -export type GhostAnimationPhase = 0 | 1; -export const GhostAnimationPhases: GhostAnimationPhase[] = [0, 1]; -export type FrightenedGhostTime = 0 | 1; -export const FrightenedGhostTimes: FrightenedGhostTime[] = [0, 1]; - -const FRIGHTENED_ABOUT_TO_END_DURATION: MilliSeconds = 3000; -const DEAD_WAITING_IN_BOX_DURATION: MilliSeconds = 3000; - -export const KILL_GHOST_SCORE = [0, 100, 200, 400, 800, 1600, 3200]; - -export class Ghost { - constructor(game: Game) { - this.game = game; - this.stateChart = makeGhostStateChart({ - onScatterToChase: this.onScatterToChase, - onChaseToScatter: this.onChaseToScatter, - onDead: this.onDead, - }); - this.stateChart.start(); - this.stateChartState = this.stateChart.state as unknown as GhostState; - makeObservable(this); - - this.stateChart.onTransition(this.handleStateTransition as unknown as Parameters[0]); - } - - @action.bound - handleStateTransition(state: GhostState) { - if (!state.changed) { - return; - } - this.stateChartState = state; - this.stateChanges++; - } - - stateChart: ReturnType; - - onDead = () => { - this.game.killedGhosts++; - this.game.score += KILL_GHOST_SCORE[this.game.killedGhosts]; - this.deadWaitingTimeInBoxLeft = DEAD_WAITING_IN_BOX_DURATION; - }; - - onScatterToChase = () => { - changeDirectionToOpposite(this); - }; - - onChaseToScatter = () => { - changeDirectionToOpposite(this); - }; - - @observable.ref - stateChartState!: GhostState; - - @computed - get state(): StateValue { - return this.stateChartState.value; - } - - @observable - stateChanges = 0; - - @computed - get dead() { - return this.stateChartState.matches('dead'); - } - - @computed - get alive() { - return !this.dead; - } - - @computed get frightened(): boolean { - return this.stateChartState.matches('frightened'); - } - - name = 'ghost name'; - - send(event: GhostEventType) { - this.stateChart.send(event); - } - - @observable - ghostPaused = true; - - game: Game; - - @observable - ghostNumber: GhostNumber = 0; - - color = 'ghost color'; - colorCode = '#00ffff'; - - @observable - screenCoordinates: ScreenCoordinates = { - x: 16, - y: 16, - }; - - @computed - get atTileCenter(): boolean { - return isTileCenter(this.screenCoordinates); - } - - @observable - speedFactor = 1; - - @action - setTileCoordinates(tile: TileCoordinates) { - this.screenCoordinates = screenFromTile(tile); - } - - @computed - get tileCoordinates(): TileCoordinates { - return tileFromScreen(this.screenCoordinates); - } - - @computed - get animationPhase(): GhostAnimationPhase { - return Math.round((this.game.timestamp + this.ghostNumber * 100) / 300) % - 2 === - 0 - ? 0 - : 1; - } - - @computed - get frightenedAboutToEnd(): boolean { - return this.game.energizerTimer.timeLeft < FRIGHTENED_ABOUT_TO_END_DURATION; - } - - @observable - deadWaitingTimeInBoxLeft: MilliSeconds = 0; - - @computed - get frightenedGhostTime(): FrightenedGhostTime { - if (!this.frightenedAboutToEnd) { - return 0; - } - // Blink every 0.5 seconds - return this.game.timestamp % 1000 < 500 ? 0 : 1; - } - - @observable - direction: Direction = 'LEFT'; - - @observable - targetTile: TileCoordinates = { x: 1, y: 1 }; - - @computed - get wayPoints(): TileCoordinates[] | null { - return findWayPoints( - this.tileCoordinates, - this.targetTile, - this.direction, - this.canPassThroughBoxDoor - ); - } - - statePhaseTimer = new TimeoutTimer(3000); - - @computed - get isInsideBoxWalls(): boolean { - return isTileInBoxWalls(this.tileCoordinates); - } - - @computed - get isOutsideBoxSpace() { - return !isTileInBoxSpace(this.tileCoordinates); - } - - @computed - get canPassThroughBoxDoor(): boolean { - if (this.alive) { - if (this.isInsideBoxWalls) { - if (this.game.timestamp > this.initialWaitingTimeInBox) { - return true; - } - } - } - - if (this.dead) { - if (this.isOutsideBoxSpace) { - return true; - } - - // Dead && Inside box - if (this.deadWaitingTimeInBoxLeft <= 0) { - return true; - } - } - - return false; - } - - @action - resetGhost() { - this.ghostPaused = false; - this.send('RESET'); - this.statePhaseTimer.setDuration(getStatePhaseLength(this.state)); - this.statePhaseTimer.restart(); - } - - initialWaitingTimeInBox = 0; -} diff --git a/src/model/GhostStateChart.test.ts b/src/model/GhostStateChart.test.ts deleted file mode 100644 index 98a976f4..00000000 --- a/src/model/GhostStateChart.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { makeGhostStateChart } from './GhostStateChart'; - -describe('GhostStateChart', () => { - let ghostStateChart: ReturnType; - - beforeEach(() => { - ghostStateChart = makeGhostStateChart({ - onScatterToChase: () => {}, - onChaseToScatter: () => {}, - onDead: () => {}, - }); - ghostStateChart.start(); - }); - - it('starts in scatter state', () => { - expect(ghostStateChart.state.value).toBe('scatter'); - }); - - it('reacts to energizer', () => { - expect(ghostStateChart.state.value).toBe('scatter'); - - ghostStateChart.send('ENERGIZER_EATEN'); - expect(ghostStateChart.state.value).toBe('frightened'); - - ghostStateChart.send('ENERGIZER_TIMED_OUT'); - expect(ghostStateChart.state.value).toBe('chase'); - }); - - it('reacts to collision with Pac Man', () => { - ghostStateChart.send('PHASE_END'); - expect(ghostStateChart.state.value).toBe('chase'); - - ghostStateChart.send('ENERGIZER_EATEN'); - expect(ghostStateChart.state.value).toBe('frightened'); - - ghostStateChart.send('COLLISION_WITH_PAC_MAN'); - expect(ghostStateChart.state.value).toBe('dead'); - - ghostStateChart.send('REVIVED'); - expect(ghostStateChart.state.value).toBe('scatter'); - }); - - it('reacts to phase timeout', () => { - expect(ghostStateChart.state.value).toBe('scatter'); - - ghostStateChart.send('PHASE_END'); - expect(ghostStateChart.state.value).toBe('chase'); - - ghostStateChart.send('PHASE_END'); - expect(ghostStateChart.state.value).toBe('scatter'); - }); - - it('reacts to RESET', () => { - expect(ghostStateChart.state.value).toBe('scatter'); - - ghostStateChart.send('PHASE_END'); - expect(ghostStateChart.state.value).toBe('chase'); - - ghostStateChart.send('RESET'); - expect(ghostStateChart.state.value).toBe('scatter'); - }); -}); diff --git a/src/model/GhostStateChart.ts b/src/model/GhostStateChart.ts deleted file mode 100644 index b1e5b674..00000000 --- a/src/model/GhostStateChart.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-types */ - -import { Machine, interpret, State } from 'xstate'; - -export const INITIAL_GHOST_STATE = 'scatter'; - -interface GhostEventHandler { - onScatterToChase(): void; - onChaseToScatter(): void; - onDead(): void; -} - -type GhostContext = {}; - -interface GhostStateSchema { - states: { - chase: {}; - scatter: {}; - frightened: {}; - dead: {}; - }; -} - -export type GhostEventType = - | 'RESET' - | 'ENERGIZER_EATEN' - | 'ENERGIZER_TIMED_OUT' - | 'PHASE_END' - | 'COLLISION_WITH_PAC_MAN' - | 'REVIVED'; - -type GhostEvent = { type: GhostEventType }; - -export type GhostState = State; - -const GhostStateChart = Machine({ - id: 'ghost', - initial: INITIAL_GHOST_STATE, - on: { - RESET: INITIAL_GHOST_STATE, - }, - states: { - chase: { - on: { - ENERGIZER_EATEN: 'frightened', - PHASE_END: { - target: 'scatter', - actions: 'onChaseToScatter', - }, - COLLISION_WITH_PAC_MAN: { - target: 'scatter', - }, - }, - }, - scatter: { - on: { - ENERGIZER_EATEN: 'frightened', - PHASE_END: { - target: 'chase', - actions: 'onScatterToChase', - }, - COLLISION_WITH_PAC_MAN: { - target: 'scatter', - }, - }, - }, - frightened: { - on: { - ENERGIZER_TIMED_OUT: 'chase', - COLLISION_WITH_PAC_MAN: { - target: 'dead', - actions: 'onDead', - }, - }, - }, - dead: { - on: { - REVIVED: 'scatter', - ENERGIZER_TIMED_OUT: 'scatter', - }, - }, - }, -}); - -export const makeGhostStateChart = (eventHandler: GhostEventHandler) => { - const extended = GhostStateChart.withConfig({ - actions: { - onScatterToChase: eventHandler.onScatterToChase, - onChaseToScatter: eventHandler.onChaseToScatter, - onDead: eventHandler.onDead, - }, - }); - const stateChart = interpret(extended); - return stateChart; -}; diff --git a/src/model/IntervalTimer.test.ts b/src/model/IntervalTimer.test.ts deleted file mode 100644 index 3e06ee10..00000000 --- a/src/model/IntervalTimer.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { IntervalTimer } from './IntervalTimer'; - -describe('IntervalTimer', () => { - describe('start()', () => { - it('only counts down after start()', () => { - // Arrange - const onInterval = jest.fn(); - const timer = new IntervalTimer(3000, onInterval); - - // Act - timer.advance(5000); - - // Assert - expect(onInterval).toBeCalledTimes(0); - expect(timer.timeLeft).toBe(3000); - }); - - it('counts down on advance()', () => { - // Arrange - const onInterval = jest.fn(); - const timer = new IntervalTimer(3000, onInterval); - timer.start(); - - // Act - timer.advance(1000); - - // Assert - expect(timer.timeSpent).toBe(1000); - expect(timer.timeLeft).toBe(2000); - }); - - it('times out after the given duration', () => { - // Arrange - const onInterval = jest.fn(); - const timer = new IntervalTimer(3000, onInterval); - timer.start(); - - // Act - timer.advance(1000); - - // Assert - expect(onInterval).not.toBeCalled(); - expect(timer.timeLeft).toBe(2000); - - // Act - timer.advance(2000); - - // Assert - expect(onInterval).toBeCalledTimes(1); - expect(timer.timeSpent).toBe(0); - }); - - it('triggers multiple times', () => { - // Arrange - const onInterval = jest.fn(); - const timer = new IntervalTimer(3000, onInterval); - timer.start(); - - // Act - timer.advance(3000); - - // Assert - expect(onInterval).toBeCalledTimes(1); - expect(timer.timeSpent).toBe(0); - - // Act - timer.advance(3000); - - // Assert - expect(onInterval).toBeCalledTimes(2); - expect(timer.timeSpent).toBe(0); - }); - }); - - describe('stop()', () => { - it('stops the timer without calling the callback', () => { - // Arrange - const onInterval = jest.fn(); - const timer = new IntervalTimer(3000, onInterval); - timer.start(); - - timer.advance(1000); - timer.stop(); - - timer.advance(2000); - expect(timer.isTimedOut).toBeFalsy(); - expect(timer.timeLeft).toBe(2000); - expect(onInterval).toBeCalledTimes(0); - expect(timer.running).toBeFalsy(); - }); - }); - - describe('setDuration()', () => { - it('sets the timeout duration', () => { - // Arrange - const onInterval = jest.fn(); - const timer = new IntervalTimer(3000, onInterval); - expect(timer.duration).toBe(3000); - - // Act - timer.setDuration(5000); - expect(timer.duration).toBe(5000); - - // Assert - timer.start(); - timer.advance(3000); - timer.advance(2000); - expect(onInterval).toBeCalledTimes(1); - }); - }); - - describe('restart()', () => { - it('resets the timeout time', () => { - // Arrange - const onInterval = jest.fn(); - const timer = new IntervalTimer(3000, onInterval); - timer.start(); - timer.advance(1000); - expect(timer.timeLeft).toBe(2000); - expect(timer.running).toBeTruthy(); - - // Act - timer.restart(); - - // Assert - expect(timer.timeLeft).toBe(3000); - expect(timer.running).toBeTruthy(); - }); - }); -}); diff --git a/src/model/IntervalTimer.ts b/src/model/IntervalTimer.ts deleted file mode 100644 index 3ccb3ad5..00000000 --- a/src/model/IntervalTimer.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { MilliSeconds } from './Types'; -import { observable, computed, action, makeObservable } from 'mobx'; - -export type TimerCallback = () => void; - -export class IntervalTimer { - duration: MilliSeconds; - readonly onTimedOut: TimerCallback | null; - - @observable - running: boolean; - - @observable - timeSpent: MilliSeconds; - - constructor(duration: MilliSeconds, onTimedOut: TimerCallback | null = null) { - makeObservable(this); - this.duration = duration; - this.onTimedOut = onTimedOut; - this.running = false; - this.timeSpent = 0; - } - - @action - setDuration(duration: MilliSeconds) { - this.duration = duration; - } - - @action.bound - start() { - this.running = true; - this.timeSpent = 0; - } - - @action - advance(timePassed: MilliSeconds) { - if (!this.running) { - return; - } - this.timeSpent += timePassed; - if (this.isTimedOut) { - this.onTimedOut?.(); - this.timeSpent = 0; - } - } - - @action - stop() { - this.running = false; - } - - restart() { - this.stop(); - this.start(); - } - - @computed - get timeLeft() { - return this.duration - this.timeSpent; - } - - @computed - get isTimedOut() { - return this.timeSpent >= this.duration; - } -} diff --git a/src/model/Maze.ts b/src/model/Maze.ts deleted file mode 100644 index cec79b68..00000000 --- a/src/model/Maze.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { observable, makeObservable } from 'mobx'; -import { getPillsMatrix, TileId } from './MazeData'; - -export class Maze { - constructor() { - makeObservable(this); - } - - @observable - pills: TileId[][] = getPillsMatrix(); -} diff --git a/src/model/PacMan.test.ts b/src/model/PacMan.test.ts deleted file mode 100644 index 80cdf9c5..00000000 --- a/src/model/PacMan.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Game } from './Game'; -import { Store } from './Store'; - -// import { PacManStore } from "./PacManStore"; -export {}; - -describe('PacMan', () => { - it('has a state', () => { - // Arrange - const store = new Store(); - const game = new Game(store); - const pacMan = game.pacMan; - - // Assert - expect(pacMan.state).toBe('eating'); - }); - - it('reacts to events', () => { - // Arrange - const store = new Store(); - const game = new Game(store); - const pacMan = game.pacMan; - expect(pacMan.state).toBe('eating'); - - // Act - pacMan.send('COLLISION_WITH_GHOST'); - - // Assert - expect(pacMan.state).toBe('dead'); - }); -}); diff --git a/src/model/PacMan.ts b/src/model/PacMan.ts deleted file mode 100644 index c9d0976b..00000000 --- a/src/model/PacMan.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { observable, action, computed, makeObservable } from 'mobx'; - -import { Direction, MilliSeconds } from './Types'; -import { - tileFromScreen, - screenFromTile, - TileCoordinates, - ScreenCoordinates, - assertValidTileCoordinates, -} from './Coordinates'; -import { - makePacManStateChart, - PacManEventType, - INITIAL_PACMAN_STATE, - PacManState, -} from './PacManStateChart'; -import { Game } from './Game'; -import { StateValue } from 'xstate'; - -export class PacMan { - constructor(game: Game) { - this.game = game; - this.stateChart = makePacManStateChart({ - onChasing: this.onChasing, - onDead: this.onDead, - }); - this.stateChart.start(); - this.stateChartState = this.stateChart.state as unknown as PacManState; - makeObservable(this); - - this.stateChart.onTransition(this.handleTransition as unknown as Parameters[0]); - } - - @action.bound - handleTransition(state: PacManState) { - if (!state.changed) { - return; - } - this.stateChartState = state; - } - - game: Game; - - stateChart: ReturnType; - - @observable.ref - stateChartState!: PacManState; - - onChasing = () => { - this.game.energizerTimer.start(); - }; - - onDead = () => { - this.diedAtTimestamp = this.game.timestamp; - }; - - @computed - get dead(): boolean { - return this.stateChartState.matches('dead'); - } - - @computed - get state(): StateValue { - return this.stateChartState.value; - } - - send(event: PacManEventType) { - this.stateChart.send(event); - } - - @computed - get alive() { - return !this.dead; - } - - @observable - screenCoordinates: ScreenCoordinates = screenFromTile({ x: 1, y: 1 }); - - @action - setTileCoordinates(tile: TileCoordinates) { - assertValidTileCoordinates(tile); - this.screenCoordinates = screenFromTile(tile); - } - - @computed - get tileCoordinates(): TileCoordinates { - return tileFromScreen(this.screenCoordinates); - } - - @observable - diedAtTimestamp: MilliSeconds = -1; - - @computed - get timeSinceDeath(): MilliSeconds { - if (this.alive) { - return 0; - } - return this.game.timestamp - this.diedAtTimestamp; - } - - @observable - extraLivesLeft = 2; - - @observable - direction: Direction = 'RIGHT'; - nextDirection: Direction = 'RIGHT'; -} - -export const resetPacMan = (pacMan: PacMan) => { - pacMan.diedAtTimestamp = -1; - pacMan.stateChart.state.value = INITIAL_PACMAN_STATE; - pacMan.setTileCoordinates({ x: 14, y: 23 }); - pacMan.nextDirection = 'LEFT'; - pacMan.direction = 'LEFT'; -}; diff --git a/src/model/PacManStateChart.test.ts b/src/model/PacManStateChart.test.ts deleted file mode 100644 index b832b993..00000000 --- a/src/model/PacManStateChart.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { makePacManStateChart } from './PacManStateChart'; - -describe('PacManStateChart', () => { - const onChasing = jest.fn(); - const onDead = jest.fn(); - const pacManStateChart = makePacManStateChart({ onChasing, onDead }); - - beforeEach(() => { - pacManStateChart.start(); - }); - - it('starts in eating state', () => { - expect(pacManStateChart.state.value).toBe('eating'); - }); - - it('reacts to energizer', () => { - expect(pacManStateChart.state.value).toBe('eating'); - - pacManStateChart.send('ENERGIZER_EATEN'); - expect(pacManStateChart.state.value).toBe('chasing'); - - pacManStateChart.send('ENERGIZER_TIMED_OUT'); - expect(pacManStateChart.state.value).toBe('eating'); - }); - - it('reacts to collision with ghost', () => { - expect(pacManStateChart.state.value).toBe('eating'); - - pacManStateChart.send('ENERGIZER_EATEN'); - expect(pacManStateChart.state.value).toBe('chasing'); - - pacManStateChart.send('COLLISION_WITH_GHOST'); - expect(pacManStateChart.state.value).toBe('chasing'); - - pacManStateChart.send('ENERGIZER_TIMED_OUT'); - expect(pacManStateChart.state.value).toBe('eating'); - - pacManStateChart.send('COLLISION_WITH_GHOST'); - expect(pacManStateChart.state.value).toBe('dead'); - expect(onDead.mock.calls.length).toBe(1); - - pacManStateChart.send('REVIVED'); - expect(pacManStateChart.state.value).toBe('eating'); - }); -}); diff --git a/src/model/PacManStateChart.ts b/src/model/PacManStateChart.ts deleted file mode 100644 index a6405a2e..00000000 --- a/src/model/PacManStateChart.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-types */ - -import { Machine, interpret, State } from 'xstate'; - -export const INITIAL_PACMAN_STATE = 'eating'; - -interface EventHandler { - onChasing(): void; - onDead(): void; -} - -type PacManContext = {}; - -interface PacManStateSchema { - states: { - eating: {}; - chasing: {}; - dead: {}; - }; -} - -export type PacManEventType = - | 'ENERGIZER_EATEN' - | 'ENERGIZER_TIMED_OUT' - | 'COLLISION_WITH_GHOST' - | 'REVIVED'; - -type PacManEvent = { type: PacManEventType }; - -export type PacManState = State< - PacManContext, - PacManEvent, - PacManStateSchema, - any ->; - -const PacManStateChart = Machine( - { - id: 'pac-man', - initial: INITIAL_PACMAN_STATE, - states: { - eating: { - on: { - ENERGIZER_EATEN: 'chasing', - COLLISION_WITH_GHOST: 'dead', - }, - }, - chasing: { - entry: 'onChasing', - on: { - ENERGIZER_TIMED_OUT: 'eating', - }, - }, - dead: { - entry: 'onDead', - on: { - REVIVED: 'eating', - }, - }, - }, - } -); - -export const makePacManStateChart = (eventHandler: EventHandler) => { - const extended = PacManStateChart.withConfig({ - actions: { - onChasing: eventHandler.onChasing, - onDead: eventHandler.onDead, - }, - }); - const stateChart = interpret(extended); - return stateChart; -}; diff --git a/src/model/Store.ts b/src/model/Store.ts deleted file mode 100644 index 4599d9cc..00000000 --- a/src/model/Store.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { observable, action, makeObservable } from 'mobx'; -import { Game } from './Game'; -import { DebugState } from './DebugState'; - -export class Store { - constructor() { - makeObservable(this); - } - - @observable - game: Game = new Game(this); - - debugState = new DebugState(this); - - @action.bound - resetGame() { - this.game = new Game(this); - this.game.readyGameForPlay(); - } -} diff --git a/src/model/TimeoutTimer.test.ts b/src/model/TimeoutTimer.test.ts deleted file mode 100644 index 46445331..00000000 --- a/src/model/TimeoutTimer.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { TimeoutTimer } from './TimeoutTimer'; - -describe('TimeoutTimer', () => { - describe('start()', () => { - it('only counts down after start()', () => { - // Arrange - const onTimedOut = jest.fn(); - const timer = new TimeoutTimer(3000, onTimedOut); - - // Act - timer.advance(5000); - - // Assert - expect(onTimedOut.mock.calls).toHaveLength(0); - expect(timer.timeLeft).toBe(3000); - }); - - it('counts down on advance()', () => { - // Arrange - const onTimedOut = jest.fn(); - const timer = new TimeoutTimer(3000, onTimedOut); - timer.start(); - - // Act - timer.advance(1000); - - // Assert - expect(timer.timeSpent).toBe(1000); - expect(timer.timeLeft).toBe(2000); - }); - - it('times out after the given duration', () => { - // Arrange - const onTimedOut = jest.fn(); - const timer = new TimeoutTimer(3000, onTimedOut); - timer.start(); - - // Act - timer.advance(1000); - - // Assert - expect(onTimedOut).not.toBeCalled(); - expect(timer.timeLeft).toBe(2000); - - // Act - timer.advance(2000); - - // Assert - expect(onTimedOut).toBeCalledTimes(1); - expect(timer.timeSpent).toBe(3000); - }); - - it('restarts on start()', () => { - // Arrange - const onTimedOut = jest.fn(); - const timer = new TimeoutTimer(3000, onTimedOut); - timer.start(); - - timer.advance(3000); - - expect(timer.isTimedOut).toBeTruthy(); - - // Act - timer.start(); - - // Assert - expect(onTimedOut).toBeCalledTimes(1); - expect(timer.isTimedOut).toBeFalsy(); - expect(timer.timeLeft).toBe(3000); - - // Act - timer.advance(3000); - expect(onTimedOut).toBeCalledTimes(2); - }); - }); - - it('stops after timing out', () => { - // Arrange - const onTimedOut = jest.fn(); - const timer = new TimeoutTimer(3000, onTimedOut); - timer.start(); - - timer.advance(1000); - timer.advance(2000); - expect(timer.isTimedOut).toBeTruthy(); - expect(timer.timeLeft).toBe(0); - expect(onTimedOut).toBeCalledTimes(1); - - // Act - timer.advance(1000); - - // Assert - expect(timer.running).toBeFalsy(); - expect(timer.timeLeft).toBe(0); - expect(onTimedOut).toBeCalledTimes(1); - }); - - describe('stop()', () => { - it('stops the timer without calling the callback', () => { - // Arrange - const onTimedOut = jest.fn(); - const timer = new TimeoutTimer(3000, onTimedOut); - timer.start(); - - timer.advance(1000); - timer.stop(); - - timer.advance(2000); - expect(timer.isTimedOut).toBeFalsy(); - expect(timer.timeLeft).toBe(2000); - expect(onTimedOut.mock.calls).toHaveLength(0); - expect(timer.running).toBeFalsy(); - }); - }); - - describe('setDuration()', () => { - it('sets the timeout duration', () => { - // Arrange - const onTimedOut = jest.fn(); - const timer = new TimeoutTimer(3000, onTimedOut); - expect(timer.duration).toBe(3000); - - // Act - timer.setDuration(5000); - expect(timer.duration).toBe(5000); - - // Assert - timer.start(); - timer.advance(3000); - expect(timer.isTimedOut).toBeFalsy(); - timer.advance(2000); - expect(timer.isTimedOut).toBeTruthy(); - }); - }); - - describe('restart()', () => { - it('resets the timeout time', () => { - // Arrange - const onTimedOut = jest.fn(); - const timer = new TimeoutTimer(3000, onTimedOut); - timer.start(); - timer.advance(1000); - expect(timer.timeLeft).toBe(2000); - expect(timer.running).toBeTruthy(); - - // Act - timer.restart(); - - // Assert - expect(timer.timeLeft).toBe(3000); - expect(timer.running).toBeTruthy(); - }); - }); -}); diff --git a/src/model/TimeoutTimer.ts b/src/model/TimeoutTimer.ts deleted file mode 100644 index 08110aa1..00000000 --- a/src/model/TimeoutTimer.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { MilliSeconds } from './Types'; -import { observable, computed, action, makeObservable } from 'mobx'; - -export type TimerCallback = () => void; - -export class TimeoutTimer { - duration: MilliSeconds; - readonly onTimedOut: TimerCallback | null; - - @observable - running: boolean; - - @observable - timeSpent: MilliSeconds; - - constructor(duration: MilliSeconds, onTimedOut: TimerCallback | null = null) { - makeObservable(this); - this.duration = duration; - this.onTimedOut = onTimedOut; - this.running = false; - this.timeSpent = 0; - } - - @action - setDuration(duration: MilliSeconds) { - this.duration = duration; - } - - @action.bound - start() { - this.running = true; - this.timeSpent = 0; - } - - @action - advance(timePassed: MilliSeconds) { - if (!this.running) { - return; - } - this.timeSpent += timePassed; - if (this.isTimedOut) { - this.onTimedOut?.(); - this.stop(); - } - } - - @action - stop() { - this.running = false; - } - - restart() { - this.stop(); - this.start(); - } - - @computed - get timeLeft() { - return this.duration - this.timeSpent; - } - - @computed - get isTimedOut() { - return this.timeSpent >= this.duration; - } -} diff --git a/src/model/Types.test.ts b/src/model/Types.test.ts index 60f571bb..7c347b78 100644 --- a/src/model/Types.test.ts +++ b/src/model/Types.test.ts @@ -1,5 +1,5 @@ import { SCREEN_TILE_SIZE } from './Coordinates'; -import { DEFAULT_SPEED } from './Game'; +import { DEFAULT_SPEED } from './store/constants'; describe('Types', () => { describe('SPEED', () => { diff --git a/src/model/changeDirectionToOpposite.test.ts b/src/model/changeDirectionToOpposite.test.ts deleted file mode 100644 index ba24e98c..00000000 --- a/src/model/changeDirectionToOpposite.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { changeDirectionToOpposite } from './changeDirectionToOpposite'; -import { Game } from './Game'; -import { Store } from './Store'; - -describe('changeDirectionToOpposite', () => { - describe('changeDirectionToOpposite()', () => { - it('turns a ghosts direction by 180 degrees', () => { - // Arrange - const store = new Store(); - const game = new Game(store); - const ghost = game.ghosts[0]; - ghost.setTileCoordinates({ x: 6, y: 1 }); - ghost.direction = 'RIGHT'; - - // Act - changeDirectionToOpposite(ghost); - - // Assert - expect(ghost.direction).toBe('LEFT'); - }); - }); -}); diff --git a/src/model/changeDirectionToOpposite.ts b/src/model/changeDirectionToOpposite.ts deleted file mode 100644 index 224dbbbc..00000000 --- a/src/model/changeDirectionToOpposite.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Ghost } from './Ghost'; -import { DIRECTION_TO_OPPOSITE_DIRECTION } from './Ways'; - -export const changeDirectionToOpposite = (ghost: Ghost) => { - ghost.direction = DIRECTION_TO_OPPOSITE_DIRECTION[ghost.direction]; -}; diff --git a/src/model/chooseNewTargetTile.test.ts b/src/model/chooseNewTargetTile.test.ts index 9e36a640..90222047 100644 --- a/src/model/chooseNewTargetTile.test.ts +++ b/src/model/chooseNewTargetTile.test.ts @@ -1,25 +1,48 @@ import { TileCoordinates } from './Coordinates'; -import { Game } from './Game'; import { chooseNewTargetTile, chooseGhost2IntermediateTile, TILE_FOR_RETURNING_TO_BOX, SCATTER_TILE_FOR_GHOST_0, + GhostTargetingContext, } from './chooseNewTargetTile'; -import { Store } from './Store'; const TILE_OUTSIDE_THE_BOX: TileCoordinates = { x: 13, y: 11 }; +const createContext = (overrides: Partial<{ + ghostState: 'scatter' | 'chase' | 'frightened' | 'dead'; + ghostNumber: number; + ghostTileCoordinates: TileCoordinates; + ghostDirection: 'UP' | 'DOWN' | 'LEFT' | 'RIGHT'; + isInsideBoxWalls: boolean; + pacManTileCoordinates: TileCoordinates; + pacManDirection: 'UP' | 'DOWN' | 'LEFT' | 'RIGHT'; + blinkyTileCoordinates: TileCoordinates; +}> = {}): GhostTargetingContext => ({ + ghost: { + state: overrides.ghostState ?? 'scatter', + ghostNumber: overrides.ghostNumber ?? 0, + tileCoordinates: overrides.ghostTileCoordinates ?? TILE_OUTSIDE_THE_BOX, + direction: overrides.ghostDirection ?? 'LEFT', + isInsideBoxWalls: overrides.isInsideBoxWalls ?? false, + }, + pacMan: { + tileCoordinates: overrides.pacManTileCoordinates ?? { x: 1, y: 1 }, + direction: overrides.pacManDirection ?? 'LEFT', + }, + blinkyTileCoordinates: overrides.blinkyTileCoordinates ?? { x: 1, y: 1 }, +}); + describe('chooseNewTargetTile', () => { describe('chooseNewTargetTile()', () => { describe('in scatter state', () => { it('returns the ghosts scatter tile', () => { - const store = new Store(); - const game = new Game(store); - const ghost = game.ghosts[0]; - ghost.setTileCoordinates(TILE_OUTSIDE_THE_BOX); - expect(ghost.state).toBe('scatter'); - const tile: TileCoordinates = chooseNewTargetTile(ghost); + const ctx = createContext({ + ghostState: 'scatter', + ghostNumber: 0, + ghostTileCoordinates: TILE_OUTSIDE_THE_BOX, + }); + const tile: TileCoordinates = chooseNewTargetTile(ctx); expect(tile).toEqual(SCATTER_TILE_FOR_GHOST_0); }); }); @@ -27,67 +50,59 @@ describe('chooseNewTargetTile', () => { describe('in chase state', () => { describe('for Blinky (0)', () => { it('returns pac mans tile', () => { - const store = new Store(); - const game = new Game(store); - game.pacMan.setTileCoordinates({ x: 1, y: 1 }); - const ghost = game.ghosts[0]; - ghost.setTileCoordinates(TILE_OUTSIDE_THE_BOX); - ghost.send('PHASE_END'); - expect(ghost.state).toBe('chase'); - const tile: TileCoordinates = chooseNewTargetTile(ghost); + const ctx = createContext({ + ghostState: 'chase', + ghostNumber: 0, + ghostTileCoordinates: TILE_OUTSIDE_THE_BOX, + pacManTileCoordinates: { x: 1, y: 1 }, + }); + const tile: TileCoordinates = chooseNewTargetTile(ctx); expect(tile).toEqual({ x: 1, y: 1 }); }); }); describe('for Pinky (1)', () => { it('returns the tile 4 ahead of pac man', () => { - const store = new Store(); - const game = new Game(store); - game.pacMan.setTileCoordinates({ x: 1, y: 1 }); - game.pacMan.direction = 'RIGHT'; - - const ghost = game.ghosts[1]; - ghost.setTileCoordinates(TILE_OUTSIDE_THE_BOX); - ghost.send('PHASE_END'); - expect(ghost.state).toBe('chase'); - const tile: TileCoordinates = chooseNewTargetTile(ghost); + const ctx = createContext({ + ghostState: 'chase', + ghostNumber: 1, + ghostTileCoordinates: TILE_OUTSIDE_THE_BOX, + pacManTileCoordinates: { x: 1, y: 1 }, + pacManDirection: 'RIGHT', + }); + const tile: TileCoordinates = chooseNewTargetTile(ctx); expect(tile).toEqual({ x: 5, y: 1 }); }); describe('when pac man faces up', () => { it('returns the tile 4 ahead and to the left of pac man', () => { - const store = new Store(); - const game = new Game(store); - game.pacMan.setTileCoordinates({ x: 6, y: 5 }); - game.pacMan.direction = 'UP'; - - const ghost = game.ghosts[1]; - ghost.setTileCoordinates(TILE_OUTSIDE_THE_BOX); - ghost.send('PHASE_END'); - expect(ghost.state).toBe('chase'); - const tile: TileCoordinates = chooseNewTargetTile(ghost); + const ctx = createContext({ + ghostState: 'chase', + ghostNumber: 1, + ghostTileCoordinates: TILE_OUTSIDE_THE_BOX, + pacManTileCoordinates: { x: 6, y: 5 }, + pacManDirection: 'UP', + }); + const tile: TileCoordinates = chooseNewTargetTile(ctx); expect(tile).toEqual({ x: 2, y: 1 }); }); }); }); describe('for Inky (2)', () => { - it('returns the tile 4 ahead of pac man', () => { - const store = new Store(); - const game = new Game(store); - - const ghost = game.ghosts[2]; - ghost.setTileCoordinates(TILE_OUTSIDE_THE_BOX); - ghost.send('PHASE_END'); - expect(ghost.state).toBe('chase'); - game.pacMan.setTileCoordinates({ x: 6, y: 5 }); - game.pacMan.direction = 'DOWN'; - const blinky = game.ghosts[0]; - blinky.setTileCoordinates({ x: 6, y: 1 }); + it('returns the tile based on blinky position', () => { + const ctx = createContext({ + ghostState: 'chase', + ghostNumber: 2, + ghostTileCoordinates: TILE_OUTSIDE_THE_BOX, + pacManTileCoordinates: { x: 6, y: 5 }, + pacManDirection: 'DOWN', + blinkyTileCoordinates: { x: 6, y: 1 }, + }); - expect(chooseGhost2IntermediateTile(ghost)).toEqual({ x: 6, y: 7 }); + expect(chooseGhost2IntermediateTile(ctx)).toEqual({ x: 6, y: 7 }); - const tile: TileCoordinates = chooseNewTargetTile(ghost); + const tile: TileCoordinates = chooseNewTargetTile(ctx); expect(tile).toEqual({ x: 6, y: 13 }); }); }); @@ -95,28 +110,26 @@ describe('chooseNewTargetTile', () => { describe('for Clyde (3)', () => { describe('when pac man is >= 8 tiles away', () => { it('returns pac mans tile', () => { - const store = new Store(); - const game = new Game(store); - game.pacMan.setTileCoordinates({ x: 6, y: 5 }); - const ghost = game.ghosts[3]; - ghost.send('PHASE_END'); - expect(ghost.state).toBe('chase'); - ghost.setTileCoordinates({ x: 21, y: 20 }); - const tile: TileCoordinates = chooseNewTargetTile(ghost); + const ctx = createContext({ + ghostState: 'chase', + ghostNumber: 3, + ghostTileCoordinates: { x: 21, y: 20 }, + pacManTileCoordinates: { x: 6, y: 5 }, + }); + const tile: TileCoordinates = chooseNewTargetTile(ctx); expect(tile).toEqual({ x: 6, y: 5 }); }); }); describe('when pac man is < 8 tiles away', () => { it('returns clydes scatter tile', () => { - const store = new Store(); - const game = new Game(store); - game.pacMan.setTileCoordinates({ x: 6, y: 5 }); - const ghost = game.ghosts[3]; - ghost.send('PHASE_END'); - expect(ghost.state).toBe('chase'); - ghost.setTileCoordinates({ x: 1, y: 8 }); - const tile: TileCoordinates = chooseNewTargetTile(ghost); + const ctx = createContext({ + ghostState: 'chase', + ghostNumber: 3, + ghostTileCoordinates: { x: 1, y: 8 }, + pacManTileCoordinates: { x: 6, y: 5 }, + }); + const tile: TileCoordinates = chooseNewTargetTile(ctx); expect(tile).toEqual({ x: 1, y: 29 }); }); }); @@ -125,41 +138,30 @@ describe('chooseNewTargetTile', () => { describe('in frightened state', () => { it('returns a random direction that is not backward and not in to a wall', () => { - // Arrange - const store = new Store(); - const game = new Game(store); - game.timestamp = 1; - const ghost = game.ghosts[0]; - ghost.setTileCoordinates({ x: 1, y: 1 }); - ghost.direction = 'DOWN'; - ghost.send('ENERGIZER_EATEN'); - expect(ghost.state).toBe('frightened'); - - // Act - const tile: TileCoordinates = chooseNewTargetTile(ghost); - - // Assert + const ctx = createContext({ + ghostState: 'frightened', + ghostNumber: 0, + ghostTileCoordinates: { x: 1, y: 1 }, + ghostDirection: 'DOWN', + }); + + const tile: TileCoordinates = chooseNewTargetTile(ctx); + expect(tile).toEqual({ x: 2, y: 1 }); }); }); describe('in dead state', () => { it('returns a tile inside the box', () => { - // Arrange - const store = new Store(); - const game = new Game(store); - game.timestamp = 1; - const ghost = game.ghosts[0]; - ghost.setTileCoordinates(TILE_OUTSIDE_THE_BOX); - ghost.direction = 'LEFT'; - ghost.send('ENERGIZER_EATEN'); - ghost.send('COLLISION_WITH_PAC_MAN'); - expect(ghost.state).toBe('dead'); - - // Act - const tile: TileCoordinates = chooseNewTargetTile(ghost); - - // Assert + const ctx = createContext({ + ghostState: 'dead', + ghostNumber: 0, + ghostTileCoordinates: TILE_OUTSIDE_THE_BOX, + ghostDirection: 'LEFT', + }); + + const tile: TileCoordinates = chooseNewTargetTile(ctx); + expect(tile).toEqual(TILE_FOR_RETURNING_TO_BOX); }); }); diff --git a/src/model/chooseNewTargetTile.ts b/src/model/chooseNewTargetTile.ts index 736308e7..047b1584 100644 --- a/src/model/chooseNewTargetTile.ts +++ b/src/model/chooseNewTargetTile.ts @@ -3,12 +3,27 @@ import { getPointDifferenceAsVector, addCoordinatesAndVector, } from './Coordinates'; -import { Ghost } from './Ghost'; import { moveFromTile, isWayFreeInDirection, getNextTile } from './Ways'; import { getTileDistance } from './getTileDistance'; import { Directions, Direction } from './Types'; import { rotateVectorBy180Degrees } from './Vector'; import { assert } from '../util/assert'; +import { GhostStateValue } from './store/types'; + +export interface GhostTargetingContext { + ghost: { + state: GhostStateValue; + ghostNumber: number; + tileCoordinates: TileCoordinates; + direction: Direction; + isInsideBoxWalls: boolean; + }; + pacMan: { + tileCoordinates: TileCoordinates; + direction: Direction; + }; + blinkyTileCoordinates: TileCoordinates; +} export const TILE_FOR_LEAVING_THE_BOX: TileCoordinates = { x: 13, @@ -22,26 +37,26 @@ export const TILE_FOR_RETURNING_TO_BOX: TileCoordinates = { export const SCATTER_TILE_FOR_GHOST_0: TileCoordinates = { x: 26, y: 1 }; -export const chooseNewTargetTile = (ghost: Ghost): TileCoordinates => { - switch (ghost.state) { +export const chooseNewTargetTile = (ctx: GhostTargetingContext): TileCoordinates => { + switch (ctx.ghost.state) { case 'scatter': - return chooseInScatterMode(ghost); + return chooseInScatterMode(ctx); case 'chase': - return choseInChaseMode(ghost); + return choseInChaseMode(ctx); case 'frightened': - return chooseInFrightenedMode(ghost); + return chooseInFrightenedMode(ctx); case 'dead': - return chooseInDeadMode(ghost); + return chooseInDeadMode(); default: - throw new Error(`Bad state ${ghost.state}`); + throw new Error(`Bad state ${ctx.ghost.state}`); } }; -const chooseInScatterMode = (ghost: Ghost): TileCoordinates => { - if (ghost.isInsideBoxWalls) { +const chooseInScatterMode = (ctx: GhostTargetingContext): TileCoordinates => { + if (ctx.ghost.isInsideBoxWalls) { return TILE_FOR_LEAVING_THE_BOX; } - switch (ghost.ghostNumber) { + switch (ctx.ghost.ghostNumber) { case 0: return SCATTER_TILE_FOR_GHOST_0; case 1: @@ -51,33 +66,30 @@ const chooseInScatterMode = (ghost: Ghost): TileCoordinates => { case 3: return { x: 1, y: 29 }; default: - throw new Error(`Bad ghostNumber ${ghost.ghostNumber}`); + throw new Error(`Bad ghostNumber ${ctx.ghost.ghostNumber}`); } }; -const chooseForGhost0InChaseState = (ghost: Ghost): TileCoordinates => { - const pacMan = ghost.game.pacMan; - return pacMan.tileCoordinates; +const chooseForGhost0InChaseState = (ctx: GhostTargetingContext): TileCoordinates => { + return ctx.pacMan.tileCoordinates; }; -const chooseForGhost1InChaseState = (ghost: Ghost): TileCoordinates => { - const pacMan = ghost.game.pacMan; +const chooseForGhost1InChaseState = (ctx: GhostTargetingContext): TileCoordinates => { const fourTilesAhead = moveFromTile( - pacMan.tileCoordinates, - pacMan.direction, + ctx.pacMan.tileCoordinates, + ctx.pacMan.direction, 4 ); - return pacMan.direction === 'UP' + return ctx.pacMan.direction === 'UP' ? moveFromTile(fourTilesAhead, 'LEFT', 4) : fourTilesAhead; }; -const chooseForGhost2InChaseState = (ghost: Ghost): TileCoordinates => { - const intermediateTile = chooseGhost2IntermediateTile(ghost); - const blinky = ghost.game.ghosts[0]; +const chooseForGhost2InChaseState = (ctx: GhostTargetingContext): TileCoordinates => { + const intermediateTile = chooseGhost2IntermediateTile(ctx); const vectorToBlinky = getPointDifferenceAsVector( intermediateTile, - blinky.tileCoordinates + ctx.blinkyTileCoordinates ); const rotatedVector = rotateVectorBy180Degrees(vectorToBlinky); const newTile = addCoordinatesAndVector(intermediateTile, rotatedVector); @@ -85,76 +97,69 @@ const chooseForGhost2InChaseState = (ghost: Ghost): TileCoordinates => { return newTile; }; -export const chooseGhost2IntermediateTile = (ghost: Ghost): TileCoordinates => { - const pacMan = ghost.game.pacMan; +export const chooseGhost2IntermediateTile = (ctx: GhostTargetingContext): TileCoordinates => { const twoTilesAhead = moveFromTile( - pacMan.tileCoordinates, - pacMan.direction, + ctx.pacMan.tileCoordinates, + ctx.pacMan.direction, 2 ); - return pacMan.direction === 'UP' + return ctx.pacMan.direction === 'UP' ? moveFromTile(twoTilesAhead, 'LEFT', 2) : twoTilesAhead; }; -const chooseForGhost3InChaseState = (ghost: Ghost): TileCoordinates => { - const pacMan = ghost.game.pacMan; +const chooseForGhost3InChaseState = (ctx: GhostTargetingContext): TileCoordinates => { const distance = getTileDistance( - ghost.tileCoordinates, - pacMan.tileCoordinates + ctx.ghost.tileCoordinates, + ctx.pacMan.tileCoordinates ); - return distance >= 8 ? pacMan.tileCoordinates : chooseInScatterMode(ghost); + return distance >= 8 ? ctx.pacMan.tileCoordinates : chooseInScatterMode(ctx); }; -const choseInChaseMode = (ghost: Ghost): TileCoordinates => { - if (ghost.isInsideBoxWalls) { +const choseInChaseMode = (ctx: GhostTargetingContext): TileCoordinates => { + if (ctx.ghost.isInsideBoxWalls) { return TILE_FOR_LEAVING_THE_BOX; } - switch (ghost.ghostNumber) { + switch (ctx.ghost.ghostNumber) { case 0: - return chooseForGhost0InChaseState(ghost); + return chooseForGhost0InChaseState(ctx); case 1: - return chooseForGhost1InChaseState(ghost); + return chooseForGhost1InChaseState(ctx); case 2: - return chooseForGhost2InChaseState(ghost); + return chooseForGhost2InChaseState(ctx); case 3: - return chooseForGhost3InChaseState(ghost); + return chooseForGhost3InChaseState(ctx); default: - throw new Error(`Bad ghostNumber ${ghost.ghostNumber}`); + throw new Error(`Bad ghostNumber ${ctx.ghost.ghostNumber}`); } }; const getRandomInt = (max: number) => Math.floor(Math.random() * Math.floor(max)); -const chooseInFrightenedMode = (ghost: Ghost): TileCoordinates => { - // Choose a random neighbour tile that is not backward and not into a wall. - - return chooseSomeRandomMovement(ghost); +const chooseInFrightenedMode = (ctx: GhostTargetingContext): TileCoordinates => { + return chooseSomeRandomMovement(ctx); }; /** * Choose a random neighbour tile that is not backward and not into a wall. */ -const chooseSomeRandomMovement = (ghost: Ghost): TileCoordinates => { +const chooseSomeRandomMovement = (ctx: GhostTargetingContext): TileCoordinates => { const candidateDirections: Direction[] = Directions.filter( direction => - direction !== ghost.direction && - isWayFreeInDirection(ghost.tileCoordinates, direction) + direction !== ctx.ghost.direction && + isWayFreeInDirection(ctx.ghost.tileCoordinates, direction) ); assert(candidateDirections.length > 0); const newDirection = candidateDirections[getRandomInt(candidateDirections.length)]; assert(newDirection); - const randomNeighourTile = getNextTile(ghost.tileCoordinates, newDirection); + const randomNeighourTile = getNextTile(ctx.ghost.tileCoordinates, newDirection); return randomNeighourTile; }; -const chooseInDeadMode = (ghost: Ghost): TileCoordinates => { - // if (ghost.deadWaitingTimeInBoxLeft < 0) { - // return chooseSomeRandomMovement(ghost); - // } +const chooseInDeadMode = (): TileCoordinates => { return TILE_FOR_RETURNING_TO_BOX; }; diff --git a/src/model/chooseNextTile.ts b/src/model/chooseNextTile.ts index aa26203c..3777347d 100644 --- a/src/model/chooseNextTile.ts +++ b/src/model/chooseNextTile.ts @@ -8,7 +8,6 @@ import { isBoxDoorAt, } from './Ways'; import { getTileDistance } from './getTileDistance'; -import { toJS } from 'mobx'; import { assert } from '../util/assert'; interface CandidateTile { @@ -27,7 +26,7 @@ export const chooseNextTile = ({ targetTile: TileCoordinates; boxDoorIsOpen: boolean; }): TileCoordinates => { - assert(isValidTileCoordinates(currentTile), `${toJS(currentTile)}`); + assert(isValidTileCoordinates(currentTile), JSON.stringify(currentTile)); const bestNextTile = chooseBestNextTile({ currentTile, currentDirection, @@ -53,7 +52,7 @@ export const chooseNextTile = ({ console.error('currentTile', currentTile); console.error('currentDirection', currentDirection); console.error('boxDoorIsOpen', boxDoorIsOpen); - console.error('targetTile', toJS(targetTile)); + console.error('targetTile', targetTile); throw new Error(`Found no candidate at ${JSON.stringify(currentTile)}`); }; diff --git a/src/model/detectCollisions.test.ts b/src/model/detectCollisions.test.ts index 18988b2d..f55f5498 100644 --- a/src/model/detectCollisions.test.ts +++ b/src/model/detectCollisions.test.ts @@ -1,56 +1,70 @@ import { detectCollisions, BASIC_PILL_POINTS } from './detectCollisions'; -import { Game } from './Game'; import { EMPTY_TILE_ID } from './MazeData'; import { ENERGIZER_POINTS } from './eatEnergizer'; -import { Store } from './Store'; +import { useGameStore, createInitialState } from './store'; +import { screenFromTile } from './Coordinates'; + +// Helper to reset store before each test +const resetStore = () => { + useGameStore.setState(createInitialState()); +}; + +// Helper to set pacman position +const setPacManTileCoordinates = (tile: { x: number; y: number }) => { + useGameStore.setState((state) => { + state.game.pacMan.screenCoordinates = screenFromTile(tile); + }); +}; describe('detectCollisions', () => { + beforeEach(() => { + resetStore(); + }); + describe('detectCollisions()', () => { describe('when hitting pill', () => { it('eats it', () => { - const store = new Store(); - const game = new Game(store); - const pacMan = game.pacMan; - pacMan.setTileCoordinates({ x: 12, y: 8 }); - expect(game.score).toBe(0); - detectCollisions(game); - expect(game.score).toBe(BASIC_PILL_POINTS); + setPacManTileCoordinates({ x: 12, y: 8 }); + expect(useGameStore.getState().game.score).toBe(0); + detectCollisions(); + expect(useGameStore.getState().game.score).toBe(BASIC_PILL_POINTS); }); }); describe('when hitting energizer', () => { - let game: Game; - beforeEach(() => { // Arrange - const store = new Store(); - game = new Game(store); - game.timestamp = 1; - const pacMan = game.pacMan; - pacMan.setTileCoordinates({ x: 26, y: 3 }); - expect(game.score).toBe(0); - game.killedGhosts = 1; + useGameStore.setState((state) => { + state.game.timestamp = 1; + state.game.killedGhosts = 1; + }); + setPacManTileCoordinates({ x: 26, y: 3 }); + expect(useGameStore.getState().game.score).toBe(0); // Act - detectCollisions(game); + detectCollisions(); }); it('eats it', () => { - expect(game.score).toBe(ENERGIZER_POINTS); - expect(game.maze.pills[3][26]).toBe(EMPTY_TILE_ID); + const state = useGameStore.getState(); + expect(state.game.score).toBe(ENERGIZER_POINTS); + expect(state.game.maze.pills[3][26]).toBe(EMPTY_TILE_ID); }); it('makes pacman chase', () => { - expect(game.pacMan.state).toBe('chasing'); - expect(game.energizerTimer.running).toBeTruthy(); + const state = useGameStore.getState(); + expect(state.game.pacMan.state).toBe('chasing'); + expect(state.game.energizerTimer.running).toBeTruthy(); }); it('makes ghosts frightened', () => { - expect(game.ghosts[0].state).toBe('frightened'); + const state = useGameStore.getState(); + expect(state.game.ghosts[0].state).toBe('frightened'); }); it('resets killed ghost counter', () => { - expect(game.killedGhosts).toBe(0); + const state = useGameStore.getState(); + expect(state.game.killedGhosts).toBe(0); }); }); }); diff --git a/src/model/detectCollisions.ts b/src/model/detectCollisions.ts index 7f6df510..2c3c50a6 100644 --- a/src/model/detectCollisions.ts +++ b/src/model/detectCollisions.ts @@ -4,10 +4,10 @@ import { ScreenCoordinates, screenFromTile, TileCoordinates, + tileFromScreen, } from './Coordinates'; import { eatEnergizer } from './eatEnergizer'; -import { Game } from './Game'; -import { Ghost } from './Ghost'; +import { useGameStore } from './store'; import { BASIC_PILL_ID, EMPTY_TILE_ID, ENERGIZER_ID, TileId } from './MazeData'; import { Rectangle } from './Rectangle'; @@ -51,73 +51,90 @@ export const getGhostHitBox = (screen: ScreenCoordinates): Rectangle => { }; }; -const detectPacManEatingPill = (game: Game) => { - const pillTile = game.pacMan.tileCoordinates; - const pill: TileId = game.maze.pills[pillTile.y][pillTile.x]; +const detectPacManEatingPill = () => { + const store = useGameStore.getState(); + const pacMan = store.game.pacMan; + const maze = store.game.maze; + + const pillTile = tileFromScreen(pacMan.screenCoordinates); + const pill: TileId = maze.pills[pillTile.y][pillTile.x]; + if (pill === EMPTY_TILE_ID) { return; } const pillHitBox: Rectangle = getPillHitBox(pillTile, pill); - const pacManHitBox: Rectangle = getPacManHitBox( - game.pacMan.screenCoordinates - ); + const pacManHitBox: Rectangle = getPacManHitBox(pacMan.screenCoordinates); + if (collide(pacManHitBox, pillHitBox)) { - eatPillLayerObject(pillTile, game); + eatPillLayerObject(pillTile, pill); } }; export const BASIC_PILL_POINTS = 10; -const eatPillLayerObject = (tile: TileCoordinates, game: Game) => { - const tileId = game.maze.pills[tile.y][tile.x]; +const eatPillLayerObject = (tile: TileCoordinates, tileId: TileId) => { switch (tileId) { case BASIC_PILL_ID: - eatPill(tile, game); + eatPill(); break; case ENERGIZER_ID: - eatEnergizer(game); + eatEnergizer(); break; default: console.error('Unknown pill layer tile id', tileId); break; } - game.maze.pills[tile.y][tile.x] = EMPTY_TILE_ID; + // Remove the pill from the maze + useGameStore.setState((state) => { + state.game.maze.pills[tile.y][tile.x] = EMPTY_TILE_ID; + }); }; -const eatPill = (tile: TileCoordinates, game: Game) => { - game.score += BASIC_PILL_POINTS; +const eatPill = () => { + useGameStore.setState((state) => { + state.game.score += BASIC_PILL_POINTS; + }); }; -const detectGhostCollisions = (game: Game) => { - const pacManHitBox: Rectangle = getPacManHitBox( - game.pacMan.screenCoordinates - ); +const detectGhostCollisions = () => { + const store = useGameStore.getState(); + const pacMan = store.game.pacMan; + const ghosts = store.game.ghosts; + + const pacManHitBox: Rectangle = getPacManHitBox(pacMan.screenCoordinates); - for (const ghost of game.ghosts) { - if (ghost.dead) { + for (let i = 0; i < ghosts.length; i++) { + const ghost = ghosts[i]; + const isDead = ghost.state === 'dead'; + + if (isDead) { continue; } const ghostHitBox: Rectangle = getGhostHitBox(ghost.screenCoordinates); + if (collide(pacManHitBox, ghostHitBox)) { - ghostCollidesWithPacMan(ghost); + ghostCollidesWithPacMan(i); } } }; -export const ghostCollidesWithPacMan = (ghost: Ghost) => { - const game = ghost.game; - game.pacMan.send('COLLISION_WITH_GHOST'); - ghost.send('COLLISION_WITH_PAC_MAN'); +export const ghostCollidesWithPacMan = (ghostIndex: number) => { + const store = useGameStore.getState(); + store.sendPacManEvent('COLLISION_WITH_GHOST'); + store.sendGhostEvent(ghostIndex, 'COLLISION_WITH_PAC_MAN'); }; -export const detectCollisions = (game: Game) => { - if (game.pacMan.dead) { +export const detectCollisions = () => { + const store = useGameStore.getState(); + const pacManDead = store.game.pacMan.state === 'dead'; + + if (pacManDead) { return; } - detectPacManEatingPill(game); - detectGhostCollisions(game); + detectPacManEatingPill(); + detectGhostCollisions(); }; diff --git a/src/model/eatEnergizer.ts b/src/model/eatEnergizer.ts index 8c5c3164..89cda60e 100644 --- a/src/model/eatEnergizer.ts +++ b/src/model/eatEnergizer.ts @@ -1,13 +1,21 @@ -import { Game } from './Game'; -import { action } from 'mobx'; +import { useGameStore } from './store'; export const ENERGIZER_POINTS = 30; -export const eatEnergizer = action((game: Game) => { - game.score += ENERGIZER_POINTS; - game.killedGhosts = 0; - game.pacMan.send('ENERGIZER_EATEN'); - for (const ghost of game.ghosts) { - ghost.send('ENERGIZER_EATEN'); - } -}); +export const eatEnergizer = () => { + const store = useGameStore.getState(); + + // Update score and reset killed ghosts + useGameStore.setState((state) => { + state.game.score += ENERGIZER_POINTS; + state.game.killedGhosts = 0; + }); + + // Send ENERGIZER_EATEN to PacMan + store.sendPacManEvent('ENERGIZER_EATEN'); + + // Send ENERGIZER_EATEN to all ghosts + store.game.ghosts.forEach((_, index) => { + store.sendGhostEvent(index, 'ENERGIZER_EATEN'); + }); +}; diff --git a/src/model/getGhostDestination.test.ts b/src/model/getGhostDestination.test.ts deleted file mode 100644 index 198958dc..00000000 --- a/src/model/getGhostDestination.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Game } from './Game'; -import { getGhostDestination } from './getGhostDestination'; -import { Store } from './Store'; - -describe('getGhostDestination', () => { - describe('ghost 0', () => { - it('is pac mans position', () => { - const store = new Store(); - const game = new Game(store); - const { pacMan } = game; - pacMan.setTileCoordinates({ x: 3, y: 1 }); - const destination = getGhostDestination({ ghostNumber: 0, game }); - expect(destination).toEqual({ x: 3, y: 1 }); - }); - }); -}); diff --git a/src/model/getGhostDestination.ts b/src/model/getGhostDestination.ts deleted file mode 100644 index e2cf565d..00000000 --- a/src/model/getGhostDestination.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Game } from './Game'; -import { TileCoordinates } from './Coordinates'; - -export const getGhostDestination = ({ - ghostNumber, - game, -}: { - ghostNumber: number; - game: Game; -}): TileCoordinates => { - return game.pacMan.tileCoordinates; -}; diff --git a/src/model/makeGhosts.ts b/src/model/makeGhosts.ts deleted file mode 100644 index c6580ba5..00000000 --- a/src/model/makeGhosts.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Game } from './Game'; -import { Ghost } from './Ghost'; - -export const resetGhosts = (ghosts: Ghost[]) => { - ghosts[0].setTileCoordinates({ x: 12, y: 14 }); - ghosts[0].direction = 'LEFT'; - ghosts[1].setTileCoordinates({ x: 13, y: 14 }); - ghosts[1].direction = 'RIGHT'; - ghosts[2].setTileCoordinates({ x: 14, y: 14 }); - ghosts[3].direction = 'LEFT'; - ghosts[3].setTileCoordinates({ x: 15, y: 14 }); - ghosts[3].direction = 'RIGHT'; - - for (const ghost of ghosts) { - ghost.resetGhost(); - } -}; - -export const makeGhosts = (game: Game): Ghost[] => { - const ghosts: Ghost[] = [ - new Ghost(game), - new Ghost(game), - new Ghost(game), - new Ghost(game), - ]; - - ghosts[0].ghostNumber = 0; - ghosts[0].name = 'Blinky'; - ghosts[0].color = 'red'; - ghosts[0].colorCode = '#ff0000'; - ghosts[0].initialWaitingTimeInBox = 1000; - - ghosts[1].ghostNumber = 1; - ghosts[1].name = 'Pinky'; - ghosts[1].color = 'pink'; - ghosts[1].colorCode = '#fcb5ff'; - ghosts[1].initialWaitingTimeInBox = 1300; - - ghosts[2].ghostNumber = 2; - ghosts[2].name = 'Inky'; - ghosts[2].color = 'blue'; - ghosts[2].colorCode = '#00ffff'; - ghosts[2].initialWaitingTimeInBox = 1600; - - ghosts[3].ghostNumber = 3; - ghosts[3].name = 'Clyde'; - ghosts[3].color = 'orange'; - ghosts[3].colorCode = '#f9ba55'; - ghosts[3].initialWaitingTimeInBox = 1900; - - resetGhosts(ghosts); - - return ghosts; -}; diff --git a/src/model/movePacManBy.test.ts b/src/model/movePacManBy.test.ts deleted file mode 100644 index 128ff5a4..00000000 --- a/src/model/movePacManBy.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Game } from './Game'; -import { SCREEN_TILE_SIZE } from './Coordinates'; -import { Store } from './Store'; -import { movePacManBy } from './movePacManBy'; - -describe('movePacManBy()', () => { - it('advances the screen position', () => { - // Arrange - const store = new Store(); - const game = new Game(store); - const pacMan = game.pacMan; - pacMan.screenCoordinates = { x: 20, y: 20 }; - movePacManBy(pacMan, { x: 2, y: 3 }); - - // Assert - expect(pacMan.screenCoordinates).toEqual({ x: 22, y: 23 }); - }); - - it('handles the tunnel when going RIGHT', () => { - // Arrange - const store = new Store(); - const game = new Game(store); - const pacMan = game.pacMan; - pacMan.setTileCoordinates({ x: 27, y: 14 }); - movePacManBy(pacMan, { x: SCREEN_TILE_SIZE, y: 0 }); - - // Assert - expect(pacMan.tileCoordinates).toEqual({ x: 0, y: 14 }); - }); - - it('handles the tunnel when going LEFT', () => { - // Arrange - const store = new Store(); - const game = new Game(store); - const pacMan = game.pacMan; - pacMan.setTileCoordinates({ x: 0, y: 14 }); - movePacManBy(pacMan, { x: -SCREEN_TILE_SIZE, y: 0 }); - - // Assert - expect(pacMan.tileCoordinates).toEqual({ x: 27, y: 14 }); - }); -}); diff --git a/src/model/movePacManBy.ts b/src/model/movePacManBy.ts deleted file mode 100644 index 3e96abb3..00000000 --- a/src/model/movePacManBy.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { action } from 'mobx'; -import { MAZE_WIDTH_IN_SCREEN_COORDINATES } from './Coordinates'; -import { PacMan } from './PacMan'; -import { MilliSeconds } from './Types'; -import { Vector } from './Vector'; -import { TotalPacManDyingAnimationLength } from './pacManDyingPhase'; - -export const DELAY_TO_REVIVE_PAC_MAN: MilliSeconds = TotalPacManDyingAnimationLength; - -export const movePacManBy = action((pacMan: PacMan, vector: Vector) => { - pacMan.screenCoordinates.x = - (pacMan.screenCoordinates.x + vector.x + MAZE_WIDTH_IN_SCREEN_COORDINATES) % - MAZE_WIDTH_IN_SCREEN_COORDINATES; - pacMan.screenCoordinates.y += vector.y; -}); diff --git a/src/model/onAnimationFrame.ts b/src/model/onAnimationFrame.ts index 27633f74..131c5717 100644 --- a/src/model/onAnimationFrame.ts +++ b/src/model/onAnimationFrame.ts @@ -1,25 +1,43 @@ -import { action } from 'mobx'; +import { useGameStore } from './store'; import { detectCollisions } from './detectCollisions'; -import { Game } from './Game'; import { updateGhosts } from './updateGhosts'; import { updatePacMan } from './updatePacMan'; import { updateEnergizerTimer } from './updateEnergizerTimer'; -import { updateExternalTimestamp } from './updateExternalTimeStamp'; -import { updateGameTimestamp } from './updateGameTimestamp'; -export const onAnimationFrame = action( - 'onAnimationFrame', - ({ game, timestamp }: { game: Game; timestamp: number }) => { - updateExternalTimestamp({ game, externalTimeStamp: timestamp }); +// The typical duration of a frame: 1000ms for 60 frames per second = 17ms. +export const TYPICAL_FRAME_LENGTH = 17; - if (game.gamePaused) { - return; - } +export const onAnimationFrame = (externalTimeStamp: number) => { + const store = useGameStore.getState(); + const game = store.game; - updateGameTimestamp(game); - updateEnergizerTimer(game); - updatePacMan(game); - updateGhosts(game); - detectCollisions(game); + // Update timing + let lastFrameLength: number; + if (game.externalTimeStamp === null) { + lastFrameLength = TYPICAL_FRAME_LENGTH; + } else { + lastFrameLength = externalTimeStamp - game.externalTimeStamp; } -); + + // Batch all timing updates + useGameStore.setState((state) => { + state.game.externalTimeStamp = externalTimeStamp; + state.game.lastFrameLength = lastFrameLength; + }); + + if (game.gamePaused) { + return; + } + + // Update game timestamp + useGameStore.setState((state) => { + state.game.timestamp += state.game.lastFrameLength; + state.game.frameCount++; + }); + + // Update timers and game entities + updateEnergizerTimer(); + updatePacMan(); + updateGhosts(); + detectCollisions(); +}; diff --git a/src/model/pacManDyingPhase.ts b/src/model/pacManDyingPhase.ts index 60bbb005..938d898a 100644 --- a/src/model/pacManDyingPhase.ts +++ b/src/model/pacManDyingPhase.ts @@ -1,5 +1,4 @@ import { MilliSeconds } from './Types'; -import { PacMan } from './PacMan'; export type PacManDyingPhase = number; export const PacManDyingPhaseCount = 13; @@ -10,9 +9,9 @@ export const PacManDyingPhaseLength: MilliSeconds = 200; export const TotalPacManDyingAnimationLength: MilliSeconds = PacManDyingPhaseLength * PacManDyingPhaseCount; -export const getPacManDyingPhase = (pacMan: PacMan): PacManDyingPhase => { +export const getPacManDyingPhase = (timeSinceDeath: MilliSeconds): PacManDyingPhase => { let dyingPhase: number = Math.floor( - pacMan.timeSinceDeath / PacManDyingPhaseLength + timeSinceDeath / PacManDyingPhaseLength ); if (dyingPhase >= PacManDyingPhaseCount) { dyingPhase = PacManDyingPhaseCount - 1; diff --git a/src/model/simulateFrames.ts b/src/model/simulateFrames.ts index 8f97caf1..5be4f6fc 100644 --- a/src/model/simulateFrames.ts +++ b/src/model/simulateFrames.ts @@ -1,43 +1,36 @@ -import { Game } from './Game'; -import { onAnimationFrame } from './onAnimationFrame'; +import { useGameStore } from './store'; +import { onAnimationFrame, TYPICAL_FRAME_LENGTH } from './onAnimationFrame'; import { MilliSeconds } from './Types'; import { SCREEN_TILE_SIZE } from './Coordinates'; -import { TYPICAL_FRAME_LENGTH } from './updateExternalTimeStamp'; -const framesPerTile = (game: Game) => SCREEN_TILE_SIZE / game.speed; +const framesPerTile = () => { + const speed = useGameStore.getState().game.speed; + return SCREEN_TILE_SIZE / speed; +}; -export const simulateFrame = ( - game: Game, - frameLength: MilliSeconds = TYPICAL_FRAME_LENGTH -) => { - const previousTimestamp = game.externalTimeStamp ?? 0; +export const simulateFrame = (frameLength: MilliSeconds = TYPICAL_FRAME_LENGTH) => { + const previousTimestamp = useGameStore.getState().game.externalTimeStamp ?? 0; const timestamp = previousTimestamp + frameLength; - onAnimationFrame({ - game, - timestamp, - }); + onAnimationFrame(timestamp); }; -export const simulateFrames = (numberOfFrames: number, game: Game) => { +export const simulateFrames = (numberOfFrames: number) => { for (let frames = 0; frames < numberOfFrames; frames++) { - simulateFrame(game); + simulateFrame(); } }; -export const simulateFramesToMoveNTiles = ( - numberOfTiles: number, - game: Game -) => { - const numberOfFrames = numberOfTiles * framesPerTile(game); - simulateFrames(numberOfFrames, game); +export const simulateFramesToMoveNTiles = (numberOfTiles: number) => { + const numberOfFrames = numberOfTiles * framesPerTile(); + simulateFrames(numberOfFrames); }; -export const simulateTime = (game: Game, timeToPass: MilliSeconds) => { +export const simulateTime = (timeToPass: MilliSeconds) => { const numberOfFrames = Math.floor(timeToPass / TYPICAL_FRAME_LENGTH); - simulateFrames(numberOfFrames, game); + simulateFrames(numberOfFrames); const passedTime = numberOfFrames * TYPICAL_FRAME_LENGTH; const timeLeft = timeToPass - passedTime; if (timeLeft > 0) { - simulateFrame(game, timeLeft); + simulateFrame(timeLeft); } }; diff --git a/src/model/store/Game.test.ts b/src/model/store/Game.test.ts new file mode 100644 index 00000000..33fa4c27 --- /dev/null +++ b/src/model/store/Game.test.ts @@ -0,0 +1,127 @@ +import { useGameStore } from './gameStore'; +import { DEFAULT_SPEED } from './constants'; +import { SCREEN_TILE_SIZE } from '../Coordinates'; + +describe('Game', () => { + beforeEach(() => { + useGameStore.getState().resetGame(); + }); + + describe('initial state', () => { + it('has initial score of 0', () => { + expect(useGameStore.getState().game.score).toBe(0); + }); + + it('has initial killedGhosts of 0', () => { + expect(useGameStore.getState().game.killedGhosts).toBe(0); + }); + + it('has gamePaused set to false', () => { + expect(useGameStore.getState().game.gamePaused).toBe(false); + }); + + it('has 4 ghosts', () => { + expect(useGameStore.getState().game.ghosts).toHaveLength(4); + }); + + it('has default speed', () => { + expect(useGameStore.getState().game.speed).toBe(DEFAULT_SPEED); + }); + }); + + describe('resetGame', () => { + it('resets score to 0', () => { + useGameStore.getState().addScore(500); + expect(useGameStore.getState().game.score).toBe(500); + + useGameStore.getState().resetGame(); + + expect(useGameStore.getState().game.score).toBe(0); + }); + + it('resets killedGhosts to 0', () => { + const store = useGameStore.getState(); + store.sendGhostEvent(0, 'ENERGIZER_EATEN'); + store.sendGhostEvent(0, 'COLLISION_WITH_PAC_MAN'); + expect(useGameStore.getState().game.killedGhosts).toBe(1); + + store.resetGame(); + + expect(useGameStore.getState().game.killedGhosts).toBe(0); + }); + + it('resets pacMan state to eating', () => { + const store = useGameStore.getState(); + store.sendPacManEvent('COLLISION_WITH_GHOST'); + expect(useGameStore.getState().game.pacMan.state).toBe('dead'); + + store.resetGame(); + + expect(useGameStore.getState().game.pacMan.state).toBe('eating'); + }); + + it('resets ghost states to scatter', () => { + const store = useGameStore.getState(); + store.sendGhostEvent(0, 'PHASE_END'); + expect(useGameStore.getState().game.ghosts[0].state).toBe('chase'); + + store.resetGame(); + + expect(useGameStore.getState().game.ghosts[0].state).toBe('scatter'); + }); + }); + + describe('advanceGame', () => { + it('updates timestamp', () => { + expect(useGameStore.getState().game.timestamp).toBe(0); + + useGameStore.getState().advanceGame(100, 16); + + expect(useGameStore.getState().game.timestamp).toBe(16); + }); + + it('updates lastFrameLength', () => { + useGameStore.getState().advanceGame(100, 16); + + expect(useGameStore.getState().game.lastFrameLength).toBe(16); + }); + + it('increments frameCount', () => { + expect(useGameStore.getState().game.frameCount).toBe(0); + + useGameStore.getState().advanceGame(100, 16); + useGameStore.getState().advanceGame(116, 16); + + expect(useGameStore.getState().game.frameCount).toBe(2); + }); + }); + + describe('addScore', () => { + it('adds points to score', () => { + useGameStore.getState().addScore(10); + expect(useGameStore.getState().game.score).toBe(10); + + useGameStore.getState().addScore(50); + expect(useGameStore.getState().game.score).toBe(60); + }); + }); + + describe('setGamePaused', () => { + it('sets gamePaused to false', () => { + useGameStore.getState().setGamePaused(false); + expect(useGameStore.getState().game.gamePaused).toBe(false); + }); + + it('sets gamePaused to true', () => { + useGameStore.getState().setGamePaused(false); + useGameStore.getState().setGamePaused(true); + expect(useGameStore.getState().game.gamePaused).toBe(true); + }); + }); + + describe('DEFAULT_SPEED', () => { + it('must be a divisor of TILE_SIZE for movement logic', () => { + expect(SCREEN_TILE_SIZE % DEFAULT_SPEED).toBe(0); + }); + }); +}); diff --git a/src/model/store/Ghost.test.ts b/src/model/store/Ghost.test.ts new file mode 100644 index 00000000..b304550e --- /dev/null +++ b/src/model/store/Ghost.test.ts @@ -0,0 +1,164 @@ +import { useGameStore } from './gameStore'; +import { KILL_GHOST_SCORE } from './constants'; + +describe('Ghost state machine', () => { + beforeEach(() => { + useGameStore.getState().resetGame(); + }); + + describe('initial state', () => { + it('starts in scatter state', () => { + const ghost = useGameStore.getState().game.ghosts[0]; + expect(ghost.state).toBe('scatter'); + }); + }); + + describe('ENERGIZER_EATEN event', () => { + it('transitions from scatter to frightened', () => { + const store = useGameStore.getState(); + expect(store.game.ghosts[0].state).toBe('scatter'); + + store.sendGhostEvent(0, 'ENERGIZER_EATEN'); + + expect(useGameStore.getState().game.ghosts[0].state).toBe('frightened'); + }); + + it('transitions from chase to frightened', () => { + const store = useGameStore.getState(); + store.sendGhostEvent(0, 'PHASE_END'); // scatter -> chase + expect(useGameStore.getState().game.ghosts[0].state).toBe('chase'); + + store.sendGhostEvent(0, 'ENERGIZER_EATEN'); + + expect(useGameStore.getState().game.ghosts[0].state).toBe('frightened'); + }); + + it('tracks previous state before frightened', () => { + const store = useGameStore.getState(); + store.sendGhostEvent(0, 'PHASE_END'); // scatter -> chase + store.sendGhostEvent(0, 'ENERGIZER_EATEN'); + + const ghost = useGameStore.getState().game.ghosts[0]; + expect(ghost.state).toBe('frightened'); + expect(ghost.previousStateBeforeFrightened).toBe('chase'); + }); + }); + + describe('PHASE_END event', () => { + it('transitions from scatter to chase', () => { + const store = useGameStore.getState(); + expect(store.game.ghosts[0].state).toBe('scatter'); + + store.sendGhostEvent(0, 'PHASE_END'); + + expect(useGameStore.getState().game.ghosts[0].state).toBe('chase'); + }); + + it('transitions from chase to scatter', () => { + const store = useGameStore.getState(); + store.sendGhostEvent(0, 'PHASE_END'); // scatter -> chase + expect(useGameStore.getState().game.ghosts[0].state).toBe('chase'); + + store.sendGhostEvent(0, 'PHASE_END'); + + expect(useGameStore.getState().game.ghosts[0].state).toBe('scatter'); + }); + }); + + describe('when ghost is killed (COLLISION_WITH_PAC_MAN while frightened)', () => { + beforeEach(() => { + const store = useGameStore.getState(); + store.sendGhostEvent(0, 'ENERGIZER_EATEN'); + expect(useGameStore.getState().game.ghosts[0].state).toBe('frightened'); + }); + + it('transitions to dead state', () => { + useGameStore.getState().sendGhostEvent(0, 'COLLISION_WITH_PAC_MAN'); + + expect(useGameStore.getState().game.ghosts[0].state).toBe('dead'); + }); + + it('increments killedGhosts', () => { + expect(useGameStore.getState().game.killedGhosts).toBe(0); + + useGameStore.getState().sendGhostEvent(0, 'COLLISION_WITH_PAC_MAN'); + + expect(useGameStore.getState().game.killedGhosts).toBe(1); + }); + + it('adds score based on kill count', () => { + expect(useGameStore.getState().game.score).toBe(0); + + useGameStore.getState().sendGhostEvent(0, 'COLLISION_WITH_PAC_MAN'); + + expect(useGameStore.getState().game.score).toBe(KILL_GHOST_SCORE[1]); + }); + + it('score increases 100, 200, 400, 800 for consecutive kills', () => { + const store = useGameStore.getState(); + + // Kill first ghost + store.sendGhostEvent(0, 'COLLISION_WITH_PAC_MAN'); + expect(useGameStore.getState().game.score).toBe(100); + + // Kill second ghost + store.sendGhostEvent(1, 'ENERGIZER_EATEN'); + store.sendGhostEvent(1, 'COLLISION_WITH_PAC_MAN'); + expect(useGameStore.getState().game.score).toBe(100 + 200); + + // Kill third ghost + store.sendGhostEvent(2, 'ENERGIZER_EATEN'); + store.sendGhostEvent(2, 'COLLISION_WITH_PAC_MAN'); + expect(useGameStore.getState().game.score).toBe(100 + 200 + 400); + + // Kill fourth ghost + store.sendGhostEvent(3, 'ENERGIZER_EATEN'); + store.sendGhostEvent(3, 'COLLISION_WITH_PAC_MAN'); + expect(useGameStore.getState().game.score).toBe(100 + 200 + 400 + 800); + }); + + it('sets deadWaitingTimeInBoxLeft', () => { + useGameStore.getState().sendGhostEvent(0, 'COLLISION_WITH_PAC_MAN'); + + expect(useGameStore.getState().game.ghosts[0].deadWaitingTimeInBoxLeft).toBe(3000); + }); + }); + + describe('REVIVED event', () => { + it('transitions from dead to scatter', () => { + const store = useGameStore.getState(); + store.sendGhostEvent(0, 'ENERGIZER_EATEN'); + store.sendGhostEvent(0, 'COLLISION_WITH_PAC_MAN'); + expect(useGameStore.getState().game.ghosts[0].state).toBe('dead'); + + store.sendGhostEvent(0, 'REVIVED'); + + expect(useGameStore.getState().game.ghosts[0].state).toBe('scatter'); + }); + }); + + describe('RESET event', () => { + it('resets ghost to initial scatter state from any state', () => { + const store = useGameStore.getState(); + store.sendGhostEvent(0, 'PHASE_END'); // scatter -> chase + store.sendGhostEvent(0, 'ENERGIZER_EATEN'); // chase -> frightened + expect(useGameStore.getState().game.ghosts[0].state).toBe('frightened'); + + store.sendGhostEvent(0, 'RESET'); + + expect(useGameStore.getState().game.ghosts[0].state).toBe('scatter'); + }); + }); + + describe('ENERGIZER_TIMED_OUT event', () => { + it('transitions from frightened to chase', () => { + const store = useGameStore.getState(); + store.sendGhostEvent(0, 'ENERGIZER_EATEN'); + expect(useGameStore.getState().game.ghosts[0].state).toBe('frightened'); + + store.sendGhostEvent(0, 'ENERGIZER_TIMED_OUT'); + + expect(useGameStore.getState().game.ghosts[0].state).toBe('chase'); + }); + }); +}); diff --git a/src/model/store/PacMan.test.ts b/src/model/store/PacMan.test.ts new file mode 100644 index 00000000..412e32b9 --- /dev/null +++ b/src/model/store/PacMan.test.ts @@ -0,0 +1,113 @@ +import { useGameStore } from './gameStore'; + +describe('PacMan state machine', () => { + beforeEach(() => { + useGameStore.getState().resetGame(); + }); + + describe('initial state', () => { + it('starts in eating state', () => { + const pacMan = useGameStore.getState().game.pacMan; + expect(pacMan.state).toBe('eating'); + }); + }); + + describe('ENERGIZER_EATEN event', () => { + it('transitions from eating to chasing', () => { + const store = useGameStore.getState(); + expect(store.game.pacMan.state).toBe('eating'); + + store.sendPacManEvent('ENERGIZER_EATEN'); + + expect(useGameStore.getState().game.pacMan.state).toBe('chasing'); + }); + + it('starts the energizer timer', () => { + const store = useGameStore.getState(); + expect(store.game.energizerTimer.running).toBe(false); + + store.sendPacManEvent('ENERGIZER_EATEN'); + + const timer = useGameStore.getState().game.energizerTimer; + expect(timer.running).toBe(true); + expect(timer.timeSpent).toBe(0); + }); + }); + + describe('ENERGIZER_TIMED_OUT event', () => { + it('transitions from chasing to eating', () => { + const store = useGameStore.getState(); + store.sendPacManEvent('ENERGIZER_EATEN'); + expect(useGameStore.getState().game.pacMan.state).toBe('chasing'); + + store.sendPacManEvent('ENERGIZER_TIMED_OUT'); + + expect(useGameStore.getState().game.pacMan.state).toBe('eating'); + }); + + it('does nothing in eating state', () => { + const store = useGameStore.getState(); + expect(store.game.pacMan.state).toBe('eating'); + + store.sendPacManEvent('ENERGIZER_TIMED_OUT'); + + expect(useGameStore.getState().game.pacMan.state).toBe('eating'); + }); + }); + + describe('COLLISION_WITH_GHOST event', () => { + it('transitions from eating to dead', () => { + const store = useGameStore.getState(); + expect(store.game.pacMan.state).toBe('eating'); + + store.sendPacManEvent('COLLISION_WITH_GHOST'); + + expect(useGameStore.getState().game.pacMan.state).toBe('dead'); + }); + + it('records diedAtTimestamp', () => { + const store = useGameStore.getState(); + store.advanceGame(0, 1000); // Set timestamp to 1000 + expect(useGameStore.getState().game.timestamp).toBe(1000); + + store.sendPacManEvent('COLLISION_WITH_GHOST'); + + expect(useGameStore.getState().game.pacMan.diedAtTimestamp).toBe(1000); + }); + }); + + describe('REVIVED event', () => { + it('transitions from dead to eating', () => { + const store = useGameStore.getState(); + store.sendPacManEvent('COLLISION_WITH_GHOST'); + expect(useGameStore.getState().game.pacMan.state).toBe('dead'); + + store.sendPacManEvent('REVIVED'); + + expect(useGameStore.getState().game.pacMan.state).toBe('eating'); + }); + }); + + describe('state transitions are idempotent for invalid events', () => { + it('COLLISION_WITH_GHOST does nothing in chasing state', () => { + const store = useGameStore.getState(); + store.sendPacManEvent('ENERGIZER_EATEN'); + expect(useGameStore.getState().game.pacMan.state).toBe('chasing'); + + // PacMan can't die while chasing (eating energizer) - ghosts are frightened + store.sendPacManEvent('COLLISION_WITH_GHOST'); + + expect(useGameStore.getState().game.pacMan.state).toBe('chasing'); + }); + + it('ENERGIZER_EATEN does nothing in dead state', () => { + const store = useGameStore.getState(); + store.sendPacManEvent('COLLISION_WITH_GHOST'); + expect(useGameStore.getState().game.pacMan.state).toBe('dead'); + + store.sendPacManEvent('ENERGIZER_EATEN'); + + expect(useGameStore.getState().game.pacMan.state).toBe('dead'); + }); + }); +}); diff --git a/src/model/store/constants.ts b/src/model/store/constants.ts new file mode 100644 index 00000000..9c9fa2ce --- /dev/null +++ b/src/model/store/constants.ts @@ -0,0 +1,11 @@ +import { MilliSeconds, PixelsPerFrame } from '../Types'; + +export const DEFAULT_SPEED: PixelsPerFrame = 2; + +export const ENERGIZER_DURATION: MilliSeconds = 5000; +export const FRIGHTENED_ABOUT_TO_END_DURATION: MilliSeconds = 3000; +export const DEAD_WAITING_IN_BOX_DURATION: MilliSeconds = 3000; +export const CHASE_PHASE_LENGTH: MilliSeconds = 20 * 1000; +export const SCATTER_PHASE_LENGTH: MilliSeconds = 7 * 1000; + +export const KILL_GHOST_SCORE = [0, 100, 200, 400, 800, 1600, 3200]; diff --git a/src/model/store/gameStore.ts b/src/model/store/gameStore.ts new file mode 100644 index 00000000..9e38ee2c --- /dev/null +++ b/src/model/store/gameStore.ts @@ -0,0 +1,343 @@ +import { create } from 'zustand'; +import { immer } from 'zustand/middleware/immer'; +import { screenFromTile, tileFromScreen } from '../Coordinates'; +import { + Store, + PacManEventType, + GhostEventType, + GhostStateValue, + PacManStateValue, + INITIAL_GHOST_STATE, +} from './types'; +import { + createInitialState, + createPacManState, + createGhostsState, + createMazeState, + createTimerState, +} from './initialState'; +import { KILL_GHOST_SCORE, CHASE_PHASE_LENGTH, SCATTER_PHASE_LENGTH } from './constants'; + +const ENERGIZER_DURATION = 5000; + +export const getStatePhaseLength = (state: GhostStateValue): number => { + switch (state) { + case 'chase': + return CHASE_PHASE_LENGTH; + case 'scatter': + return SCATTER_PHASE_LENGTH; + default: + return 9999999999; + } +}; + +// PacMan state machine transitions +const pacManTransition = ( + currentState: PacManStateValue, + event: PacManEventType +): { nextState: PacManStateValue; action?: 'onChasing' | 'onDead' } | null => { + switch (currentState) { + case 'eating': + switch (event) { + case 'ENERGIZER_EATEN': + return { nextState: 'chasing', action: 'onChasing' }; + case 'COLLISION_WITH_GHOST': + return { nextState: 'dead', action: 'onDead' }; + default: + return null; + } + case 'chasing': + switch (event) { + case 'ENERGIZER_TIMED_OUT': + return { nextState: 'eating' }; + default: + return null; + } + case 'dead': + switch (event) { + case 'REVIVED': + return { nextState: 'eating' }; + default: + return null; + } + default: + return null; + } +}; + +// Ghost state machine transitions +const ghostTransition = ( + currentState: GhostStateValue, + event: GhostEventType +): { nextState: GhostStateValue; action?: 'onScatterToChase' | 'onChaseToScatter' | 'onDead' } | null => { + // Global RESET transition + if (event === 'RESET') { + return { nextState: INITIAL_GHOST_STATE }; + } + + switch (currentState) { + case 'chase': + switch (event) { + case 'ENERGIZER_EATEN': + return { nextState: 'frightened' }; + case 'PHASE_END': + return { nextState: 'scatter', action: 'onChaseToScatter' }; + case 'COLLISION_WITH_PAC_MAN': + return { nextState: 'scatter' }; + default: + return null; + } + case 'scatter': + switch (event) { + case 'ENERGIZER_EATEN': + return { nextState: 'frightened' }; + case 'PHASE_END': + return { nextState: 'chase', action: 'onScatterToChase' }; + case 'COLLISION_WITH_PAC_MAN': + return { nextState: 'scatter' }; + default: + return null; + } + case 'frightened': + switch (event) { + case 'ENERGIZER_TIMED_OUT': + return { nextState: 'chase' }; + case 'COLLISION_WITH_PAC_MAN': + return { nextState: 'dead', action: 'onDead' }; + default: + return null; + } + case 'dead': + switch (event) { + case 'REVIVED': + return { nextState: 'scatter' }; + case 'ENERGIZER_TIMED_OUT': + return { nextState: 'scatter' }; + default: + return null; + } + default: + return null; + } +}; + +export const useGameStore = create()( + immer((set, get) => ({ + ...createInitialState(), + + // Game actions + resetGame: () => + set(state => { + state.game = { + ...createInitialState().game, + pacMan: createPacManState(), + ghosts: createGhostsState(), + maze: createMazeState(), + energizerTimer: createTimerState(ENERGIZER_DURATION), + }; + }), + + setGamePaused: (paused) => + set(state => { + state.game.gamePaused = paused; + }), + + advanceGame: (timestamp, delta) => + set(state => { + if (state.game.externalTimeStamp === null) { + state.game.externalTimeStamp = timestamp; + } + state.game.lastFrameLength = delta; + state.game.timestamp += delta; + state.game.frameCount++; + }), + + // PacMan actions + setPacManDirection: (direction) => + set(state => { + state.game.pacMan.direction = direction; + }), + + setPacManNextDirection: (direction) => + set(state => { + state.game.pacMan.nextDirection = direction; + }), + + setPacManScreenCoordinates: (coords) => + set(state => { + state.game.pacMan.screenCoordinates = coords; + }), + + setPacManTileCoordinates: (tile) => + set(state => { + state.game.pacMan.screenCoordinates = screenFromTile(tile); + }), + + sendPacManEvent: (event) => + set(state => { + const result = pacManTransition(state.game.pacMan.state, event); + if (result) { + state.game.pacMan.state = result.nextState; + + // Handle actions + if (result.action === 'onChasing') { + state.game.energizerTimer.running = true; + state.game.energizerTimer.timeSpent = 0; + } else if (result.action === 'onDead') { + state.game.pacMan.diedAtTimestamp = state.game.timestamp; + } + } + }), + + // Ghost actions + sendGhostEvent: (ghostIndex, event) => + set(state => { + const ghost = state.game.ghosts[ghostIndex]; + + // Track state before frightened for returning after + if (event === 'ENERGIZER_EATEN' && (ghost.state === 'chase' || ghost.state === 'scatter')) { + ghost.previousStateBeforeFrightened = ghost.state; + } + + const result = ghostTransition(ghost.state, event); + if (result) { + ghost.state = result.nextState; + ghost.stateChanges++; + + // Handle actions + if (result.action === 'onDead') { + state.game.killedGhosts++; + state.game.score += KILL_GHOST_SCORE[state.game.killedGhosts] || 0; + ghost.deadWaitingTimeInBoxLeft = 3000; // DEAD_WAITING_IN_BOX_DURATION + } + // onScatterToChase and onChaseToScatter trigger direction change + // This is handled by the caller (changeDirectionToOpposite) + } + }), + + setGhostScreenCoordinates: (ghostIndex, coords) => + set(state => { + state.game.ghosts[ghostIndex].screenCoordinates = coords; + }), + + setGhostDirection: (ghostIndex, direction) => + set(state => { + state.game.ghosts[ghostIndex].direction = direction; + }), + + setGhostTargetTile: (ghostIndex, tile) => + set(state => { + state.game.ghosts[ghostIndex].targetTile = tile; + }), + + setGhostPaused: (ghostIndex, paused) => + set(state => { + state.game.ghosts[ghostIndex].ghostPaused = paused; + }), + + resetGhost: (ghostIndex) => + set(state => { + const ghost = state.game.ghosts[ghostIndex]; + ghost.ghostPaused = false; + ghost.state = INITIAL_GHOST_STATE; + ghost.statePhaseTimer.duration = getStatePhaseLength(INITIAL_GHOST_STATE); + ghost.statePhaseTimer.running = true; + ghost.statePhaseTimer.timeSpent = 0; + ghost.stateChanges++; + }), + + // Timer actions + startEnergizerTimer: () => + set(state => { + state.game.energizerTimer.running = true; + state.game.energizerTimer.timeSpent = 0; + }), + + advanceEnergizerTimer: (delta) => + set(state => { + const timer = state.game.energizerTimer; + if (!timer.running) return; + timer.timeSpent += delta; + }), + + stopEnergizerTimer: () => + set(state => { + state.game.energizerTimer.running = false; + }), + + advanceGhostStatePhaseTimer: (ghostIndex, delta) => + set(state => { + const timer = state.game.ghosts[ghostIndex].statePhaseTimer; + if (!timer.running) return; + timer.timeSpent += delta; + }), + + restartGhostStatePhaseTimer: (ghostIndex, duration) => + set(state => { + const timer = state.game.ghosts[ghostIndex].statePhaseTimer; + timer.duration = duration; + timer.running = true; + timer.timeSpent = 0; + }), + + // Maze actions + setPill: (x, y, value) => + set(state => { + state.game.maze.pills[y][x] = value; + }), + + // Score actions + addScore: (points) => + set(state => { + state.game.score += points; + }), + + incrementKilledGhosts: () => + set(state => { + state.game.killedGhosts++; + }), + + // Debug actions + setGameViewOption: (key, value) => + set(state => { + state.debugState.gameViewOptions[key] = value; + }), + + setGhostViewOption: (key, value) => + set(state => { + state.debugState.ghostViewOptions[key] = value; + }), + + setPacManViewOption: (key, value) => + set(state => { + state.debugState.pacManViewOptions[key] = value; + }), + })) +); + +// Selectors for computed values +export const selectPacManDead = (state: Store) => state.game.pacMan.state === 'dead'; +export const selectPacManAlive = (state: Store) => state.game.pacMan.state !== 'dead'; +export const selectPacManChasing = (state: Store) => state.game.pacMan.state === 'chasing'; +export const selectGameOver = (state: Store) => + state.game.pacMan.state === 'dead' && state.game.pacMan.extraLivesLeft === 0; +export const selectPacManTileCoordinates = (state: Store) => + tileFromScreen(state.game.pacMan.screenCoordinates); +export const selectPacManTimeSinceDeath = (state: Store) => { + if (state.game.pacMan.state !== 'dead') return 0; + return state.game.timestamp - state.game.pacMan.diedAtTimestamp; +}; +export const selectEnergizerTimeLeft = (state: Store) => + state.game.energizerTimer.duration - state.game.energizerTimer.timeSpent; +export const selectEnergizerTimedOut = (state: Store) => + state.game.energizerTimer.timeSpent >= state.game.energizerTimer.duration; + +// Ghost selectors +export const selectGhostDead = (state: Store, ghostIndex: number) => + state.game.ghosts[ghostIndex].state === 'dead'; +export const selectGhostAlive = (state: Store, ghostIndex: number) => + state.game.ghosts[ghostIndex].state !== 'dead'; +export const selectGhostFrightened = (state: Store, ghostIndex: number) => + state.game.ghosts[ghostIndex].state === 'frightened'; +export const selectGhostTileCoordinates = (state: Store, ghostIndex: number) => + tileFromScreen(state.game.ghosts[ghostIndex].screenCoordinates); diff --git a/src/model/store/ghostHelpers.ts b/src/model/store/ghostHelpers.ts new file mode 100644 index 00000000..c06ec478 --- /dev/null +++ b/src/model/store/ghostHelpers.ts @@ -0,0 +1,136 @@ +import { TileCoordinates, tileFromScreen } from '../Coordinates'; +import { isTileCenter, isTileInBox as isTileInBoxWalls, isTileInBoxSpace } from '../Ways'; +import { useGameStore } from './gameStore'; +import { GhostState, GhostStateValue, PacManState } from './types'; + +// Helper to compute tile coordinates from screen coordinates +export const getGhostTileCoordinates = (ghost: GhostState): TileCoordinates => { + return tileFromScreen(ghost.screenCoordinates); +}; + +// Helper to check if ghost is at tile center +export const isGhostAtTileCenter = (ghost: GhostState): boolean => { + return isTileCenter(ghost.screenCoordinates); +}; + +// Helper to check if ghost is inside box walls +export const isGhostInsideBoxWalls = (ghost: GhostState): boolean => { + return isTileInBoxWalls(getGhostTileCoordinates(ghost)); +}; + +// Helper to check if ghost is outside box space +export const isGhostOutsideBoxSpace = (ghost: GhostState): boolean => { + return !isTileInBoxSpace(getGhostTileCoordinates(ghost)); +}; + +// Helper to check if ghost can pass through box door +export const canGhostPassThroughBoxDoor = (ghost: GhostState, timestamp: number): boolean => { + const isDead = ghost.state === 'dead'; + const isAlive = !isDead; + const isInsideBoxWalls = isGhostInsideBoxWalls(ghost); + const isOutsideBoxSpace = isGhostOutsideBoxSpace(ghost); + + if (isAlive) { + if (isInsideBoxWalls) { + if (timestamp > ghost.initialWaitingTimeInBox) { + return true; + } + } + } + + if (isDead) { + if (isOutsideBoxSpace) { + return true; + } + + // Dead && Inside box + if (ghost.deadWaitingTimeInBoxLeft <= 0) { + return true; + } + } + + return false; +}; + +// Helper to get PacMan tile coordinates +export const getPacManTileCoordinates = (pacMan: PacManState): TileCoordinates => { + return tileFromScreen(pacMan.screenCoordinates); +}; + +// Interface for ghost data needed by game logic functions +export interface GhostData { + state: GhostStateValue; + ghostNumber: number; + screenCoordinates: { x: number; y: number }; + direction: 'UP' | 'DOWN' | 'LEFT' | 'RIGHT'; + targetTile: TileCoordinates; + ghostPaused: boolean; + speedFactor: number; + deadWaitingTimeInBoxLeft: number; + initialWaitingTimeInBox: number; + statePhaseTimer: { running: boolean; timeSpent: number; duration: number }; + // Computed + tileCoordinates: TileCoordinates; + atTileCenter: boolean; + isInsideBoxWalls: boolean; + isOutsideBoxSpace: boolean; + dead: boolean; + alive: boolean; + frightened: boolean; + canPassThroughBoxDoor: boolean; +} + +// Create a GhostData object from Zustand state +export const createGhostData = (ghostIndex: number): GhostData => { + const store = useGameStore.getState(); + const ghost = store.game.ghosts[ghostIndex]; + const timestamp = store.game.timestamp; + + return { + state: ghost.state, + ghostNumber: ghost.ghostNumber, + screenCoordinates: ghost.screenCoordinates, + direction: ghost.direction, + targetTile: ghost.targetTile, + ghostPaused: ghost.ghostPaused, + speedFactor: ghost.speedFactor, + deadWaitingTimeInBoxLeft: ghost.deadWaitingTimeInBoxLeft, + initialWaitingTimeInBox: ghost.initialWaitingTimeInBox, + statePhaseTimer: ghost.statePhaseTimer, + // Computed + tileCoordinates: getGhostTileCoordinates(ghost), + atTileCenter: isGhostAtTileCenter(ghost), + isInsideBoxWalls: isGhostInsideBoxWalls(ghost), + isOutsideBoxSpace: isGhostOutsideBoxSpace(ghost), + dead: ghost.state === 'dead', + alive: ghost.state !== 'dead', + frightened: ghost.state === 'frightened', + canPassThroughBoxDoor: canGhostPassThroughBoxDoor(ghost, timestamp), + }; +}; + +// Interface for PacMan data +export interface PacManData { + state: 'eating' | 'chasing' | 'dead'; + screenCoordinates: { x: number; y: number }; + direction: 'UP' | 'DOWN' | 'LEFT' | 'RIGHT'; + // Computed + tileCoordinates: TileCoordinates; + dead: boolean; + alive: boolean; +} + +// Create a PacManData object from Zustand state +export const createPacManData = (): PacManData => { + const store = useGameStore.getState(); + const pacMan = store.game.pacMan; + + return { + state: pacMan.state, + screenCoordinates: pacMan.screenCoordinates, + direction: pacMan.direction, + tileCoordinates: getPacManTileCoordinates(pacMan), + dead: pacMan.state === 'dead', + alive: pacMan.state !== 'dead', + }; +}; diff --git a/src/model/store/index.ts b/src/model/store/index.ts new file mode 100644 index 00000000..511cdb03 --- /dev/null +++ b/src/model/store/index.ts @@ -0,0 +1,5 @@ +export * from './types'; +export * from './constants'; +export * from './gameStore'; +export * from './initialState'; +export * from './ghostHelpers'; diff --git a/src/model/store/initialState.ts b/src/model/store/initialState.ts new file mode 100644 index 00000000..261648d0 --- /dev/null +++ b/src/model/store/initialState.ts @@ -0,0 +1,164 @@ +import { screenFromTile } from '../Coordinates'; +import { getPillsMatrix } from '../MazeData'; +import { + StoreState, + GameState, + PacManState, + GhostState, + MazeState, + DebugState, + TimerState, + INITIAL_PACMAN_STATE, + INITIAL_GHOST_STATE, + GhostNumber, + GhostStateValue, +} from './types'; +import { + DEFAULT_SPEED, + CHASE_PHASE_LENGTH, + SCATTER_PHASE_LENGTH, +} from './constants'; + +// Local helper to avoid circular dependency with gameStore +const getStatePhaseLength = (state: GhostStateValue): number => { + switch (state) { + case 'chase': + return CHASE_PHASE_LENGTH; + case 'scatter': + return SCATTER_PHASE_LENGTH; + default: + return 9999999999; + } +}; + +const ENERGIZER_DURATION = 5000; + +export const createTimerState = (duration: number): TimerState => ({ + running: false, + timeSpent: 0, + duration, +}); + +export const createPacManState = (): PacManState => ({ + state: INITIAL_PACMAN_STATE, + screenCoordinates: screenFromTile({ x: 14, y: 23 }), + direction: 'LEFT', + nextDirection: 'LEFT', + diedAtTimestamp: -1, + extraLivesLeft: 2, +}); + +const GHOST_CONFIGS: Array<{ + ghostNumber: GhostNumber; + name: string; + color: string; + colorCode: string; + initialWaitingTimeInBox: number; + initialTile: { x: number; y: number }; + initialDirection: 'LEFT' | 'RIGHT'; +}> = [ + { + ghostNumber: 0, + name: 'Blinky', + color: 'red', + colorCode: '#ff0000', + initialWaitingTimeInBox: 1000, + initialTile: { x: 12, y: 14 }, + initialDirection: 'LEFT', + }, + { + ghostNumber: 1, + name: 'Pinky', + color: 'pink', + colorCode: '#fcb5ff', + initialWaitingTimeInBox: 1300, + initialTile: { x: 13, y: 14 }, + initialDirection: 'RIGHT', + }, + { + ghostNumber: 2, + name: 'Inky', + color: 'blue', + colorCode: '#00ffff', + initialWaitingTimeInBox: 1600, + initialTile: { x: 14, y: 14 }, + initialDirection: 'LEFT', + }, + { + ghostNumber: 3, + name: 'Clyde', + color: 'orange', + colorCode: '#f9ba55', + initialWaitingTimeInBox: 1900, + initialTile: { x: 15, y: 14 }, + initialDirection: 'RIGHT', + }, +]; + +export const createGhostState = (ghostNumber: GhostNumber): GhostState => { + const config = GHOST_CONFIGS[ghostNumber]; + const initialPhaseDuration = getStatePhaseLength(INITIAL_GHOST_STATE); + return { + state: INITIAL_GHOST_STATE, + previousStateBeforeFrightened: 'scatter', + ghostNumber: config.ghostNumber, + name: config.name, + color: config.color, + colorCode: config.colorCode, + screenCoordinates: screenFromTile(config.initialTile), + direction: config.initialDirection, + targetTile: { x: 1, y: 1 }, + ghostPaused: false, + speedFactor: 1, + deadWaitingTimeInBoxLeft: 0, + initialWaitingTimeInBox: config.initialWaitingTimeInBox, + stateChanges: 0, + statePhaseTimer: createTimerState(initialPhaseDuration), + }; +}; + +export const createGhostsState = (): GhostState[] => { + const ghosts = GHOST_CONFIGS.map((_, i) => createGhostState(i as GhostNumber)); + // Start all state phase timers + ghosts.forEach(ghost => { + ghost.statePhaseTimer.running = true; + }); + return ghosts; +}; + +export const createMazeState = (): MazeState => ({ + pills: getPillsMatrix(), +}); + +export const createDebugState = (): DebugState => ({ + gameViewOptions: { + hitBox: false, + }, + ghostViewOptions: { + target: false, + wayPoints: false, + }, + pacManViewOptions: { + somePlaceholder: false, + }, +}); + +export const createGameState = (): GameState => ({ + externalTimeStamp: null, + timestamp: 0, + lastFrameLength: 17, + frameCount: 0, + gamePaused: false, + speed: DEFAULT_SPEED, + score: 0, + killedGhosts: 0, + pacMan: createPacManState(), + ghosts: createGhostsState(), + maze: createMazeState(), + energizerTimer: createTimerState(ENERGIZER_DURATION), +}); + +export const createInitialState = (): StoreState => ({ + game: createGameState(), + debugState: createDebugState(), +}); diff --git a/src/model/store/types.ts b/src/model/store/types.ts new file mode 100644 index 00000000..9a880654 --- /dev/null +++ b/src/model/store/types.ts @@ -0,0 +1,150 @@ +import { TileId } from '../MazeData'; +import { Direction, MilliSeconds, PixelsPerFrame } from '../Types'; +import { ScreenCoordinates, TileCoordinates } from '../Coordinates'; +import { GhostViewOptions } from '../GhostViewOptions'; +import { PacManViewOptions } from '../../pages/GamePage/components/PacManViewOptions'; +import { GameViewOptions } from '../GameViewOptions'; + +// PacMan State Machine +export type PacManStateValue = 'eating' | 'chasing' | 'dead'; +export const INITIAL_PACMAN_STATE: PacManStateValue = 'eating'; + +export type PacManEventType = + | 'ENERGIZER_EATEN' + | 'ENERGIZER_TIMED_OUT' + | 'COLLISION_WITH_GHOST' + | 'REVIVED'; + +// Ghost State Machine +export type GhostStateValue = 'chase' | 'scatter' | 'frightened' | 'dead'; +export const INITIAL_GHOST_STATE: GhostStateValue = 'scatter'; + +export type GhostEventType = + | 'RESET' + | 'ENERGIZER_EATEN' + | 'ENERGIZER_TIMED_OUT' + | 'PHASE_END' + | 'COLLISION_WITH_PAC_MAN' + | 'REVIVED'; + +// Ghost types +export type GhostNumber = 0 | 1 | 2 | 3; +export const GhostNumbers: GhostNumber[] = [0, 1, 2, 3]; +export type GhostAnimationPhase = 0 | 1; +export const GhostAnimationPhases: GhostAnimationPhase[] = [0, 1]; +export type FrightenedGhostTime = 0 | 1; +export const FrightenedGhostTimes: FrightenedGhostTime[] = [0, 1]; + +// Timer state +export interface TimerState { + running: boolean; + timeSpent: MilliSeconds; + duration: MilliSeconds; +} + +// PacMan state +export interface PacManState { + state: PacManStateValue; + screenCoordinates: ScreenCoordinates; + direction: Direction; + nextDirection: Direction; + diedAtTimestamp: MilliSeconds; + extraLivesLeft: number; +} + +// Ghost state +export interface GhostState { + state: GhostStateValue; + previousStateBeforeFrightened: 'chase' | 'scatter'; + ghostNumber: GhostNumber; + name: string; + color: string; + colorCode: string; + screenCoordinates: ScreenCoordinates; + direction: Direction; + targetTile: TileCoordinates; + ghostPaused: boolean; + speedFactor: number; + deadWaitingTimeInBoxLeft: MilliSeconds; + initialWaitingTimeInBox: number; + stateChanges: number; + statePhaseTimer: TimerState; +} + +// Maze state +export interface MazeState { + pills: TileId[][]; +} + +// Debug state +export interface DebugState { + gameViewOptions: GameViewOptions; + ghostViewOptions: GhostViewOptions; + pacManViewOptions: PacManViewOptions; +} + +// Game state +export interface GameState { + externalTimeStamp: MilliSeconds | null; + timestamp: MilliSeconds; + lastFrameLength: MilliSeconds; + frameCount: number; + gamePaused: boolean; + speed: PixelsPerFrame; + score: number; + killedGhosts: number; + pacMan: PacManState; + ghosts: GhostState[]; + maze: MazeState; + energizerTimer: TimerState; +} + +// Root store state +export interface StoreState { + game: GameState; + debugState: DebugState; +} + +// Store actions +export interface StoreActions { + // Game actions + resetGame: () => void; + setGamePaused: (paused: boolean) => void; + advanceGame: (timestamp: MilliSeconds, delta: MilliSeconds) => void; + + // PacMan actions + setPacManDirection: (direction: Direction) => void; + setPacManNextDirection: (direction: Direction) => void; + setPacManScreenCoordinates: (coords: ScreenCoordinates) => void; + setPacManTileCoordinates: (tile: TileCoordinates) => void; + sendPacManEvent: (event: PacManEventType) => void; + + // Ghost actions + sendGhostEvent: (ghostIndex: number, event: GhostEventType) => void; + setGhostScreenCoordinates: (ghostIndex: number, coords: ScreenCoordinates) => void; + setGhostDirection: (ghostIndex: number, direction: Direction) => void; + setGhostTargetTile: (ghostIndex: number, tile: TileCoordinates) => void; + setGhostPaused: (ghostIndex: number, paused: boolean) => void; + resetGhost: (ghostIndex: number) => void; + + // Timer actions + startEnergizerTimer: () => void; + advanceEnergizerTimer: (delta: MilliSeconds) => void; + stopEnergizerTimer: () => void; + advanceGhostStatePhaseTimer: (ghostIndex: number, delta: MilliSeconds) => void; + restartGhostStatePhaseTimer: (ghostIndex: number, duration: MilliSeconds) => void; + + // Maze actions + setPill: (x: number, y: number, value: TileId) => void; + + // Score actions + addScore: (points: number) => void; + incrementKilledGhosts: () => void; + + // Debug actions + setGameViewOption: (key: K, value: GameViewOptions[K]) => void; + setGhostViewOption: (key: K, value: GhostViewOptions[K]) => void; + setPacManViewOption: (key: K, value: PacManViewOptions[K]) => void; +} + +export type Store = StoreState & StoreActions; diff --git a/src/model/updateEnergizerTimer.ts b/src/model/updateEnergizerTimer.ts index efb01a1d..54869879 100644 --- a/src/model/updateEnergizerTimer.ts +++ b/src/model/updateEnergizerTimer.ts @@ -1,5 +1,42 @@ -import { Game } from './Game'; +import { useGameStore } from './store'; -export const updateEnergizerTimer = (game: Game) => { - game.energizerTimer.advance(game.lastFrameLength); +export const updateEnergizerTimer = () => { + const store = useGameStore.getState(); + const timer = store.game.energizerTimer; + + if (!timer.running) { + return; + } + + const lastFrameLength = store.game.lastFrameLength; + + useGameStore.setState((state) => { + state.game.energizerTimer.timeSpent += lastFrameLength; + }); + + // Check if timed out after the update + const updatedStore = useGameStore.getState(); + const updatedTimer = updatedStore.game.energizerTimer; + + if (updatedTimer.timeSpent >= updatedTimer.duration) { + // Handle energizer timed out + handleEnergizerTimedOut(); + + // Stop the timer + useGameStore.setState((state) => { + state.game.energizerTimer.running = false; + }); + } +}; + +const handleEnergizerTimedOut = () => { + const store = useGameStore.getState(); + + // Send ENERGIZER_TIMED_OUT to PacMan + store.sendPacManEvent('ENERGIZER_TIMED_OUT'); + + // Send ENERGIZER_TIMED_OUT to all ghosts + store.game.ghosts.forEach((_, index) => { + store.sendGhostEvent(index, 'ENERGIZER_TIMED_OUT'); + }); }; diff --git a/src/model/updateExternalTimeStamp.ts b/src/model/updateExternalTimeStamp.ts deleted file mode 100644 index 2f305402..00000000 --- a/src/model/updateExternalTimeStamp.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Game } from './Game'; -import { MilliSeconds } from './Types'; - -// The typical duration of a frame: 1000ms for 60 frames per second = 17ms. -export const TYPICAL_FRAME_LENGTH: MilliSeconds = 17; - -export const updateExternalTimestamp = ({ - game, - externalTimeStamp, -}: { - game: Game; - externalTimeStamp: number; -}) => { - if (game.externalTimeStamp === null) { - // The very first frame - // We cannot measure its duration. Therefore we have to make an assumption. - game.lastFrameLength = TYPICAL_FRAME_LENGTH; - } else { - // A later frame. - // We can calculate its duration. - game.lastFrameLength = externalTimeStamp - game.externalTimeStamp; - } - game.externalTimeStamp = externalTimeStamp; -}; diff --git a/src/model/updateGameTimestamp.ts b/src/model/updateGameTimestamp.ts deleted file mode 100644 index 31a6d518..00000000 --- a/src/model/updateGameTimestamp.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Game } from './Game'; - -export const updateGameTimestamp = (game: Game) => { - game.timestamp += game.lastFrameLength; - game.frameCount++; -}; diff --git a/src/model/updateGhostStatePhase.ts b/src/model/updateGhostStatePhase.ts deleted file mode 100644 index ba7fb5f4..00000000 --- a/src/model/updateGhostStatePhase.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { action } from 'mobx'; -import { Ghost } from './Ghost'; -import { MilliSeconds } from './Types'; -import { StateValue } from 'xstate'; - -export const CHASE_PHASE_LENGTH = 20 * 1000; -export const SCATTER_PHASE_LENGTH = 7 * 1000; - -export const updateGhostStatePhaseTime = action( - 'updateGhostStatePhaseTime', - (ghost: Ghost) => { - ghost.statePhaseTimer.advance(ghost.game.lastFrameLength); - } -); - -export const updateGhostStatePhase = action( - 'updateGhostStatePhase', - (ghost: Ghost) => { - if (!ghost.atTileCenter) { - return; - } - - if (ghost.statePhaseTimer.isTimedOut) { - ghost.send('PHASE_END'); - ghost.statePhaseTimer.setDuration(getStatePhaseLength(ghost.state)); - ghost.statePhaseTimer.restart(); - } - } -); - -export const getStatePhaseLength = (state: StateValue): MilliSeconds => { - switch (state) { - case 'chase': - return CHASE_PHASE_LENGTH; - case 'scatter': - return SCATTER_PHASE_LENGTH; - default: - // Never ends - return 9999999999; - } -}; diff --git a/src/model/updateGhosts.test.ts b/src/model/updateGhosts.test.ts index 2024e528..7e3f6dec 100644 --- a/src/model/updateGhosts.test.ts +++ b/src/model/updateGhosts.test.ts @@ -1,4 +1,3 @@ -import { Game } from './Game'; import { onAnimationFrame } from './onAnimationFrame'; import { getNewDirection, @@ -7,220 +6,257 @@ import { SPEED_FACTOR_SLOW, } from './updateGhosts'; import { simulateFramesToMoveNTiles, simulateFrames } from './simulateFrames'; -import { Store } from './Store'; import { TILE_FOR_RETURNING_TO_BOX } from './chooseNewTargetTile'; -import { SCREEN_TILE_SIZE } from './Coordinates'; +import { SCREEN_TILE_SIZE, screenFromTile, tileFromScreen } from './Coordinates'; +import { useGameStore, createGhostData, createInitialState } from './store'; const MILLISECONDS_PER_FRAME = 17; +const resetStore = () => { + useGameStore.setState(createInitialState()); +}; + describe('updateGhost', () => { + beforeEach(() => { + resetStore(); + }); + describe('getNewDirection()', () => { it('returns the new direction to take', () => { - const store = new Store(); - const game = new Game(store); + const store = useGameStore.getState(); - const ghost = game.ghosts[0]; - ghost.send('PHASE_END'); + // Set ghost 0 to chase state + store.sendGhostEvent(0, 'PHASE_END'); + let ghost = useGameStore.getState().game.ghosts[0]; expect(ghost.state).toBe('chase'); - ghost.targetTile = { x: 6, y: 1 }; - ghost.setTileCoordinates({ x: 1, y: 1 }); - ghost.direction = 'UP'; - expect(getNewDirection(ghost)).toBe('RIGHT'); + + // Set target tile and position + useGameStore.setState((state) => { + state.game.ghosts[0].targetTile = { x: 6, y: 1 }; + state.game.ghosts[0].screenCoordinates = screenFromTile({ x: 1, y: 1 }); + state.game.ghosts[0].direction = 'UP'; + }); + + const ghostData = createGhostData(0); + expect(getNewDirection(ghostData)).toBe('RIGHT'); }); it('walks throught the tunnel to the RIGHT', () => { // Arrange + const store = useGameStore.getState(); - const store = new Store(); - const game = new Game(store); - - const ghost = game.ghosts[0]; - ghost.send('PHASE_END'); + store.sendGhostEvent(0, 'PHASE_END'); + let ghost = useGameStore.getState().game.ghosts[0]; expect(ghost.state).toBe('chase'); - ghost.targetTile = { x: 4, y: 14 }; - ghost.setTileCoordinates({ x: 27, y: 14 }); - ghost.direction = 'RIGHT'; + + useGameStore.setState((state) => { + state.game.ghosts[0].targetTile = { x: 4, y: 14 }; + state.game.ghosts[0].screenCoordinates = screenFromTile({ x: 27, y: 14 }); + state.game.ghosts[0].direction = 'RIGHT'; + }); // Act / Asset - expect(getNewDirection(ghost)).toBe('RIGHT'); + let ghostData = createGhostData(0); + expect(getNewDirection(ghostData)).toBe('RIGHT'); // Arrange - - ghost.setTileCoordinates({ x: 26, y: 14 }); - ghost.direction = 'RIGHT'; - expect(getNewDirection(ghost)).toBe('RIGHT'); + useGameStore.setState((state) => { + state.game.ghosts[0].screenCoordinates = screenFromTile({ x: 26, y: 14 }); + state.game.ghosts[0].direction = 'RIGHT'; + }); + ghostData = createGhostData(0); + expect(getNewDirection(ghostData)).toBe('RIGHT'); }); it('walks throught the tunnel to the LEFT', () => { // Arrange + const store = useGameStore.getState(); - const store = new Store(); - const game = new Game(store); - - const ghost = game.ghosts[0]; - ghost.send('PHASE_END'); + store.sendGhostEvent(0, 'PHASE_END'); + let ghost = useGameStore.getState().game.ghosts[0]; expect(ghost.state).toBe('chase'); - ghost.targetTile = { x: 26, y: 14 }; - ghost.setTileCoordinates({ x: 0, y: 14 }); - ghost.direction = 'LEFT'; + + useGameStore.setState((state) => { + state.game.ghosts[0].targetTile = { x: 26, y: 14 }; + state.game.ghosts[0].screenCoordinates = screenFromTile({ x: 0, y: 14 }); + state.game.ghosts[0].direction = 'LEFT'; + }); // Act / Asset - expect(getNewDirection(ghost)).toBe('LEFT'); + let ghostData = createGhostData(0); + expect(getNewDirection(ghostData)).toBe('LEFT'); // Arrange - - ghost.setTileCoordinates({ x: 1, y: 14 }); - ghost.direction = 'LEFT'; - expect(getNewDirection(ghost)).toBe('LEFT'); + useGameStore.setState((state) => { + state.game.ghosts[0].screenCoordinates = screenFromTile({ x: 1, y: 14 }); + state.game.ghosts[0].direction = 'LEFT'; + }); + ghostData = createGhostData(0); + expect(getNewDirection(ghostData)).toBe('LEFT'); }); }); describe('updateGhost()', () => { it('advances ghost positions', () => { // Arrange + useGameStore.setState((state) => { + state.game.pacMan.screenCoordinates = screenFromTile({ x: 1, y: 1 }); + state.game.pacMan.direction = 'LEFT'; + state.game.pacMan.nextDirection = 'LEFT'; + }); - const store = new Store(); - const game = new Game(store); + const pacMan = useGameStore.getState().game.pacMan; + expect(pacMan.screenCoordinates.x).toBe(20); - game.pacMan.setTileCoordinates({ x: 1, y: 1 }); - expect(game.pacMan.screenCoordinates.x).toBe(20); - game.pacMan.direction = 'LEFT'; - game.pacMan.nextDirection = 'LEFT'; + const store = useGameStore.getState(); + store.sendGhostEvent(0, 'PHASE_END'); + expect(useGameStore.getState().game.ghosts[0].state).toBe('chase'); - const ghost = game.ghosts[0]; + useGameStore.setState((state) => { + state.game.ghosts[0].screenCoordinates = screenFromTile({ x: 1, y: 3 }); + state.game.ghosts[0].direction = 'UP'; + }); - ghost.send('PHASE_END'); - expect(ghost.state).toBe('chase'); - ghost.setTileCoordinates({ x: 1, y: 3 }); - ghost.direction = 'UP'; + const ghost = useGameStore.getState().game.ghosts[0]; expect(ghost.screenCoordinates).toEqual({ x: 20, y: 60 }); // Act - simulateFrames(1, game); + simulateFrames(1); // Assert - expect(ghost.screenCoordinates).toEqual({ x: 20, y: 58 }); + const updatedGhost = useGameStore.getState().game.ghosts[0]; + expect(updatedGhost.screenCoordinates).toEqual({ x: 20, y: 58 }); }); describe('in chase state', () => { it('lets ghost 0 chase pacman', () => { // Arrange + useGameStore.setState((state) => { + state.game.pacMan.screenCoordinates = screenFromTile({ x: 1, y: 8 }); + state.game.pacMan.direction = 'LEFT'; + state.game.pacMan.nextDirection = 'LEFT'; + }); - const store = new Store(); - const game = new Game(store); - - game.pacMan.setTileCoordinates({ x: 1, y: 8 }); + const store = useGameStore.getState(); + store.sendGhostEvent(0, 'PHASE_END'); - game.pacMan.direction = 'LEFT'; - game.pacMan.nextDirection = 'LEFT'; + useGameStore.setState((state) => { + state.game.ghosts[0].direction = 'LEFT'; + state.game.ghosts[0].screenCoordinates = screenFromTile({ x: 3, y: 5 }); + }); - const ghost = game.ghosts[0]; - ghost.send('PHASE_END'); - ghost.direction = 'LEFT'; + let ghost = useGameStore.getState().game.ghosts[0]; expect(ghost.state).toBe('chase'); - - ghost.setTileCoordinates({ x: 3, y: 5 }); - expect(ghost.direction).toBe('LEFT'); // Act - simulateFramesToMoveNTiles(1, game); + simulateFramesToMoveNTiles(1); + ghost = useGameStore.getState().game.ghosts[0]; expect(ghost.direction).toBe('LEFT'); - expect(ghost.tileCoordinates).toEqual({ x: 2, y: 5 }); + expect(tileFromScreen(ghost.screenCoordinates)).toEqual({ x: 2, y: 5 }); expect(ghost.targetTile).toEqual({ x: 1, y: 8 }); - expect(ghost.wayPoints).toEqual([ - { x: 2, y: 5 }, - { x: 1, y: 5 }, - { x: 1, y: 6 }, - { x: 1, y: 7 }, - { x: 1, y: 8 }, - ]); + expect(ghost.direction).toBe('LEFT'); expect(ghost.state).toBe('chase'); - simulateFramesToMoveNTiles(1, game); - expect(ghost.tileCoordinates).toEqual({ x: 1, y: 5 }); + simulateFramesToMoveNTiles(1); + ghost = useGameStore.getState().game.ghosts[0]; + expect(tileFromScreen(ghost.screenCoordinates)).toEqual({ x: 1, y: 5 }); - simulateFramesToMoveNTiles(1, game); - expect(ghost.tileCoordinates).toEqual({ x: 1, y: 6 }); + simulateFramesToMoveNTiles(1); + ghost = useGameStore.getState().game.ghosts[0]; + expect(tileFromScreen(ghost.screenCoordinates)).toEqual({ x: 1, y: 6 }); - simulateFramesToMoveNTiles(1, game); - expect(ghost.tileCoordinates).toEqual({ x: 1, y: 7 }); + simulateFramesToMoveNTiles(1); + ghost = useGameStore.getState().game.ghosts[0]; + expect(tileFromScreen(ghost.screenCoordinates)).toEqual({ x: 1, y: 7 }); - simulateFramesToMoveNTiles(1, game); - expect(ghost.tileCoordinates).toEqual({ x: 1, y: 7 }); + simulateFramesToMoveNTiles(1); + ghost = useGameStore.getState().game.ghosts[0]; + expect(tileFromScreen(ghost.screenCoordinates)).toEqual({ x: 1, y: 7 }); // Assert // We had a collision with pac man - expect(game.pacMan.state).toBe('dead'); + const pacMan = useGameStore.getState().game.pacMan; + expect(pacMan.state).toBe('dead'); + ghost = useGameStore.getState().game.ghosts[0]; expect(ghost.state).toBe('scatter'); }); it('lets ghost 0 go through the tunnel', () => { // Arrange + useGameStore.setState((state) => { + state.game.pacMan.screenCoordinates = screenFromTile({ x: 27, y: 14 }); + state.game.pacMan.direction = 'RIGHT'; + state.game.pacMan.nextDirection = 'RIGHT'; + }); - const store = new Store(); - const game = new Game(store); - - game.pacMan.setTileCoordinates({ x: 27, y: 14 }); + const store = useGameStore.getState(); + store.sendGhostEvent(0, 'PHASE_END'); + expect(useGameStore.getState().game.ghosts[0].state).toBe('chase'); - game.pacMan.direction = 'RIGHT'; - game.pacMan.nextDirection = 'RIGHT'; - - const ghost = game.ghosts[0]; - ghost.send('PHASE_END'); - expect(ghost.state).toBe('chase'); - - ghost.setTileCoordinates({ x: 25, y: 14 }); - ghost.direction = 'RIGHT'; + useGameStore.setState((state) => { + state.game.ghosts[0].screenCoordinates = screenFromTile({ x: 25, y: 14 }); + state.game.ghosts[0].direction = 'RIGHT'; + }); // Act - onAnimationFrame({ game, timestamp: MILLISECONDS_PER_FRAME }); + onAnimationFrame(MILLISECONDS_PER_FRAME); // Assert + let ghost = useGameStore.getState().game.ghosts[0]; expect(ghost.targetTile).toEqual({ x: 27, y: 14 }); - expect(ghost.tileCoordinates).toEqual({ x: 25, y: 14 }); + expect(tileFromScreen(ghost.screenCoordinates)).toEqual({ x: 25, y: 14 }); expect(ghost.direction).toBe('RIGHT'); // Act - simulateFramesToMoveNTiles(1, game); + simulateFramesToMoveNTiles(1); // Assert + ghost = useGameStore.getState().game.ghosts[0]; expect(ghost.direction).toBe('RIGHT'); - expect(ghost.tileCoordinates).toEqual({ x: 25, y: 14 }); + expect(tileFromScreen(ghost.screenCoordinates)).toEqual({ x: 25, y: 14 }); // Act - simulateFramesToMoveNTiles(2, game); + simulateFramesToMoveNTiles(2); // Assert - expect(ghost.tileCoordinates).toEqual({ x: 26, y: 14 }); + ghost = useGameStore.getState().game.ghosts[0]; + expect(tileFromScreen(ghost.screenCoordinates)).toEqual({ x: 26, y: 14 }); // Act + // Make sure we stay in the current state phase, which is "chase" + useGameStore.setState((state) => { + const timer = state.game.ghosts[0].statePhaseTimer; + timer.timeSpent = 0; + }); - // Make sure we stay in the current state phase, which is "chase" - ghost.statePhaseTimer.restart(); - - simulateFramesToMoveNTiles(1, game); - simulateFramesToMoveNTiles(1, game); + simulateFramesToMoveNTiles(1); + simulateFramesToMoveNTiles(1); // Assert - expect(ghost.tileCoordinates).toEqual({ x: 27, y: 14 }); + ghost = useGameStore.getState().game.ghosts[0]; + const pacMan = useGameStore.getState().game.pacMan; + expect(tileFromScreen(ghost.screenCoordinates)).toEqual({ x: 27, y: 14 }); expect(ghost.direction).toBe('RIGHT'); - expect(game.pacMan.tileCoordinates).toEqual({ x: 4, y: 14 }); + expect(tileFromScreen(pacMan.screenCoordinates)).toEqual({ x: 4, y: 14 }); expect(ghost.targetTile).toEqual({ x: 3, y: 14 }); // Act - simulateFramesToMoveNTiles(2, game); + simulateFramesToMoveNTiles(2); + ghost = useGameStore.getState().game.ghosts[0]; expect(ghost.state).toBe('chase'); - expect(game.pacMan.tileCoordinates).toEqual({ x: 6, y: 14 }); + const pacManAfter = useGameStore.getState().game.pacMan; + expect(tileFromScreen(pacManAfter.screenCoordinates)).toEqual({ x: 6, y: 14 }); // Assert + ghost = useGameStore.getState().game.ghosts[0]; expect(ghost.targetTile).toEqual({ x: 5, y: 14 }); - expect(ghost.tileCoordinates).toEqual({ x: 0, y: 14 }); + expect(tileFromScreen(ghost.screenCoordinates)).toEqual({ x: 0, y: 14 }); expect(ghost.direction).toBe('RIGHT'); - expect(game.pacMan.tileCoordinates).toEqual({ x: 6, y: 14 }); + expect(tileFromScreen(pacManAfter.screenCoordinates)).toEqual({ x: 6, y: 14 }); expect(ghost.state).toBe('chase'); }); }); @@ -228,33 +264,31 @@ describe('updateGhost', () => { describe('in scatter state', () => { it('lets ghost 0 go to the lower right corner', () => { // Arrange + useGameStore.setState((state) => { + state.game.pacMan.screenCoordinates = screenFromTile({ x: 1, y: 8 }); + state.game.pacMan.direction = 'LEFT'; + state.game.pacMan.nextDirection = 'LEFT'; + }); - const store = new Store(); - const game = new Game(store); - - game.pacMan.setTileCoordinates({ x: 1, y: 8 }); - game.pacMan.direction = 'LEFT'; - game.pacMan.nextDirection = 'LEFT'; - - const ghost = game.ghosts[0]; + let ghost = useGameStore.getState().game.ghosts[0]; expect(ghost.state).toBe('scatter'); - ghost.setTileCoordinates({ x: 24, y: 1 }); - ghost.direction = 'RIGHT'; + + useGameStore.setState((state) => { + state.game.ghosts[0].screenCoordinates = screenFromTile({ x: 24, y: 1 }); + state.game.ghosts[0].direction = 'RIGHT'; + }); // Act - onAnimationFrame({ game, timestamp: MILLISECONDS_PER_FRAME }); + onAnimationFrame(MILLISECONDS_PER_FRAME); + ghost = useGameStore.getState().game.ghosts[0]; expect(ghost.targetTile).toEqual({ x: 26, y: 1 }); - expect(ghost.wayPoints).toEqual([ - { x: 24, y: 1 }, - { x: 25, y: 1 }, - { x: 26, y: 1 }, - ]); - expect(ghost.tileCoordinates).toEqual({ x: 24, y: 1 }); - simulateFramesToMoveNTiles(2, game); + expect(tileFromScreen(ghost.screenCoordinates)).toEqual({ x: 24, y: 1 }); + simulateFramesToMoveNTiles(2); - expect(ghost.tileCoordinates).toEqual({ x: 26, y: 1 }); + ghost = useGameStore.getState().game.ghosts[0]; + expect(tileFromScreen(ghost.screenCoordinates)).toEqual({ x: 26, y: 1 }); expect(ghost.direction).toBe('DOWN'); }); }); @@ -262,62 +296,54 @@ describe('updateGhost', () => { describe('in dead state', () => { it('lets ghost go into the box', () => { // Arrange + useGameStore.setState((state) => { + state.game.pacMan.screenCoordinates = screenFromTile({ x: 1, y: 8 }); + state.game.pacMan.direction = 'LEFT'; + state.game.pacMan.nextDirection = 'LEFT'; + state.game.ghosts[0].direction = 'RIGHT'; + }); - const store = new Store(); - const game = new Game(store); - const ghost = game.ghosts[0]; - - game.pacMan.setTileCoordinates({ x: 1, y: 8 }); - game.pacMan.direction = 'LEFT'; - game.pacMan.nextDirection = 'LEFT'; + const store = useGameStore.getState(); + store.sendGhostEvent(0, 'ENERGIZER_EATEN'); + store.sendGhostEvent(0, 'COLLISION_WITH_PAC_MAN'); + expect(useGameStore.getState().game.ghosts[0].state).toBe('dead'); - ghost.direction = 'RIGHT'; - - ghost.send('ENERGIZER_EATEN'); - ghost.send('COLLISION_WITH_PAC_MAN'); - expect(ghost.state).toBe('dead'); - ghost.setTileCoordinates({ x: 12, y: 11 }); + useGameStore.setState((state) => { + state.game.ghosts[0].screenCoordinates = screenFromTile({ x: 12, y: 11 }); + }); - expect(ghost.atTileCenter).toBeTruthy(); + let ghostData = createGhostData(0); + expect(ghostData.atTileCenter).toBeTruthy(); const simulateFramesToMoveGhostOneTile = () => { - simulateFramesToMoveNTiles(0.5, game); - expect(ghost.atTileCenter).toBeTruthy(); + simulateFramesToMoveNTiles(0.5); + ghostData = createGhostData(0); + expect(ghostData.atTileCenter).toBeTruthy(); }; // Act simulateFramesToMoveGhostOneTile(); - expect(ghost.tileCoordinates).toEqual({ x: 13, y: 11 }); + let ghost = useGameStore.getState().game.ghosts[0]; + expect(tileFromScreen(ghost.screenCoordinates)).toEqual({ x: 13, y: 11 }); simulateFramesToMoveGhostOneTile(); - expect(ghost.tileCoordinates).toEqual({ x: 13, y: 12 }); + ghost = useGameStore.getState().game.ghosts[0]; + expect(tileFromScreen(ghost.screenCoordinates)).toEqual({ x: 13, y: 12 }); expect(ghost.targetTile).toEqual(TILE_FOR_RETURNING_TO_BOX); - expect(ghost.wayPoints).toEqual([ - { - x: 13, - y: 12, - }, - { - x: 13, - y: 13, - }, - { - x: 13, - y: 14, - }, - TILE_FOR_RETURNING_TO_BOX, - ]); // Act simulateFramesToMoveGhostOneTile(); - expect(ghost.tileCoordinates).toEqual({ x: 13, y: 13 }); + ghost = useGameStore.getState().game.ghosts[0]; + expect(tileFromScreen(ghost.screenCoordinates)).toEqual({ x: 13, y: 13 }); simulateFramesToMoveGhostOneTile(); - expect(ghost.tileCoordinates).toEqual({ x: 13, y: 14 }); + ghost = useGameStore.getState().game.ghosts[0]; + expect(tileFromScreen(ghost.screenCoordinates)).toEqual({ x: 13, y: 14 }); simulateFramesToMoveGhostOneTile(); - expect(ghost.tileCoordinates).toEqual(TILE_FOR_RETURNING_TO_BOX); + ghost = useGameStore.getState().game.ghosts[0]; + expect(tileFromScreen(ghost.screenCoordinates)).toEqual(TILE_FOR_RETURNING_TO_BOX); expect(ghost.state).toBe('dead'); }); @@ -326,9 +352,7 @@ describe('updateGhost', () => { describe('speed factors', () => { const isValidSpeedFactor = (speedFactor: number): boolean => { - const store = new Store(); - const game = new Game(store); - const gameSpeed = game.speed; + const gameSpeed = useGameStore.getState().game.speed; const ghostSpeed = gameSpeed * speedFactor; return SCREEN_TILE_SIZE % ghostSpeed === 0; }; diff --git a/src/model/updateGhosts.ts b/src/model/updateGhosts.ts index da41afd0..c65fe234 100644 --- a/src/model/updateGhosts.ts +++ b/src/model/updateGhosts.ts @@ -1,81 +1,183 @@ -import { chooseNewTargetTile } from './chooseNewTargetTile'; +import { chooseNewTargetTile, GhostTargetingContext } from './chooseNewTargetTile'; import { chooseNextTile } from './chooseNextTile'; import { TileCoordinates, MAZE_WIDTH_IN_SCREEN_COORDINATES, MAZE_HEIGHT_IN_SCREEN_COORDINATES, - assertValidTileCoordinates, + tileFromScreen, } from './Coordinates'; import { getDirectionFromTileToTile } from './getDirectionFromTileToTile'; -import { Ghost } from './Ghost'; import { Direction } from './Types'; -import { directionToVector } from './Ways'; -import { - updateGhostStatePhaseTime, - updateGhostStatePhase, -} from './updateGhostStatePhase'; +import { directionToVector, DIRECTION_TO_OPPOSITE_DIRECTION } from './Ways'; import { Vector } from './Vector'; -import { Game } from './Game'; -import { action } from 'mobx'; - -export const updateGhosts = (game: Game) => { - for (const ghost of game.ghosts) { - updateGhost({ ghost }); +import { + useGameStore, + createGhostData, + GhostData, + getStatePhaseLength, +} from './store'; + +export const updateGhosts = () => { + const store = useGameStore.getState(); + const ghostCount = store.game.ghosts.length; + + for (let i = 0; i < ghostCount; i++) { + updateGhost(i); } }; -const updateGhost = ({ ghost }: { ghost: Ghost }) => { +const updateGhost = (ghostIndex: number) => { + const store = useGameStore.getState(); + const ghost = store.game.ghosts[ghostIndex]; + if (ghost.ghostPaused) { return; } - updateGhostStatePhaseTime(ghost); - updateDeadWaitingTimeInBoxLeft(ghost); + updateGhostStatePhaseTime(ghostIndex); + updateDeadWaitingTimeInBoxLeft(ghostIndex); + updateGhostStatePhase(ghostIndex); + routeAndMoveGhost(ghostIndex); +}; - updateGhostStatePhase(ghost); +const updateGhostStatePhaseTime = (ghostIndex: number) => { + const store = useGameStore.getState(); + const lastFrameLength = store.game.lastFrameLength; - routeAndMoveGhost(ghost); + useGameStore.setState((state) => { + const timer = state.game.ghosts[ghostIndex].statePhaseTimer; + if (timer.running) { + timer.timeSpent += lastFrameLength; + } + }); }; -const updateDeadWaitingTimeInBoxLeft = (ghost: Ghost) => { - if (ghost.dead && ghost.deadWaitingTimeInBoxLeft > 0) { - ghost.deadWaitingTimeInBoxLeft -= ghost.game.lastFrameLength; +const updateDeadWaitingTimeInBoxLeft = (ghostIndex: number) => { + const store = useGameStore.getState(); + const ghost = store.game.ghosts[ghostIndex]; + const isDead = ghost.state === 'dead'; + + if (isDead && ghost.deadWaitingTimeInBoxLeft > 0) { + useGameStore.setState((state) => { + state.game.ghosts[ghostIndex].deadWaitingTimeInBoxLeft -= state.game.lastFrameLength; + }); } }; -export const routeAndMoveGhost = (ghost: Ghost) => { - if (ghost.game.pacMan.dead) { +const updateGhostStatePhase = (ghostIndex: number) => { + const ghostData = createGhostData(ghostIndex); + + if (!ghostData.atTileCenter) { + return; + } + + const timer = ghostData.statePhaseTimer; + const isTimedOut = timer.timeSpent >= timer.duration; + + if (isTimedOut) { + // Send PHASE_END event - this will trigger direction change for scatter<->chase + const store = useGameStore.getState(); + const ghost = store.game.ghosts[ghostIndex]; + const currentState = ghost.state; + + store.sendGhostEvent(ghostIndex, 'PHASE_END'); + + // If transitioning between scatter and chase, change direction to opposite + const updatedGhost = useGameStore.getState().game.ghosts[ghostIndex]; + if ( + (currentState === 'scatter' && updatedGhost.state === 'chase') || + (currentState === 'chase' && updatedGhost.state === 'scatter') + ) { + useGameStore.setState((state) => { + const g = state.game.ghosts[ghostIndex]; + g.direction = DIRECTION_TO_OPPOSITE_DIRECTION[g.direction]; + }); + } + + // Restart timer with new duration + const newDuration = getStatePhaseLength(updatedGhost.state); + useGameStore.setState((state) => { + const timer = state.game.ghosts[ghostIndex].statePhaseTimer; + timer.duration = newDuration; + timer.running = true; + timer.timeSpent = 0; + }); + } +}; + +const routeAndMoveGhost = (ghostIndex: number) => { + const store = useGameStore.getState(); + const pacManDead = store.game.pacMan.state === 'dead'; + + if (pacManDead) { return; } - if (ghost.atTileCenter) { - reRouteGhost(ghost); + const ghostData = createGhostData(ghostIndex); + + if (ghostData.atTileCenter) { + reRouteGhost(ghostIndex, ghostData); } - moveGhost(ghost); + moveGhost(ghostIndex); }; -const reRouteGhost = (ghost: Ghost) => { - ghost.targetTile = chooseNewTargetTile(ghost); - updateDirection(ghost); - updateSpeed(ghost); +const reRouteGhost = (ghostIndex: number, ghostData: GhostData) => { + const newTargetTile = chooseNewTargetTileForGhost(ghostIndex, ghostData); + + useGameStore.setState((state) => { + state.game.ghosts[ghostIndex].targetTile = newTargetTile; + }); + + updateDirection(ghostIndex); + updateSpeed(ghostIndex); }; -const updateDirection = (ghost: Ghost) => { - const newDirection = getNewDirection(ghost); - ghost.direction = newDirection; +const chooseNewTargetTileForGhost = (ghostIndex: number, ghostData: GhostData): TileCoordinates => { + const store = useGameStore.getState(); + const game = store.game; + + const ctx: GhostTargetingContext = { + ghost: { + state: ghostData.state, + ghostNumber: ghostData.ghostNumber, + tileCoordinates: ghostData.tileCoordinates, + direction: ghostData.direction, + isInsideBoxWalls: ghostData.isInsideBoxWalls, + }, + pacMan: { + tileCoordinates: tileFromScreen(game.pacMan.screenCoordinates), + direction: game.pacMan.direction, + }, + blinkyTileCoordinates: tileFromScreen(game.ghosts[0].screenCoordinates), + }; + + return chooseNewTargetTile(ctx); }; -const updateSpeed = (ghost: Ghost) => { - const newSpeedFactor = getNewSpeedFactor(ghost); - ghost.speedFactor = newSpeedFactor; +const updateDirection = (ghostIndex: number) => { + const ghostData = createGhostData(ghostIndex); + const newDirection = getNewDirection(ghostData); + + useGameStore.setState((state) => { + state.game.ghosts[ghostIndex].direction = newDirection; + }); }; -export const getNewDirection = (ghost: Ghost): Direction => { - const currentTile = ghost.tileCoordinates; - const currentDirection = ghost.direction; - const targetTile = ghost.targetTile; - const boxDoorIsOpen = ghost.canPassThroughBoxDoor; +const updateSpeed = (ghostIndex: number) => { + const ghostData = createGhostData(ghostIndex); + const newSpeedFactor = getNewSpeedFactor(ghostData); + + useGameStore.setState((state) => { + state.game.ghosts[ghostIndex].speedFactor = newSpeedFactor; + }); +}; + +export const getNewDirection = (ghostData: GhostData): Direction => { + const currentTile = ghostData.tileCoordinates; + const currentDirection = ghostData.direction; + const targetTile = ghostData.targetTile; + const boxDoorIsOpen = ghostData.canPassThroughBoxDoor; const nextTile: TileCoordinates = chooseNextTile({ currentTile, @@ -87,40 +189,35 @@ export const getNewDirection = (ghost: Ghost): Direction => { return getDirectionFromTileToTile(currentTile, nextTile); }; -const moveGhost = (ghost: Ghost) => { - const vector: Vector = getGhostMovementVector(ghost); - moveGhostBy(ghost, vector); +const moveGhost = (ghostIndex: number) => { + const store = useGameStore.getState(); + const ghost = store.game.ghosts[ghostIndex]; + const speed = store.game.speed * ghost.speedFactor; + const vector: Vector = directionToVector(ghost.direction, speed); + + useGameStore.setState((state) => { + const g = state.game.ghosts[ghostIndex]; + g.screenCoordinates.x = + (g.screenCoordinates.x + vector.x + MAZE_WIDTH_IN_SCREEN_COORDINATES) % + MAZE_WIDTH_IN_SCREEN_COORDINATES; + g.screenCoordinates.y = + (g.screenCoordinates.y + vector.y + MAZE_HEIGHT_IN_SCREEN_COORDINATES) % + MAZE_HEIGHT_IN_SCREEN_COORDINATES; + }); }; -const moveGhostBy = action((ghost: Ghost, vector: Vector) => { - ghost.screenCoordinates.x = - (ghost.screenCoordinates.x + vector.x + MAZE_WIDTH_IN_SCREEN_COORDINATES) % - MAZE_WIDTH_IN_SCREEN_COORDINATES; - ghost.screenCoordinates.y = - (ghost.screenCoordinates.y + vector.y + MAZE_HEIGHT_IN_SCREEN_COORDINATES) % - MAZE_HEIGHT_IN_SCREEN_COORDINATES; - - assertValidTileCoordinates(ghost.tileCoordinates); -}); - const isInTunnel = (tile: TileCoordinates) => tile.y === 14 && (tile.x >= 22 || tile.x <= 5); -const getGhostMovementVector = (ghost: Ghost): Vector => { - const speed = ghost.game.speed * ghost.speedFactor; - const velocity = directionToVector(ghost.direction, speed); - return velocity; -}; - export const SPEED_FACTOR_HIGH = 2; export const SPEED_FACTOR_NORMAL = 1; export const SPEED_FACTOR_SLOW = 0.5; -const getNewSpeedFactor = (ghost: Ghost): number => { - if (ghost.dead) { +const getNewSpeedFactor = (ghostData: GhostData): number => { + if (ghostData.dead) { return SPEED_FACTOR_HIGH; } - if (isInTunnel(ghost.tileCoordinates) || ghost.state === 'frightened') { + if (isInTunnel(ghostData.tileCoordinates) || ghostData.state === 'frightened') { return SPEED_FACTOR_SLOW; } return SPEED_FACTOR_NORMAL; diff --git a/src/model/updatePacMan.test.ts b/src/model/updatePacMan.test.ts index 57c6085f..8e1afa0b 100644 --- a/src/model/updatePacMan.test.ts +++ b/src/model/updatePacMan.test.ts @@ -1,127 +1,164 @@ import { screenFromTile } from './Coordinates'; import { BASIC_PILL_POINTS, ghostCollidesWithPacMan } from './detectCollisions'; -import { Game, DEFAULT_SPEED } from './Game'; -import { Ghost } from './Ghost'; import { BASIC_PILL_ID, EMPTY_TILE_ID } from './MazeData'; import { simulateFrames, simulateFrame, simulateTime } from './simulateFrames'; import { DELAY_TO_REVIVE_PAC_MAN } from './updatePacMan'; -import { Store } from './Store'; -import { TYPICAL_FRAME_LENGTH } from './updateExternalTimeStamp'; +import { TYPICAL_FRAME_LENGTH } from './onAnimationFrame'; import { PacManDyingPhaseLength, getPacManDyingPhase, } from './pacManDyingPhase'; +import { useGameStore, createInitialState } from './store'; + +// Add DEFAULT_SPEED to store constants +const GAME_DEFAULT_SPEED = 2; + +// Helper to reset store before each test +const resetStore = () => { + useGameStore.setState(createInitialState()); +}; + +// Helper to set pacman position +const setPacManTileCoordinates = (tile: { x: number; y: number }) => { + useGameStore.setState((state) => { + state.game.pacMan.screenCoordinates = screenFromTile(tile); + }); +}; + +// Helper to set ghost position +const setGhostTileCoordinates = (ghostIndex: number, tile: { x: number; y: number }) => { + useGameStore.setState((state) => { + state.game.ghosts[ghostIndex].screenCoordinates = screenFromTile(tile); + }); +}; + +// Helper to get game state +const getGame = () => useGameStore.getState().game; + +// Helper to get PacMan state +const getPacMan = () => useGameStore.getState().game.pacMan; + +// Helper to get timestamp +const getTimestamp = () => useGameStore.getState().game.timestamp; + +// Helper to compute time since death +const getTimeSinceDeath = () => { + const state = useGameStore.getState(); + const pacMan = state.game.pacMan; + if (pacMan.state !== 'dead') return 0; + return state.game.timestamp - pacMan.diedAtTimestamp; +}; + +// Helper to check if game is over +const isGameOver = () => { + const pacMan = getPacMan(); + return pacMan.state === 'dead' && pacMan.extraLivesLeft === 0; +}; describe('updatePacMan()', () => { + beforeEach(() => { + resetStore(); + }); + it('advances PacMans position', () => { // Arrange - const store = new Store(); - const game = new Game(store); - game.pacMan.setTileCoordinates({ x: 1, y: 1 }); - expect(game.pacMan.screenCoordinates.x).toBe(20); - game.pacMan.direction = 'RIGHT'; - game.pacMan.nextDirection = 'RIGHT'; + setPacManTileCoordinates({ x: 1, y: 1 }); + expect(getPacMan().screenCoordinates.x).toBe(20); + useGameStore.setState((state) => { + state.game.pacMan.direction = 'RIGHT'; + state.game.pacMan.nextDirection = 'RIGHT'; + }); // Act - simulateFrames(1, game); + simulateFrames(1); // Assert - expect(game.pacMan.screenCoordinates.x).toBe(20 + DEFAULT_SPEED); + expect(getPacMan().screenCoordinates.x).toBe(20 + GAME_DEFAULT_SPEED); // Act - simulateFrames(1, game); + simulateFrames(1); // Assert - expect(game.pacMan.screenCoordinates.x).toBe(20 + 2 * DEFAULT_SPEED); + expect(getPacMan().screenCoordinates.x).toBe(20 + 2 * GAME_DEFAULT_SPEED); }); it('stops pac man once he is dead', () => { // Arrange - const store = new Store(); - const game = new Game(store); - game.pacMan.setTileCoordinates({ x: 1, y: 1 }); - expect(game.pacMan.screenCoordinates.x).toBe(20); - game.pacMan.direction = 'RIGHT'; - game.pacMan.nextDirection = 'RIGHT'; + setPacManTileCoordinates({ x: 1, y: 1 }); + expect(getPacMan().screenCoordinates.x).toBe(20); + useGameStore.setState((state) => { + state.game.pacMan.direction = 'RIGHT'; + state.game.pacMan.nextDirection = 'RIGHT'; + }); // Act - game.pacMan.send('COLLISION_WITH_GHOST'); - simulateFrames(1, game); + useGameStore.getState().sendPacManEvent('COLLISION_WITH_GHOST'); + simulateFrames(1); // Assert - expect(game.pacMan.screenCoordinates.x).toBe(20); + expect(getPacMan().screenCoordinates.x).toBe(20); }); it('stops PacMan when he hits a wall', () => { // Arrange - const store = new Store(); - const game = new Game(store); - game.pacMan.setTileCoordinates({ x: 1, y: 1 }); - expect(game.pacMan.screenCoordinates.x).toBe(20); - game.pacMan.direction = 'LEFT'; - game.pacMan.nextDirection = 'LEFT'; + setPacManTileCoordinates({ x: 1, y: 1 }); + expect(getPacMan().screenCoordinates.x).toBe(20); + useGameStore.setState((state) => { + state.game.pacMan.direction = 'LEFT'; + state.game.pacMan.nextDirection = 'LEFT'; + }); // Act - simulateFrames(1, game); + simulateFrames(1); // Assert - expect(game.pacMan.screenCoordinates.x).toBe(20); + expect(getPacMan().screenCoordinates.x).toBe(20); }); - it('changes PacMans direction once he reachs a tile center and the the way is free', () => { + it('changes PacMans direction once he reachs a tile center and the way is free', () => { // Arrange - const store = new Store(); - const game = new Game(store); - game.pacMan.screenCoordinates = { x: 22, y: 20 }; - game.pacMan.direction = 'LEFT'; - game.pacMan.nextDirection = 'DOWN'; + useGameStore.setState((state) => { + state.game.pacMan.screenCoordinates = { x: 22, y: 20 }; + state.game.pacMan.direction = 'LEFT'; + state.game.pacMan.nextDirection = 'DOWN'; + }); // Act - simulateFrames(1, game); + simulateFrames(1); // Assert - expect(game.pacMan.screenCoordinates.x).toBe(20); - expect(game.pacMan.screenCoordinates.x).toBe(20); - expect(game.pacMan.direction).toBe('LEFT'); + expect(getPacMan().screenCoordinates.x).toBe(20); + expect(getPacMan().direction).toBe('LEFT'); // Act - simulateFrames(1, game); + simulateFrames(1); // Assert - expect(game.pacMan.direction).toBe('DOWN'); - - expect(game.pacMan.screenCoordinates.x).toBe(20); - expect(game.pacMan.screenCoordinates.y).toBe(22); + expect(getPacMan().direction).toBe('DOWN'); + expect(getPacMan().screenCoordinates.x).toBe(20); + expect(getPacMan().screenCoordinates.y).toBe(22); }); it('lets pac man eat basic pills', () => { // Arrange const BASIC_PILL_TILE = { x: 9, y: 20 }; - const store = new Store(); - const game = new Game(store); - game.pacMan.setTileCoordinates(BASIC_PILL_TILE); - game.pacMan.direction = 'DOWN'; - game.pacMan.nextDirection = 'DOWN'; - - expect(game.maze.pills[BASIC_PILL_TILE.y][BASIC_PILL_TILE.x]).toBe( - BASIC_PILL_ID - ); + setPacManTileCoordinates(BASIC_PILL_TILE); + useGameStore.setState((state) => { + state.game.pacMan.direction = 'DOWN'; + state.game.pacMan.nextDirection = 'DOWN'; + }); - expect(game.score).toBe(0); + expect(getGame().maze.pills[BASIC_PILL_TILE.y][BASIC_PILL_TILE.x]).toBe(BASIC_PILL_ID); + expect(getGame().score).toBe(0); // Act - simulateFrames(1, game); + simulateFrames(1); // Assert - expect(game.score).toBe(BASIC_PILL_POINTS); - expect(game.pacMan.screenCoordinates).toEqual( - screenFromTile(BASIC_PILL_TILE) - ); - - expect(game.maze.pills[BASIC_PILL_TILE.y][BASIC_PILL_TILE.x]).toBe( - EMPTY_TILE_ID - ); + expect(getGame().score).toBe(BASIC_PILL_POINTS); + expect(getPacMan().screenCoordinates).toEqual(screenFromTile(BASIC_PILL_TILE)); + expect(getGame().maze.pills[BASIC_PILL_TILE.y][BASIC_PILL_TILE.x]).toBe(EMPTY_TILE_ID); }); it('lets pac man die from meeting a ghost', () => { @@ -129,103 +166,107 @@ describe('updatePacMan()', () => { const GHOST_TX = 1; const GHOST_TY = 1; - const store = new Store(); - const game = new Game(store); - const ghost: Ghost = game.ghosts[0]; - ghost.setTileCoordinates({ x: GHOST_TX, y: GHOST_TY }); - ghost.ghostPaused = true; - game.pacMan.setTileCoordinates({ x: GHOST_TX, y: GHOST_TY + 1 }); - game.pacMan.direction = 'UP'; - game.pacMan.nextDirection = 'UP'; + setGhostTileCoordinates(0, { x: GHOST_TX, y: GHOST_TY }); + useGameStore.setState((state) => { + state.game.ghosts[0].ghostPaused = true; + }); + setPacManTileCoordinates({ x: GHOST_TX, y: GHOST_TY + 1 }); + useGameStore.setState((state) => { + state.game.pacMan.direction = 'UP'; + state.game.pacMan.nextDirection = 'UP'; + }); // Act - simulateFrames(10, game); + simulateFrames(10); // Assert - expect(game.pacMan.state).toBe('dead'); - expect(game.pacMan.timeSinceDeath > 0).toBeTruthy(); + expect(getPacMan().state).toBe('dead'); + expect(getTimeSinceDeath() > 0).toBeTruthy(); }); it('animates pac mans death', () => { // Arrange - const store = new Store(); - const game = new Game(store); - const { pacMan } = game; - pacMan.setTileCoordinates({ x: 1, y: 1 }); - pacMan.direction = 'UP'; - pacMan.nextDirection = 'UP'; - killPacMan(game); + setPacManTileCoordinates({ x: 1, y: 1 }); + useGameStore.setState((state) => { + state.game.pacMan.direction = 'UP'; + state.game.pacMan.nextDirection = 'UP'; + }); + killPacMan(); - expect(getPacManDyingPhase(pacMan)).toBe(0); + expect(getPacManDyingPhase(getTimeSinceDeath())).toBe(0); - simulateFrame(game); + simulateFrame(); - expect(getPacManDyingPhase(pacMan)).toBe(0); + expect(getPacManDyingPhase(getTimeSinceDeath())).toBe(0); // Act - expect(game.timestamp).toBe(TYPICAL_FRAME_LENGTH); - simulateTime(game, PacManDyingPhaseLength - TYPICAL_FRAME_LENGTH); + expect(getTimestamp()).toBe(TYPICAL_FRAME_LENGTH); + simulateTime(PacManDyingPhaseLength - TYPICAL_FRAME_LENGTH); - expect(game.timestamp).toBe(PacManDyingPhaseLength); + expect(getTimestamp()).toBe(PacManDyingPhaseLength); // Assert - expect(getPacManDyingPhase(pacMan)).toBe(1); + expect(getPacManDyingPhase(getTimeSinceDeath())).toBe(1); // Act - simulateTime(game, PacManDyingPhaseLength); + simulateTime(PacManDyingPhaseLength); // Assert - expect(getPacManDyingPhase(pacMan)).toBe(2); + expect(getPacManDyingPhase(getTimeSinceDeath())).toBe(2); }); describe('with some lives left', () => { it('revives pac man after his death', () => { // Arrange - const store = new Store(); - const game = new Game(store); - game.timestamp = 1; - game.pacMan.setTileCoordinates({ x: 1, y: 1 }); - game.pacMan.direction = 'UP'; - game.pacMan.nextDirection = 'UP'; - game.pacMan.extraLivesLeft = 2; - killPacMan(game); + useGameStore.setState((state) => { + state.game.timestamp = 1; + state.game.pacMan.extraLivesLeft = 2; + }); + setPacManTileCoordinates({ x: 1, y: 1 }); + useGameStore.setState((state) => { + state.game.pacMan.direction = 'UP'; + state.game.pacMan.nextDirection = 'UP'; + }); + killPacMan(); // Act - simulateTime(game, DELAY_TO_REVIVE_PAC_MAN); + simulateTime(DELAY_TO_REVIVE_PAC_MAN); // Assert - expect(game.pacMan.state).not.toBe('dead'); - expect(game.pacMan.state).toBe('eating'); - expect(game.pacMan.diedAtTimestamp).toBe(-1); - expect(game.ghosts[0].ghostPaused).toBeFalsy(); - expect(game.ghosts[1].ghostPaused).toBeFalsy(); - expect(game.pacMan.extraLivesLeft).toBe(1); + expect(getPacMan().state).not.toBe('dead'); + expect(getPacMan().state).toBe('eating'); + expect(getPacMan().diedAtTimestamp).toBe(-1); + expect(getGame().ghosts[0].ghostPaused).toBeFalsy(); + expect(getGame().ghosts[1].ghostPaused).toBeFalsy(); + expect(getPacMan().extraLivesLeft).toBe(1); }); }); describe('with all lives lost', () => { it('hides pac man and the ghosts and shows game over', () => { // Arrange - const store = new Store(); - const game = new Game(store); - game.timestamp = 1; - game.pacMan.setTileCoordinates({ x: 1, y: 1 }); - game.pacMan.direction = 'UP'; - game.pacMan.nextDirection = 'UP'; - game.pacMan.extraLivesLeft = 0; + useGameStore.setState((state) => { + state.game.timestamp = 1; + state.game.pacMan.extraLivesLeft = 0; + }); + setPacManTileCoordinates({ x: 1, y: 1 }); + useGameStore.setState((state) => { + state.game.pacMan.direction = 'UP'; + state.game.pacMan.nextDirection = 'UP'; + }); // Act - killPacMan(game); - simulateFrames(TYPICAL_FRAME_LENGTH + DELAY_TO_REVIVE_PAC_MAN, game); + killPacMan(); + simulateFrames(TYPICAL_FRAME_LENGTH + DELAY_TO_REVIVE_PAC_MAN); // Assert - expect(game.pacMan.state).toBe('dead'); - expect(game.gameOver).toBeTruthy(); + expect(getPacMan().state).toBe('dead'); + expect(isGameOver()).toBeTruthy(); }); }); }); -const killPacMan = (game: Game) => { - ghostCollidesWithPacMan(game.ghosts[0]); - expect(game.pacMan.state).toBe('dead'); +const killPacMan = () => { + ghostCollidesWithPacMan(0); + expect(getPacMan().state).toBe('dead'); }; diff --git a/src/model/updatePacMan.ts b/src/model/updatePacMan.ts index 48af5f19..6fe12489 100644 --- a/src/model/updatePacMan.ts +++ b/src/model/updatePacMan.ts @@ -1,7 +1,10 @@ -import { ScreenCoordinates, tileFromScreen } from './Coordinates'; -import { Game } from './Game'; -import { movePacManBy } from './movePacManBy'; -import { PacMan } from './PacMan'; +import { + ScreenCoordinates, + tileFromScreen, + MAZE_WIDTH_IN_SCREEN_COORDINATES, + screenFromTile, +} from './Coordinates'; +import { useGameStore, INITIAL_PACMAN_STATE } from './store'; import { MilliSeconds } from './Types'; import { directionToVector as directionAsVector, @@ -9,19 +12,28 @@ import { isWayFreeInDirection, } from './Ways'; import { TotalPacManDyingAnimationLength } from './pacManDyingPhase'; +import { Vector } from './Vector'; +import { getStatePhaseLength } from './store/gameStore'; +import { INITIAL_GHOST_STATE } from './store/types'; export const DELAY_TO_REVIVE_PAC_MAN: MilliSeconds = TotalPacManDyingAnimationLength; -export const updatePacMan = (game: Game): void => { - const pacMan = game.pacMan; - if (pacMan.alive) { - updateLivingPacMan(pacMan); +export const updatePacMan = (): void => { + const store = useGameStore.getState(); + const pacMan = store.game.pacMan; + const isDead = pacMan.state === 'dead'; + + if (!isDead) { + updateLivingPacMan(); } else { - updateDeadPacMan(pacMan); + updateDeadPacMan(); } }; -const updateLivingPacMan = (pacMan: PacMan) => { +const updateLivingPacMan = () => { + const store = useGameStore.getState(); + const pacMan = store.game.pacMan; + if (isTileCenter(pacMan.screenCoordinates)) { const tile = tileFromScreen(pacMan.screenCoordinates); @@ -30,34 +42,96 @@ const updateLivingPacMan = (pacMan: PacMan) => { pacMan.direction !== pacMan.nextDirection && isWayFreeInDirection(tile, pacMan.nextDirection) ) { - pacMan.direction = pacMan.nextDirection; + useGameStore.setState((state) => { + state.game.pacMan.direction = state.game.pacMan.nextDirection; + }); } + // Get updated direction after potential change + const updatedPacMan = useGameStore.getState().game.pacMan; + // Move - if (isWayFreeInDirection(tile, pacMan.direction)) { - movePacMan(pacMan); + if (isWayFreeInDirection(tile, updatedPacMan.direction)) { + movePacMan(); } } else { - movePacMan(pacMan); + movePacMan(); } }; -const movePacMan = (pacMan: PacMan): void => { - const speed = pacMan.game.speed; +const movePacMan = (): void => { + const store = useGameStore.getState(); + const pacMan = store.game.pacMan; + const speed = store.game.speed; const delta: ScreenCoordinates = directionAsVector(pacMan.direction, speed); - movePacManBy(pacMan, delta); + movePacManBy(delta); }; -const updateDeadPacMan = (pacMan: PacMan) => { - if (pacMan.timeSinceDeath >= TotalPacManDyingAnimationLength) { - revivePacMan(pacMan); +const movePacManBy = (vector: Vector) => { + useGameStore.setState((state) => { + const pacMan = state.game.pacMan; + pacMan.screenCoordinates.x = + (pacMan.screenCoordinates.x + vector.x + MAZE_WIDTH_IN_SCREEN_COORDINATES) % + MAZE_WIDTH_IN_SCREEN_COORDINATES; + pacMan.screenCoordinates.y += vector.y; + }); +}; + +const updateDeadPacMan = () => { + const store = useGameStore.getState(); + const pacMan = store.game.pacMan; + const timeSinceDeath = store.game.timestamp - pacMan.diedAtTimestamp; + + if (timeSinceDeath >= TotalPacManDyingAnimationLength) { + revivePacMan(); } - return; }; -const revivePacMan = (pacMan: PacMan) => { +const revivePacMan = () => { + const store = useGameStore.getState(); + const pacMan = store.game.pacMan; + if (pacMan.extraLivesLeft > 0) { - pacMan.extraLivesLeft -= 1; - pacMan.game.revivePacMan(); + useGameStore.setState((state) => { + state.game.pacMan.extraLivesLeft -= 1; + }); + doRevivePacMan(); } }; + +const doRevivePacMan = () => { + const store = useGameStore.getState(); + + // Send REVIVED event to pacman + store.sendPacManEvent('REVIVED'); + + // Reset game timestamp and pacman position + useGameStore.setState((state) => { + state.game.timestamp = 0; + // Reset pacman + state.game.pacMan.diedAtTimestamp = -1; + state.game.pacMan.state = INITIAL_PACMAN_STATE; + state.game.pacMan.screenCoordinates = screenFromTile({ x: 14, y: 23 }); + state.game.pacMan.nextDirection = 'LEFT'; + state.game.pacMan.direction = 'LEFT'; + + // Reset ghosts + const ghostPositions = [ + { x: 12, y: 14, direction: 'LEFT' as const }, + { x: 13, y: 14, direction: 'RIGHT' as const }, + { x: 14, y: 14, direction: 'LEFT' as const }, + { x: 15, y: 14, direction: 'RIGHT' as const }, + ]; + + state.game.ghosts.forEach((ghost, index) => { + ghost.screenCoordinates = screenFromTile(ghostPositions[index]); + ghost.direction = ghostPositions[index].direction; + ghost.ghostPaused = false; + ghost.state = INITIAL_GHOST_STATE; + ghost.statePhaseTimer.duration = getStatePhaseLength(INITIAL_GHOST_STATE); + ghost.statePhaseTimer.running = true; + ghost.statePhaseTimer.timeSpent = 0; + ghost.stateChanges++; + }); + }); +}; diff --git a/src/model/useGameLoop.ts b/src/model/useGameLoop.ts index 2ce7ab07..db210d80 100644 --- a/src/model/useGameLoop.ts +++ b/src/model/useGameLoop.ts @@ -1,13 +1,9 @@ -import { useStore } from '../components/StoreContext'; import { onAnimationFrame } from './onAnimationFrame'; import { useAnimationLoop } from './useAnimationLoop'; export const useGameLoop = () => { - const store = useStore(); - const animationStep = (timestamp: number) => { - const { game } = store; - onAnimationFrame({ game, timestamp }); + onAnimationFrame(timestamp); }; useAnimationLoop(animationStep); diff --git a/src/pages/GamePage/GamePage.tsx b/src/pages/GamePage/GamePage.tsx index acb7a7a5..7619f0ff 100644 --- a/src/pages/GamePage/GamePage.tsx +++ b/src/pages/GamePage/GamePage.tsx @@ -1,5 +1,4 @@ import { Row } from 'antd'; -import { observer } from 'mobx-react-lite'; import React, { useEffect } from 'react'; import styled from 'styled-components/macro'; import { Board } from '../../components/Board'; @@ -11,17 +10,19 @@ import { MazeView } from './components/MazeView'; import { PacManView } from './components/PacManView'; import { PillsView } from './components/PillsView'; import { Score } from './components/Score'; -import { useStore } from '../../components/StoreContext'; +import { useGameStore } from '../../model/store'; import { useKeyboardActions } from './components/useKeyboardActions'; import { VSpace } from '../../components/Spacer'; import { useGameLoop } from '../../model/useGameLoop'; -export const GamePage: React.FC = observer(() => { - const store = useStore(); +export const GamePage: React.FC = () => { + const resetGame = useGameStore((state) => state.resetGame); + const setGamePaused = useGameStore((state) => state.setGamePaused); + useEffect(() => { - store.resetGame(); + resetGame(); return () => { - store.game.gamePaused = true; + setGamePaused(true); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -59,7 +60,7 @@ export const GamePage: React.FC = observer(() => { ); -}); +}; const Layout = styled.div` margin-left: 16px; diff --git a/src/pages/GamePage/components/EnergizerDebugView.tsx b/src/pages/GamePage/components/EnergizerDebugView.tsx index 23a34452..dbd61506 100644 --- a/src/pages/GamePage/components/EnergizerDebugView.tsx +++ b/src/pages/GamePage/components/EnergizerDebugView.tsx @@ -1,8 +1,7 @@ import { Card, Button, Row, Col } from 'antd'; -import { observer } from 'mobx-react-lite'; -import React from 'react'; +import React, { FC } from 'react'; import styled from 'styled-components/macro'; -import { useGame } from '../../../components/StoreContext'; +import { useGameStore } from '../../../model/store'; import { eatEnergizer } from '../../../model/eatEnergizer'; const formatter = new Intl.NumberFormat('en-US', { @@ -10,42 +9,40 @@ const formatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1, }); -export const EnergizerDebugView = observer<{ className?: string }>( - ({ className }) => { - const game = useGame(); - return ( - - - - -
- Time left:{' '} - {formatter.format( - Math.abs(game.energizerTimer.timeLeft) / 1000 - )}{' '} - seconds -
- +export const EnergizerDebugView: FC<{ className?: string }> = ({ className }) => { + const energizerTimer = useGameStore((state) => state.game.energizerTimer); + const timeLeft = energizerTimer.duration - energizerTimer.timeSpent; - + return ( + + + + +
+ Time left:{' '} + {formatter.format(Math.abs(timeLeft) / 1000)}{' '} + seconds +
+ - - { - eatEnergizer(game); - }} - > - Eat - - -
-
-
- ); - } -); + + + + { + eatEnergizer(); + }} + > + Eat + + +
+
+
+ ); +}; const Layout = styled.div``; diff --git a/src/pages/GamePage/components/ExtraLives.tsx b/src/pages/GamePage/components/ExtraLives.tsx index 0f3fe575..474cda8e 100644 --- a/src/pages/GamePage/components/ExtraLives.tsx +++ b/src/pages/GamePage/components/ExtraLives.tsx @@ -1,18 +1,18 @@ -import { observer } from 'mobx-react-lite'; -import React from 'react'; -import { useGame } from '../../../components/StoreContext'; +import React, { FC } from 'react'; +import { useGameStore } from '../../../model/store'; import classNames from 'classnames'; import styled from 'styled-components/macro'; import { PacManSprite } from './PacManView'; import { times } from 'lodash'; import { SCALE_FACTOR } from '../../../model/Coordinates'; -export const ExtraLives = observer<{ className?: string }>(({ className }) => { - const game = useGame(); +export const ExtraLives: FC<{ className?: string }> = ({ className }) => { + const extraLivesLeft = useGameStore((state) => state.game.pacMan.extraLivesLeft); + return ( - {times(game.pacMan.extraLivesLeft, n => ( + {times(extraLivesLeft, n => ( (({ className }) => { ); -}); +}; const Layout = styled.div` display: inline-flex; diff --git a/src/pages/GamePage/components/FPS.tsx b/src/pages/GamePage/components/FPS.tsx index 6030fb08..3a4f90dd 100644 --- a/src/pages/GamePage/components/FPS.tsx +++ b/src/pages/GamePage/components/FPS.tsx @@ -1,16 +1,15 @@ -import { observer } from 'mobx-react-lite'; -import React from 'react'; -import { useGame } from '../../../components/StoreContext'; +import React, { FC } from 'react'; +import { useGameStore } from '../../../model/store'; import styled from 'styled-components/macro'; -export const FPS = observer<{ className?: string }>(({ className }) => { - const store = useGame(); +export const FPS: FC<{ className?: string }> = ({ className }) => { + const lastFrameLength = useGameStore((state) => state.game.lastFrameLength); return ( - {Math.round(1000 / store.lastFrameLength)} FPS + {Math.round(1000 / lastFrameLength)} FPS ); -}); +}; const Layout = styled.div` margin-top: 12px; diff --git a/src/pages/GamePage/components/GameDebugView.tsx b/src/pages/GamePage/components/GameDebugView.tsx index 2b8f9757..9b67b3b9 100644 --- a/src/pages/GamePage/components/GameDebugView.tsx +++ b/src/pages/GamePage/components/GameDebugView.tsx @@ -1,56 +1,52 @@ /* eslint-disable react/display-name */ import { Button, Card, Col, Row, Switch, Typography } from 'antd'; -import { observer } from 'mobx-react-lite'; -import React from 'react'; +import React, { FC } from 'react'; import styled from 'styled-components/macro'; -import { useGame, useStore } from '../../../components/StoreContext'; -import { action } from 'mobx'; +import { useGameStore } from '../../../model/store'; const { Text } = Typography; -export const GameDebugView = observer<{ className?: string }>( - ({ className }) => { - const store = useStore(); - const game = useGame(); - return ( - - - - - (store.debugState.gameViewOptions.hitBox = checked) - )} - /> - - - Show Hit Boxes - - +export const GameDebugView: FC<{ className?: string }> = ({ className }) => { + const showHitBox = useGameStore((state) => state.debugState.gameViewOptions.hitBox); + const gamePaused = useGameStore((state) => state.game.gamePaused); + const setGameViewOption = useGameStore((state) => state.setGameViewOption); + const setGamePaused = useGameStore((state) => state.setGamePaused); + const resetGame = useGameStore((state) => state.resetGame); - - { - game.gamePaused = checked; - }} - /> - - - Paused - - + return ( + + + + + setGameViewOption('hitBox', checked)} + /> + + + Show Hit Boxes + + - - Restart - - - - - ); - } -); + + setGamePaused(checked)} + /> + + + Paused + + + + resetGame()} shape="round"> + Restart + + + + + ); +}; const Layout = styled.div``; diff --git a/src/pages/GamePage/components/GameOver.tsx b/src/pages/GamePage/components/GameOver.tsx index dd32229e..08506687 100644 --- a/src/pages/GamePage/components/GameOver.tsx +++ b/src/pages/GamePage/components/GameOver.tsx @@ -1,19 +1,21 @@ -import { observer } from 'mobx-react-lite'; import React, { FC } from 'react'; import './GameOver.css'; -import { useGame } from '../../../components/StoreContext'; +import { useGameStore } from '../../../model/store'; import { Message } from './Message'; import { TotalPacManDyingAnimationLength } from '../../../model/pacManDyingPhase'; export const TOTAL_TIME_TO_GAME_OVER_MESSAGE = TotalPacManDyingAnimationLength; -export const GameOver: FC<{ className?: string }> = observer( - ({ className }) => { - const game = useGame(); - const { pacMan } = game; - const gameOverMessageVisible = - game.gameOver && pacMan.timeSinceDeath >= TOTAL_TIME_TO_GAME_OVER_MESSAGE; +export const GameOver: FC<{ className?: string }> = ({ className }) => { + const pacManState = useGameStore((state) => state.game.pacMan.state); + const extraLivesLeft = useGameStore((state) => state.game.pacMan.extraLivesLeft); + const diedAtTimestamp = useGameStore((state) => state.game.pacMan.diedAtTimestamp); + const timestamp = useGameStore((state) => state.game.timestamp); - return gameOverMessageVisible ? : null; - } -); + const isDead = pacManState === 'dead'; + const isGameOver = isDead && extraLivesLeft === 0; + const timeSinceDeath = isDead ? timestamp - diedAtTimestamp : 0; + const gameOverMessageVisible = isGameOver && timeSinceDeath >= TOTAL_TIME_TO_GAME_OVER_MESSAGE; + + return gameOverMessageVisible ? : null; +}; diff --git a/src/pages/GamePage/components/GhostDebugControls.tsx b/src/pages/GamePage/components/GhostDebugControls.tsx index 46f00f9f..fb9c3384 100644 --- a/src/pages/GamePage/components/GhostDebugControls.tsx +++ b/src/pages/GamePage/components/GhostDebugControls.tsx @@ -1,22 +1,21 @@ import { Col, Row, Switch, Typography } from 'antd'; -import { action } from 'mobx'; -import { observer } from 'mobx-react-lite'; -import React from 'react'; -import { useStore } from '../../../components/StoreContext'; +import React, { FC } from 'react'; +import { useGameStore } from '../../../model/store'; const { Text } = Typography; -export const GhostDebugControls = observer(() => { - const store = useStore(); +export const GhostDebugControls: FC = () => { + const showTarget = useGameStore((state) => state.debugState.ghostViewOptions.target); + const showWayPoints = useGameStore((state) => state.debugState.ghostViewOptions.wayPoints); + const setGhostViewOption = useGameStore((state) => state.setGhostViewOption); + return (
{ - store.debugState.ghostViewOptions.target = checked; - })} + checked={showTarget} + onChange={(checked) => setGhostViewOption('target', checked)} /> @@ -26,10 +25,8 @@ export const GhostDebugControls = observer(() => { { - store.debugState.ghostViewOptions.wayPoints = checked; - })} + checked={showWayPoints} + onChange={(checked) => setGhostViewOption('wayPoints', checked)} /> @@ -39,4 +36,4 @@ export const GhostDebugControls = observer(() => {
); -}); +}; diff --git a/src/pages/GamePage/components/GhostDebugTable.tsx b/src/pages/GamePage/components/GhostDebugTable.tsx index 317c483b..80284bce 100644 --- a/src/pages/GamePage/components/GhostDebugTable.tsx +++ b/src/pages/GamePage/components/GhostDebugTable.tsx @@ -1,16 +1,31 @@ /* eslint-disable react/display-name */ import { Button, Row, Switch, Table } from 'antd'; import { ColumnsType } from 'antd/lib/table'; -import { action } from 'mobx'; -import { observer, Observer } from 'mobx-react-lite'; import React, { FC } from 'react'; import styled from 'styled-components'; import { ghostCollidesWithPacMan } from '../../../model/detectCollisions'; -import { Ghost } from '../../../model/Ghost'; -import { routeAndMoveGhost } from '../../../model/updateGhosts'; -import { useGame } from '../../../components/StoreContext'; +import { useGameStore, GhostState } from '../../../model/store'; +import { tileFromScreen } from '../../../model/Coordinates'; -const columns: ColumnsType = [ +// Create a wrapper component for each ghost row to get fresh data +const GhostStateCell: FC<{ ghostIndex: number; render: (ghost: GhostState) => React.ReactNode }> = ({ ghostIndex, render }) => { + const ghost = useGameStore((state) => state.game.ghosts[ghostIndex]); + return <>{render(ghost)}; +}; + +const TileXCell: FC<{ ghostIndex: number }> = ({ ghostIndex }) => { + const ghost = useGameStore((state) => state.game.ghosts[ghostIndex]); + const tile = tileFromScreen(ghost.screenCoordinates); + return <>{tile.x}; +}; + +const TileYCell: FC<{ ghostIndex: number }> = ({ ghostIndex }) => { + const ghost = useGameStore((state) => state.game.ghosts[ghostIndex]); + const tile = tileFromScreen(ghost.screenCoordinates); + return <>{tile.y}; +}; + +const columns: ColumnsType = [ { title: 'Nr', dataIndex: 'ghostNumber', @@ -20,7 +35,7 @@ const columns: ColumnsType = [ { title: 'Name', width: 80, - render: (ghost: Ghost) => ( + render: (_, ghost: GhostState) => (    @@ -32,98 +47,118 @@ const columns: ColumnsType = [ title: 'State', width: 80, align: 'center', - render: ghost => {() => ghost.state.toString()}, + render: (_, ghost: GhostState) => ( + g.state.toString()} + /> + ), }, { title: '# Changes', width: 80, align: 'right', - render: ghost => {() => ghost.stateChanges.toString()}, + render: (_, ghost: GhostState) => ( + g.stateChanges.toString()} + /> + ), }, { title: 'X', width: 32, align: 'right', - render: ghost => {(): any => ghost.tileCoordinates.x}, + render: (_, ghost: GhostState) => , }, { title: 'Y', width: 32, align: 'right', - render: ghost => {(): any => ghost.tileCoordinates.y}, + render: (_, ghost: GhostState) => , }, { title: 'Paused', align: 'center', - render: ghost => , + render: (_, ghost: GhostState) => , }, { title: '', align: 'center', width: 60, - render: ghost => , + render: (_, ghost: GhostState) => , }, { title: '', align: 'center', width: 60, - render: record => , + render: (_, ghost: GhostState) => , }, { title: '', - render: record => null, + render: () => null, }, ]; -const PausedSwitch: FC<{ ghost: Ghost }> = observer(({ ghost }) => ( - { - ghost.ghostPaused = checked; - }} - /> -)); +const PausedSwitch: FC<{ ghostIndex: number }> = ({ ghostIndex }) => { + const ghostPaused = useGameStore((state) => state.game.ghosts[ghostIndex].ghostPaused); + const setGhostPaused = useGameStore((state) => state.setGhostPaused); -const KillButton = observer<{ ghost: Ghost }>(({ ghost }) => ( - -)); + return ( + { + setGhostPaused(ghostIndex, checked); + }} + /> + ); +}; -const MoveButton = observer<{ ghost: Ghost }>(({ ghost }) => ( - -)); +const KillButton: FC<{ ghostIndex: number }> = ({ ghostIndex }) => { + const isFrightened = useGameStore((state) => state.game.ghosts[ghostIndex].state === 'frightened'); -export const GhostsDebugTable = observer<{ className?: string }>( - ({ className }) => { - const store = useGame(); - return ( - - ); - } -); + return ( + + ); +}; + +const MoveButton: FC<{ ghostIndex: number }> = ({ ghostIndex }) => { + // Move button is disabled for now since routeAndMoveGhost is no longer exported + // This is a debug feature that can be re-implemented if needed + return ( + + ); +}; + +export const GhostsDebugTable: FC<{ className?: string }> = ({ className }) => { + const ghosts = useGameStore((state) => state.game.ghosts); + + return ( +
+ ); +}; interface DotProps { color: string; diff --git a/src/pages/GamePage/components/GhostsDebugView.tsx b/src/pages/GamePage/components/GhostsDebugView.tsx index a0cc131f..1f87a8b3 100644 --- a/src/pages/GamePage/components/GhostsDebugView.tsx +++ b/src/pages/GamePage/components/GhostsDebugView.tsx @@ -1,11 +1,10 @@ import { Card } from 'antd'; -import { observer } from 'mobx-react-lite'; import React, { FC } from 'react'; import { GhostDebugControls } from './GhostDebugControls'; import { GhostsDebugTable } from './GhostDebugTable'; import { VSpace } from '../../../components/Spacer'; -export const GhostsDebugView: FC = observer(() => { +export const GhostsDebugView: FC = () => { return (
@@ -16,4 +15,4 @@ export const GhostsDebugView: FC = observer(() => {
); -}); +}; diff --git a/src/pages/GamePage/components/GhostsView.tsx b/src/pages/GamePage/components/GhostsView.tsx index ac38825f..2b28af2c 100644 --- a/src/pages/GamePage/components/GhostsView.tsx +++ b/src/pages/GamePage/components/GhostsView.tsx @@ -1,23 +1,24 @@ -import { observer } from 'mobx-react-lite'; import React, { FC } from 'react'; import { SCREEN_TILE_SIZE, SCREEN_TILE_CENTER, + tileFromScreen, } from '../../../model/Coordinates'; import { getGhostHitBox } from '../../../model/detectCollisions'; -import { - Ghost, - GhostAnimationPhase, - FrightenedGhostTime, -} from '../../../model/Ghost'; import { Direction } from '../../../model/Types'; import { WayPoints } from '../../WayFindingPage/WayPoints'; import { Box } from '../../../components/Box'; import { Sprite } from '../../../components/Sprite'; -import { useGame, useStore } from '../../../components/StoreContext'; +import { useGameStore, GhostState, FRIGHTENED_ABOUT_TO_END_DURATION } from '../../../model/store'; import { Target } from './Target'; import { GhostViewOptions } from '../../../model/GhostViewOptions'; import { GameViewOptions } from '../../../model/GameViewOptions'; +import { findWayPoints } from '../../../model/findWayPoints'; +import { canGhostPassThroughBoxDoor } from '../../../model/store/ghostHelpers'; + +// Ghost types +export type GhostAnimationPhase = 0 | 1; +export type FrightenedGhostTime = 0 | 1; const GHOST_WIDTH = SCREEN_TILE_SIZE * 2; const GHOST_HEIGHT = SCREEN_TILE_SIZE * 2; @@ -25,9 +26,9 @@ const GHOST_HEIGHT = SCREEN_TILE_SIZE * 2; const GHOST_OFFSET_X = GHOST_WIDTH / 2 - 0; const GHOST_OFFSET_Y = GHOST_HEIGHT / 2; -export const GhostsGameView = observer(() => { - const store = useStore(); - const { ghostViewOptions, gameViewOptions } = store.debugState; +export const GhostsGameView: FC = () => { + const ghostViewOptions = useGameStore((state) => state.debugState.ghostViewOptions); + const gameViewOptions = useGameStore((state) => state.debugState.gameViewOptions); return ( { gameViewOptions={gameViewOptions} /> ); -}); +}; export const GhostsView: FC<{ ghostViewOptions?: GhostViewOptions; gameViewOptions?: GameViewOptions; -}> = observer( - ({ - ghostViewOptions = DefaultGhostViewOptions, - gameViewOptions = DefaultGameViewOptions, - }) => { - const store = useGame(); - - return ( - <> - {store.ghosts.map(ghost => ( - - ))} - - ); - } -); +}> = ({ + ghostViewOptions = DefaultGhostViewOptions, + gameViewOptions = DefaultGameViewOptions, +}) => { + const ghosts = useGameStore((state) => state.game.ghosts); + + return ( + <> + {ghosts.map((ghost, index) => ( + + ))} + + ); +}; const DefaultGhostViewOptions: GhostViewOptions = { target: false, @@ -72,11 +71,25 @@ const DefaultGameViewOptions: GameViewOptions = { }; export const GhostCompositeView: FC<{ - ghost: Ghost; + ghostIndex: number; ghostViewOptions: GhostViewOptions; gameViewOptions: GameViewOptions; -}> = observer(({ ghost, ghostViewOptions, gameViewOptions }) => { - const { screenCoordinates } = ghost; +}> = ({ ghostIndex, ghostViewOptions, gameViewOptions }) => { + const ghost = useGameStore((state) => state.game.ghosts[ghostIndex]); + const timestamp = useGameStore((state) => state.game.timestamp); + const energizerTimeLeft = useGameStore((state) => { + const timer = state.game.energizerTimer; + return timer.duration - timer.timeSpent; + }); + + const { screenCoordinates, targetTile, direction, colorCode } = ghost; + const tileCoordinates = tileFromScreen(screenCoordinates); + const boxDoorIsOpen = canGhostPassThroughBoxDoor(ghost, timestamp); + + const wayPoints = ghostViewOptions.wayPoints + ? findWayPoints(tileCoordinates, targetTile, direction, boxDoorIsOpen) + : null; + return ( <> {gameViewOptions.hitBox && ( @@ -86,27 +99,51 @@ export const GhostCompositeView: FC<{ color="green" /> )} - - {ghostViewOptions.wayPoints && ( - + + {ghostViewOptions.wayPoints && wayPoints && ( + )} {ghostViewOptions.target && ( - + )} ); -}); +}; + +const getGhostAnimationPhase = (timestamp: number, ghostNumber: number): GhostAnimationPhase => { + return Math.round((timestamp + ghostNumber * 100) / 300) % 2 === 0 ? 0 : 1; +}; + +const getFrightenedGhostTime = ( + timestamp: number, + energizerTimeLeft: number +): FrightenedGhostTime => { + const frightenedAboutToEnd = energizerTimeLeft < FRIGHTENED_ABOUT_TO_END_DURATION; + if (!frightenedAboutToEnd) { + return 0; + } + // Blink every 0.5 seconds + return timestamp % 1000 < 500 ? 0 : 1; +}; export const GhostView: FC<{ - ghost: Ghost; -}> = observer(({ ghost }) => { - const { screenCoordinates, animationPhase, direction, ghostNumber } = ghost; - // TODO - switch (ghost.state) { + ghost: GhostState; + timestamp: number; + energizerTimeLeft: number; +}> = ({ ghost, timestamp, energizerTimeLeft }) => { + const { screenCoordinates, direction, ghostNumber, state } = ghost; + const animationPhase = getGhostAnimationPhase(timestamp, ghostNumber); + const frightenedGhostTime = getFrightenedGhostTime(timestamp, energizerTimeLeft); + + switch (state) { case 'frightened': return ( ); } -}); +}; type GhostSpriteProps = { direction: Direction; diff --git a/src/pages/GamePage/components/Message.tsx b/src/pages/GamePage/components/Message.tsx index f6eee203..616b1295 100644 --- a/src/pages/GamePage/components/Message.tsx +++ b/src/pages/GamePage/components/Message.tsx @@ -1,12 +1,9 @@ -import { observer } from 'mobx-react-lite'; -import React from 'react'; +import React, { FC } from 'react'; import styled from 'styled-components/macro'; -export const Message = observer<{ className?: string; text: string }>( - ({ className, text }) => { - return {text}; - } -); +export const Message: FC<{ className?: string; text: string }> = ({ className, text }) => { + return {text}; +}; const MessageStyled = styled.span` font-family: Joystix; diff --git a/src/pages/GamePage/components/PacManDebugView.tsx b/src/pages/GamePage/components/PacManDebugView.tsx index 615d4219..73ac061b 100644 --- a/src/pages/GamePage/components/PacManDebugView.tsx +++ b/src/pages/GamePage/components/PacManDebugView.tsx @@ -1,51 +1,59 @@ import { Button, Card, Space, Row, Col } from 'antd'; -import { observer } from 'mobx-react-lite'; -import React from 'react'; +import React, { FC } from 'react'; import styled from 'styled-components/macro'; import { ghostCollidesWithPacMan } from '../../../model/detectCollisions'; -import { useGame } from '../../../components/StoreContext'; - -export const PacManDebugView = observer<{ className?: string }>( - ({ className }) => { - const game = useGame(); - return ( - - - - {`State: ${game.pacMan.state}`} - - - - - - {game.pacMan.alive && ( - { - ghostCollidesWithPacMan(game.ghosts[0]); - }} - > - Kill - - )} - {game.pacMan.dead && ( - - Revive - - )} - - - - - - ); - } -); +import { useGameStore } from '../../../model/store'; + +export const PacManDebugView: FC<{ className?: string }> = ({ className }) => { + const pacManState = useGameStore((state) => state.game.pacMan.state); + const sendPacManEvent = useGameStore((state) => state.sendPacManEvent); + + const isDead = pacManState === 'dead'; + const isAlive = !isDead; + + const handleKill = () => { + ghostCollidesWithPacMan(0); // Collide with first ghost + }; + + const handleRevive = () => { + sendPacManEvent('REVIVED'); + }; + + return ( + + + + {`State: ${pacManState}`} + + + + + + {isAlive && ( + + Kill + + )} + {isDead && ( + + Revive + + )} + + + + + + ); +}; const Layout = styled.div``; diff --git a/src/pages/GamePage/components/PacManView.tsx b/src/pages/GamePage/components/PacManView.tsx index 1dd60441..26859f85 100644 --- a/src/pages/GamePage/components/PacManView.tsx +++ b/src/pages/GamePage/components/PacManView.tsx @@ -1,14 +1,12 @@ import React, { FC, CSSProperties } from 'react'; import { Sprite } from '../../../components/Sprite'; import { Direction } from '../../../model/Types'; -import { observer } from 'mobx-react-lite'; -import { useGame, useStore } from '../../../components/StoreContext'; +import { useGameStore } from '../../../model/store'; import { SCREEN_TILE_SIZE, SCREEN_TILE_CENTER, } from '../../../model/Coordinates'; import { Box } from '../../../components/Box'; -import { PacMan } from '../../../model/PacMan'; import { getPacManHitBox } from '../../../model/detectCollisions'; import { PacManDyingPhase, @@ -25,23 +23,28 @@ const PAC_MAN_HEIGHT = SCREEN_TILE_SIZE * 2; const PAC_MAN_OFFSET_X = PAC_MAN_WIDTH / 2 - 2; const PAC_MAN_OFFSET_Y = PAC_MAN_HEIGHT / 2 - 2; -export const PacManView: FC = observer(() => { - const store = useStore(); - const game = useGame(); - const pacMan = game.pacMan; - const { dead, alive, screenCoordinates, direction } = pacMan; - const { gameViewOptions } = store.debugState; - const pacManAnimationPhase = getPacManAnimationPhase(pacMan); - const dyingPhase = getPacManDyingPhase(pacMan); +export const PacManView: FC = () => { + const pacMan = useGameStore((state) => state.game.pacMan); + const timestamp = useGameStore((state) => state.game.timestamp); + const showHitBox = useGameStore((state) => state.debugState.gameViewOptions.hitBox); + + const isDead = pacMan.state === 'dead'; + const isAlive = !isDead; + const { screenCoordinates, direction, diedAtTimestamp } = pacMan; + const timeSinceDeath = isDead ? timestamp - diedAtTimestamp : 0; + + const pacManAnimationPhase = getPacManAnimationPhaseFromTimestamp(timestamp); + const dyingPhase = getPacManDyingPhase(timeSinceDeath); + return ( <> - {gameViewOptions.hitBox && ( + {showHitBox && ( )} - {alive && ( + {isAlive && ( { y={screenCoordinates.y + SCREEN_TILE_CENTER - PAC_MAN_OFFSET_Y} /> )} - {dead && ( + {isDead && ( { )} ); -}); +}; -const getPacManAnimationPhase = (pacMan: PacMan): PacManAnimationPhase => { - const step = Math.round(pacMan.game.timestamp / 200) % 4; +const getPacManAnimationPhaseFromTimestamp = (timestamp: number): PacManAnimationPhase => { + const step = Math.round(timestamp / 200) % 4; const phase = step === 3 ? 1 : step; return phase as PacManAnimationPhase; }; diff --git a/src/pages/GamePage/components/PillsView.tsx b/src/pages/GamePage/components/PillsView.tsx index a6958352..2c7b2349 100644 --- a/src/pages/GamePage/components/PillsView.tsx +++ b/src/pages/GamePage/components/PillsView.tsx @@ -1,4 +1,3 @@ -import { observer } from 'mobx-react-lite'; import React, { FC, memo } from 'react'; import { Box } from '../../../components/Box'; import { Sprite } from '../../../components/Sprite'; @@ -17,7 +16,7 @@ import { MAZE_WIDTH_IN_TILES, EMPTY_TILE_ID, } from '../../../model/MazeData'; -import { useGame } from '../../../components/StoreContext'; +import { useGameStore } from '../../../model/store'; const BasicPillView: FC<{ position: ScreenCoordinates }> = ({ position }) => ( @@ -32,47 +31,45 @@ export const BasicPillHitBox: FC = () => { return ; }; -const PillView = observer<{ tile: TileCoordinates }>( - ({ tile }: { tile: TileCoordinates }) => { - const game = useGame(); - const { x, y } = tile; - const tileId = game.maze.pills[y][x]; - if (tileId === BASIC_PILL_ID) { - return ( - - ); - } - if (tileId === ENERGIZER_ID) { - return ( - - ); - } - return null; +const PillView: FC<{ tile: TileCoordinates }> = ({ tile }) => { + const { x, y } = tile; + const tileId = useGameStore((state) => state.game.maze.pills[y][x]); + + if (tileId === BASIC_PILL_ID) { + return ( + + ); } -); + if (tileId === ENERGIZER_ID) { + return ( + + ); + } + return null; +}; // Performance tricks used here: -// Make each PillView an observer, so that we don't have to rerender PillsView. +// Make each PillView subscribe to only its specific tile. // Make PillsView a React.memo to prevent any rerenders. // Also: Create PillView only for those coordinates where there is a pill on first render. export const PillsView: FC = memo(() => { - const game = useGame(); + const pills = useGameStore((state) => state.game.maze.pills); return ( <> {Array.from({ length: MAZE_HEIGHT_IN_TILES }).map((_, y) => Array.from({ length: MAZE_WIDTH_IN_TILES }).map((_, x) => { - const pillFound = game.maze.pills[y][x] !== EMPTY_TILE_ID; + const pillFound = pills[y][x] !== EMPTY_TILE_ID; return pillFound && ; }) )} diff --git a/src/pages/GamePage/components/Score.tsx b/src/pages/GamePage/components/Score.tsx index 5bd058cc..4a0c429b 100644 --- a/src/pages/GamePage/components/Score.tsx +++ b/src/pages/GamePage/components/Score.tsx @@ -1,15 +1,14 @@ -import { observer } from 'mobx-react-lite'; -import React from 'react'; -import { useGame } from '../../../components/StoreContext'; +import React, { FC } from 'react'; +import { useGameStore } from '../../../model/store'; import './Score.css'; import classNames from 'classnames'; -export const Score = observer<{ className?: string }>(({ className }) => { - const store = useGame(); +export const Score: FC<{ className?: string }> = ({ className }) => { + const score = useGameStore((state) => state.game.score); return (
Score - {store.score} + {score}
); -}); +}; diff --git a/src/pages/GamePage/components/useKeyboardActions.ts b/src/pages/GamePage/components/useKeyboardActions.ts index 6d69091f..b0427aa9 100644 --- a/src/pages/GamePage/components/useKeyboardActions.ts +++ b/src/pages/GamePage/components/useKeyboardActions.ts @@ -1,34 +1,35 @@ import { useCallback, useEffect } from 'react'; -import { useStore } from '../../../components/StoreContext'; +import { useGameStore } from '../../../model/store'; /* eslint-disable react-hooks/exhaustive-deps */ export const useKeyboardActions = (): void => { - const store = useStore(); + const setPacManNextDirection = useGameStore((state) => state.setPacManNextDirection); + const setGamePaused = useGameStore((state) => state.setGamePaused); const onKeyDown = useCallback((event: KeyboardEvent) => { - const { game } = store; const pressedKey = event.key; - const pacMan = game.pacMan; + const gamePaused = useGameStore.getState().game.gamePaused; + switch (pressedKey) { case 'ArrowLeft': - pacMan.nextDirection = 'LEFT'; + setPacManNextDirection('LEFT'); break; case 'ArrowRight': - pacMan.nextDirection = 'RIGHT'; + setPacManNextDirection('RIGHT'); break; case 'ArrowUp': - pacMan.nextDirection = 'UP'; + setPacManNextDirection('UP'); break; case 'ArrowDown': - pacMan.nextDirection = 'DOWN'; + setPacManNextDirection('DOWN'); break; case ' ': - game.gamePaused = !game.gamePaused; + setGamePaused(!gamePaused); break; default: break; } - }, []); + }, [setPacManNextDirection, setGamePaused]); useEffect(() => { document.addEventListener('keydown', onKeyDown); @@ -36,5 +37,5 @@ export const useKeyboardActions = (): void => { return () => { document.removeEventListener('keydown', onKeyDown); }; - }, []); + }, [onKeyDown]); }; diff --git a/src/pages/SpritePage/SpritePage.tsx b/src/pages/SpritePage/SpritePage.tsx index 6299e5ec..cc06b32a 100644 --- a/src/pages/SpritePage/SpritePage.tsx +++ b/src/pages/SpritePage/SpritePage.tsx @@ -18,7 +18,7 @@ import { GhostAnimationPhase, GhostNumber, FrightenedGhostTimes, -} from '../../model/Ghost'; +} from '../../model/store/types'; import styled from 'styled-components/macro'; import { PacManDyingPhaseCount, diff --git a/src/pages/WayFindingPage/WayFindingPage.tsx b/src/pages/WayFindingPage/WayFindingPage.tsx index 740c1c9c..9180df75 100644 --- a/src/pages/WayFindingPage/WayFindingPage.tsx +++ b/src/pages/WayFindingPage/WayFindingPage.tsx @@ -1,5 +1,5 @@ /* eslint-disable react/no-unescaped-entities */ -import React from 'react'; +import React, { useState, FC } from 'react'; import { Sprite } from '../../components/Sprite'; import { GridWithHoverCoordinates } from '../../components/Grid'; @@ -10,29 +10,17 @@ import { TileCoordinates, SCREEN_TILE_CENTER, } from '../../model/Coordinates'; -import { useLocalStore, observer } from 'mobx-react-lite'; -import { action } from 'mobx'; import { WayPoints } from './WayPoints'; import { findWayPoints } from '../../model/findWayPoints'; import styled from 'styled-components/macro'; import { Row } from 'antd'; -export const WayFindingPage = observer(() => { - const localStore = useLocalStore(() => ({ - origin: { x: 1, y: 1 } as TileCoordinates, - destination: { x: 6, y: 15 } as TileCoordinates, - setOrigin: action((value: TileCoordinates) => { - localStore.origin = value; - }), - setDestination: action((value: TileCoordinates) => { - localStore.destination = value; - }), - })); +export const WayFindingPage: FC = () => { + const [origin, setOrigin] = useState({ x: 1, y: 1 }); + const [destination, setDestination] = useState({ x: 6, y: 15 }); - const wayPoints = - findWayPoints(localStore.origin, localStore.destination, 'RIGHT', true) ?? - []; + const wayPoints = findWayPoints(origin, destination, 'RIGHT', true) ?? []; return ( @@ -47,9 +35,9 @@ export const WayFindingPage = observer(() => { event: React.MouseEvent ) => { if (event.shiftKey) { - localStore.setOrigin(coordinates); + setOrigin(coordinates); } else { - localStore.setDestination(coordinates); + setDestination(coordinates); } }} /> @@ -58,11 +46,11 @@ export const WayFindingPage = observer(() => { direction="RIGHT" ghostAnimationPhase={1} x={ - screenFromTileCoordinate(localStore.origin.x - 1) + + screenFromTileCoordinate(origin.x - 1) + SCREEN_TILE_CENTER } y={ - screenFromTileCoordinate(localStore.origin.y - 1) + + screenFromTileCoordinate(origin.y - 1) + SCREEN_TILE_CENTER } ghostNumber={0} @@ -72,11 +60,11 @@ export const WayFindingPage = observer(() => { direction="RIGHT" pacManAnimationPhase={1} x={ - screenFromTileCoordinate(localStore.destination.x - 1) + + screenFromTileCoordinate(destination.x - 1) + SCREEN_TILE_CENTER } y={ - screenFromTileCoordinate(localStore.destination.y - 1) + + screenFromTileCoordinate(destination.y - 1) + SCREEN_TILE_CENTER } style={{}} @@ -91,7 +79,7 @@ export const WayFindingPage = observer(() => { ); -}); +}; const Layout = styled.div` margin-top: 32px; diff --git a/src/pages/WayFindingPage/WayPoints.tsx b/src/pages/WayFindingPage/WayPoints.tsx index 3695ddab..9b302763 100644 --- a/src/pages/WayFindingPage/WayPoints.tsx +++ b/src/pages/WayFindingPage/WayPoints.tsx @@ -1,16 +1,15 @@ /* eslint-disable react/no-unescaped-entities */ -import { observer } from 'mobx-react-lite'; -import React from 'react'; +import React, { FC } from 'react'; import { screenFromTile, TileCoordinates } from '../../model/Coordinates'; import { WayPoint } from './WayPoint'; import { getDirectionFromTileToTile } from '../../model/getDirectionFromTileToTile'; import { Direction } from '../../model/Types'; import { assert } from '../../util/assert'; -export const WayPoints = observer<{ +export const WayPoints: FC<{ wayPoints: TileCoordinates[]; color: string; -}>(({ wayPoints, color }) => ( +}> = ({ wayPoints, color }) => ( <> {wayPoints.map((wayPoint, index) => { const screenCoordinates = screenFromTile(wayPoint); @@ -25,7 +24,7 @@ export const WayPoints = observer<{ ); })} -)); +); const getDirection = ( wayPoints: TileCoordinates[], diff --git a/src/test-util/TestApp.tsx b/src/test-util/TestApp.tsx index fb08f7f6..b81088a4 100644 --- a/src/test-util/TestApp.tsx +++ b/src/test-util/TestApp.tsx @@ -1,15 +1,19 @@ -import React, { FC } from 'react'; +import React, { FC, useEffect } from 'react'; import { MemoryRouter } from 'react-router-dom'; -import { Store } from '../model/Store'; import App from '../App'; +import { useGameStore } from '../model/store'; -export const TestApp: FC<{ store?: Store; route?: string }> = ({ - store = new Store(), +export const TestApp: FC<{ route?: string }> = ({ route = '/', }) => { + // Reset store state before each test render + useEffect(() => { + useGameStore.getState().resetGame(); + }, []); + const Router: FC> = ({ children }) => ( {children} ); - return ; + return ; }; diff --git a/yarn.lock b/yarn.lock index 8266f697..26112727 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5656,6 +5656,11 @@ ignore@^5.2.0: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== +immer@^11.1.3: + version "11.1.3" + resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.3.tgz#78681e1deb6cec39753acf04eb16d7576c04f4d6" + integrity sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q== + immer@^9.0.7: version "9.0.21" resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.21.tgz#1e025ea31a40f24fb064f1fef23e931496330176" @@ -7122,18 +7127,6 @@ mkdirp@~0.5.1: dependencies: minimist "^1.2.6" -mobx-react-lite@^4.0.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-4.1.1.tgz#725d74b025235f73dc2ab815766ffe010be2cf8a" - integrity sha512-iUxiMpsvNraCKXU+yPotsOncNNmyeS2B5DKL+TL6Tar/xm+wwNJAubJmtRSeAoYawdZqwv8Z/+5nPRHeQxTiXg== - dependencies: - use-sync-external-store "^1.4.0" - -mobx@^6.12.0: - version "6.15.0" - resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.15.0.tgz#78b9b82d383724eebb4b6e50c2eb4ae2da861cb5" - integrity sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g== - moment@^2.24.0, moment@^2.29.2: version "2.30.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" @@ -10420,11 +10413,6 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" -use-sync-external-store@^1.4.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" - integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== - util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -10974,11 +10962,6 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== -xstate@^4.7.5: - version "4.38.3" - resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.38.3.tgz#4e15e7ad3aa0ca1eea2010548a5379966d8f1075" - integrity sha512-SH7nAaaPQx57dx6qvfcIgqKRXIh4L0A1iYEqim4s1u7c9VoCgzZc+63FY90AKU4ZzOC2cfJzTnpO4zK7fCUzzw== - y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" @@ -11016,3 +10999,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zustand@^5.0.10: + version "5.0.10" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.10.tgz#4db510c0c4c25a5f1ae43227b307ddf1641a3210" + integrity sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==