From 4d21127d887aa02d89f81dbbcbc5139dec222448 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Jan 2026 17:54:06 +0000 Subject: [PATCH 1/4] Migrate state management from MobX/XState to Zustand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace MobX observable classes and XState state machines with a centralized Zustand store using Immer middleware for immutable updates. Key changes: - Create new src/model/store/ directory with Zustand store structure - gameStore.ts: main store with state and actions - types.ts: TypeScript interfaces for all state - initialState.ts: factory functions for initial state - ghostHelpers.ts: computed property helpers for ghosts - constants.ts: game timing constants - Implement state machine transitions directly in Zustand actions - sendPacManEvent: eating → chasing → dead - sendGhostEvent: scatter ↔ chase ↔ frightened → dead - Update all game logic to use useGameStore.getState() pattern - Remove observer HOC from all React components - Convert components to use useGameStore hooks with selectors - Update test files to use new store reset pattern - Convert WayFindingPage from MobX local store to React useState The main application now exclusively uses Zustand. Legacy MobX classes are retained for isolated unit tests of the original class behavior. --- CLAUDE.md | 27 +- package.json | 4 +- src/App.tsx | 19 +- src/components/StoreContext.ts | 26 +- src/model/detectCollisions.test.ts | 64 ++-- src/model/detectCollisions.ts | 79 ++-- src/model/eatEnergizer.ts | 28 +- src/model/onAnimationFrame.ts | 52 ++- src/model/pacManDyingPhase.ts | 5 +- src/model/simulateFrames.ts | 41 +- src/model/store/constants.ts | 9 + src/model/store/gameStore.ts | 345 +++++++++++++++++ src/model/store/ghostHelpers.ts | 136 +++++++ src/model/store/index.ts | 5 + src/model/store/initialState.ts | 149 ++++++++ src/model/store/types.ts | 150 ++++++++ src/model/updateEnergizerTimer.ts | 43 ++- src/model/updateGhosts.test.ts | 360 ++++++++++-------- src/model/updateGhosts.ts | 230 +++++++---- src/model/updatePacMan.test.ts | 283 ++++++++------ src/model/updatePacMan.ts | 122 ++++-- src/model/useGameLoop.ts | 6 +- src/pages/GamePage/GamePage.tsx | 15 +- .../components/EnergizerDebugView.tsx | 71 ++-- src/pages/GamePage/components/ExtraLives.tsx | 14 +- src/pages/GamePage/components/FPS.tsx | 13 +- .../GamePage/components/GameDebugView.tsx | 86 ++--- src/pages/GamePage/components/GameOver.tsx | 24 +- .../components/GhostDebugControls.tsx | 27 +- .../GamePage/components/GhostDebugTable.tsx | 157 +++++--- .../GamePage/components/GhostsDebugView.tsx | 5 +- src/pages/GamePage/components/GhostsView.tsx | 131 ++++--- src/pages/GamePage/components/Message.tsx | 11 +- .../GamePage/components/PacManDebugView.tsx | 98 ++--- src/pages/GamePage/components/PacManView.tsx | 37 +- src/pages/GamePage/components/PillsView.tsx | 61 ++- src/pages/GamePage/components/Score.tsx | 13 +- .../GamePage/components/useKeyboardActions.ts | 23 +- src/pages/WayFindingPage/WayFindingPage.tsx | 36 +- src/pages/WayFindingPage/WayPoints.tsx | 9 +- src/test-util/TestApp.tsx | 14 +- yarn.lock | 10 + 42 files changed, 2114 insertions(+), 924 deletions(-) create mode 100644 src/model/store/constants.ts create mode 100644 src/model/store/gameStore.ts create mode 100644 src/model/store/ghostHelpers.ts create mode 100644 src/model/store/index.ts create mode 100644 src/model/store/initialState.ts create mode 100644 src/model/store/types.ts 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..81850e88 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@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", @@ -24,7 +25,8 @@ "react-scripts": "5.0.1", "styled-components": "^5.2.1", "typescript": "^4.9.5", - "xstate": "^4.7.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/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/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/constants.ts b/src/model/store/constants.ts new file mode 100644 index 00000000..caca8775 --- /dev/null +++ b/src/model/store/constants.ts @@ -0,0 +1,9 @@ +import { MilliSeconds } from '../Types'; + +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..f2988260 --- /dev/null +++ b/src/model/store/gameStore.ts @@ -0,0 +1,345 @@ +import { create } from 'zustand'; +import { immer } from 'zustand/middleware/immer'; +import { screenFromTile, tileFromScreen } from '../Coordinates'; +import { getPillsMatrix } from '../MazeData'; +import { + Store, + PacManEventType, + GhostEventType, + GhostStateValue, + PacManStateValue, + INITIAL_PACMAN_STATE, + 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..16a5f432 --- /dev/null +++ b/src/model/store/ghostHelpers.ts @@ -0,0 +1,136 @@ +import { TileCoordinates, tileFromScreen, screenFromTile } 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..9bdd2bd8 --- /dev/null +++ b/src/model/store/initialState.ts @@ -0,0 +1,149 @@ +import { screenFromTile } from '../Coordinates'; +import { getPillsMatrix } from '../MazeData'; +import { DEFAULT_SPEED } from '../Game'; +import { + StoreState, + GameState, + PacManState, + GhostState, + MazeState, + DebugState, + TimerState, + INITIAL_PACMAN_STATE, + INITIAL_GHOST_STATE, + GhostNumber, +} from './types'; +import { getStatePhaseLength } from '../updateGhostStatePhase'; + +const ENERGIZER_DURATION = 5000; +const DEAD_WAITING_IN_BOX_DURATION = 3000; + +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/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..d190d055 100644 --- a/src/model/updateGhosts.ts +++ b/src/model/updateGhosts.ts @@ -4,78 +4,185 @@ 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, isTileCenter, 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 => { + // Create a ghost-like object for chooseNewTargetTile + const store = useGameStore.getState(); + const game = store.game; + + // Build a compatible ghost object for the existing chooseNewTargetTile function + const ghostLike = { + state: ghostData.state, + ghostNumber: ghostData.ghostNumber, + tileCoordinates: ghostData.tileCoordinates, + direction: ghostData.direction, + isInsideBoxWalls: ghostData.isInsideBoxWalls, + deadWaitingTimeInBoxLeft: ghostData.deadWaitingTimeInBoxLeft, + game: { + pacMan: { + tileCoordinates: tileFromScreen(game.pacMan.screenCoordinates), + direction: game.pacMan.direction, + }, + ghosts: game.ghosts.map((g) => ({ + tileCoordinates: tileFromScreen(g.screenCoordinates), + })), + }, + }; + + return chooseNewTargetTile(ghostLike as any); }; -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 +194,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..22d7c1d6 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, PacManState, GhostState, 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/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..6867cf10 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" @@ -11016,3 +11021,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== From 0f7f65f90fff3a82739f57622ef24df13d8f786e Mon Sep 17 00:00:00 2001 From: Stefan Wille Date: Sun, 25 Jan 2026 19:08:38 +0100 Subject: [PATCH 2/4] Refactor store imports to use LegacyStore Updated all relevant test files and model components to import from LegacyStore instead of the removed Store class. This change ensures compatibility with the new Zustand state management system while retaining legacy functionality for unit tests. --- src/model/DebugState.ts | 2 +- src/model/Game.ts | 2 +- src/model/Ghost.test.ts | 2 +- src/model/{Store.ts => LegacyStore.ts} | 0 src/model/PacMan.test.ts | 2 +- src/model/changeDirectionToOpposite.test.ts | 2 +- src/model/chooseNewTargetTile.test.ts | 2 +- src/model/getGhostDestination.test.ts | 2 +- src/model/movePacManBy.test.ts | 2 +- src/model/store/constants.ts | 4 +++- src/model/store/initialState.ts | 20 ++++++++++++++++++-- 11 files changed, 29 insertions(+), 11 deletions(-) rename src/model/{Store.ts => LegacyStore.ts} (100%) diff --git a/src/model/DebugState.ts b/src/model/DebugState.ts index 3d93b056..b2daafd2 100644 --- a/src/model/DebugState.ts +++ b/src/model/DebugState.ts @@ -1,5 +1,5 @@ import { observable, makeObservable } from 'mobx'; -import { Store } from './Store'; +import { Store } from './LegacyStore'; import { GhostViewOptions } from './GhostViewOptions'; import { PacManViewOptions } from '../pages/GamePage/components/PacManViewOptions'; import { GameViewOptions } from './GameViewOptions'; diff --git a/src/model/Game.ts b/src/model/Game.ts index f099c804..9df7d87c 100644 --- a/src/model/Game.ts +++ b/src/model/Game.ts @@ -4,7 +4,7 @@ import { makeGhosts, resetGhosts } from './makeGhosts'; import { Maze } from './Maze'; import { PacMan, resetPacMan } from './PacMan'; import { MilliSeconds, PixelsPerFrame } from './Types'; -import { Store } from './Store'; +import { Store } from './LegacyStore'; import { TimeoutTimer } from './TimeoutTimer'; export const DEFAULT_SPEED = 2; diff --git a/src/model/Ghost.test.ts b/src/model/Ghost.test.ts index 7ddb938a..33f6e4b9 100644 --- a/src/model/Ghost.test.ts +++ b/src/model/Ghost.test.ts @@ -1,6 +1,6 @@ import { Game } from './Game'; import { Ghost } from './Ghost'; -import { Store } from './Store'; +import { Store } from './LegacyStore'; describe('Ghost', () => { describe('when killing pac man', () => { diff --git a/src/model/Store.ts b/src/model/LegacyStore.ts similarity index 100% rename from src/model/Store.ts rename to src/model/LegacyStore.ts diff --git a/src/model/PacMan.test.ts b/src/model/PacMan.test.ts index 80cdf9c5..86783494 100644 --- a/src/model/PacMan.test.ts +++ b/src/model/PacMan.test.ts @@ -1,5 +1,5 @@ import { Game } from './Game'; -import { Store } from './Store'; +import { Store } from './LegacyStore'; // import { PacManStore } from "./PacManStore"; export {}; diff --git a/src/model/changeDirectionToOpposite.test.ts b/src/model/changeDirectionToOpposite.test.ts index ba24e98c..80a81957 100644 --- a/src/model/changeDirectionToOpposite.test.ts +++ b/src/model/changeDirectionToOpposite.test.ts @@ -1,6 +1,6 @@ import { changeDirectionToOpposite } from './changeDirectionToOpposite'; import { Game } from './Game'; -import { Store } from './Store'; +import { Store } from './LegacyStore'; describe('changeDirectionToOpposite', () => { describe('changeDirectionToOpposite()', () => { diff --git a/src/model/chooseNewTargetTile.test.ts b/src/model/chooseNewTargetTile.test.ts index 9e36a640..973fb4b8 100644 --- a/src/model/chooseNewTargetTile.test.ts +++ b/src/model/chooseNewTargetTile.test.ts @@ -6,7 +6,7 @@ import { TILE_FOR_RETURNING_TO_BOX, SCATTER_TILE_FOR_GHOST_0, } from './chooseNewTargetTile'; -import { Store } from './Store'; +import { Store } from './LegacyStore'; const TILE_OUTSIDE_THE_BOX: TileCoordinates = { x: 13, y: 11 }; diff --git a/src/model/getGhostDestination.test.ts b/src/model/getGhostDestination.test.ts index 198958dc..eeee4a54 100644 --- a/src/model/getGhostDestination.test.ts +++ b/src/model/getGhostDestination.test.ts @@ -1,6 +1,6 @@ import { Game } from './Game'; import { getGhostDestination } from './getGhostDestination'; -import { Store } from './Store'; +import { Store } from './LegacyStore'; describe('getGhostDestination', () => { describe('ghost 0', () => { diff --git a/src/model/movePacManBy.test.ts b/src/model/movePacManBy.test.ts index 128ff5a4..7b5d21c6 100644 --- a/src/model/movePacManBy.test.ts +++ b/src/model/movePacManBy.test.ts @@ -1,6 +1,6 @@ import { Game } from './Game'; import { SCREEN_TILE_SIZE } from './Coordinates'; -import { Store } from './Store'; +import { Store } from './LegacyStore'; import { movePacManBy } from './movePacManBy'; describe('movePacManBy()', () => { diff --git a/src/model/store/constants.ts b/src/model/store/constants.ts index caca8775..9c9fa2ce 100644 --- a/src/model/store/constants.ts +++ b/src/model/store/constants.ts @@ -1,4 +1,6 @@ -import { MilliSeconds } from '../Types'; +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; diff --git a/src/model/store/initialState.ts b/src/model/store/initialState.ts index 9bdd2bd8..7ab9e8f9 100644 --- a/src/model/store/initialState.ts +++ b/src/model/store/initialState.ts @@ -1,6 +1,5 @@ import { screenFromTile } from '../Coordinates'; import { getPillsMatrix } from '../MazeData'; -import { DEFAULT_SPEED } from '../Game'; import { StoreState, GameState, @@ -12,8 +11,25 @@ import { INITIAL_PACMAN_STATE, INITIAL_GHOST_STATE, GhostNumber, + GhostStateValue, } from './types'; -import { getStatePhaseLength } from '../updateGhostStatePhase'; +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; const DEAD_WAITING_IN_BOX_DURATION = 3000; From 2c153be360ef6fdecf5e513c7e8fab6beaad4500 Mon Sep 17 00:00:00 2001 From: Stefan Wille Date: Sun, 25 Jan 2026 19:26:58 +0100 Subject: [PATCH 3/4] Phase 1 --- src/model/chooseNewTargetTile.test.ts | 192 +++++++++++++------------- src/model/chooseNewTargetTile.ts | 113 +++++++-------- src/model/updateGhosts.ts | 33 ++--- 3 files changed, 170 insertions(+), 168 deletions(-) diff --git a/src/model/chooseNewTargetTile.test.ts b/src/model/chooseNewTargetTile.test.ts index 973fb4b8..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 './LegacyStore'; 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/updateGhosts.ts b/src/model/updateGhosts.ts index d190d055..f3e19810 100644 --- a/src/model/updateGhosts.ts +++ b/src/model/updateGhosts.ts @@ -1,4 +1,4 @@ -import { chooseNewTargetTile } from './chooseNewTargetTile'; +import { chooseNewTargetTile, GhostTargetingContext } from './chooseNewTargetTile'; import { chooseNextTile } from './chooseNextTile'; import { TileCoordinates, @@ -134,30 +134,25 @@ const reRouteGhost = (ghostIndex: number, ghostData: GhostData) => { }; const chooseNewTargetTileForGhost = (ghostIndex: number, ghostData: GhostData): TileCoordinates => { - // Create a ghost-like object for chooseNewTargetTile const store = useGameStore.getState(); const game = store.game; - // Build a compatible ghost object for the existing chooseNewTargetTile function - const ghostLike = { - state: ghostData.state, - ghostNumber: ghostData.ghostNumber, - tileCoordinates: ghostData.tileCoordinates, - direction: ghostData.direction, - isInsideBoxWalls: ghostData.isInsideBoxWalls, - deadWaitingTimeInBoxLeft: ghostData.deadWaitingTimeInBoxLeft, - game: { - pacMan: { - tileCoordinates: tileFromScreen(game.pacMan.screenCoordinates), - direction: game.pacMan.direction, - }, - ghosts: game.ghosts.map((g) => ({ - tileCoordinates: tileFromScreen(g.screenCoordinates), - })), + 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(ghostLike as any); + return chooseNewTargetTile(ctx); }; const updateDirection = (ghostIndex: number) => { From 9b4ab56dc715bc1b5ac914036046b4c68a37dbab Mon Sep 17 00:00:00 2001 From: Stefan Wille Date: Sun, 25 Jan 2026 20:14:53 +0100 Subject: [PATCH 4/4] Phase 3 --- package.json | 3 - src/model/DebugState.ts | 30 --- src/model/Game.test.ts | 10 - src/model/Game.ts | 83 ------- src/model/Ghost.test.ts | 48 ---- src/model/Ghost.ts | 231 -------------------- src/model/GhostStateChart.test.ts | 62 ------ src/model/GhostStateChart.ts | 95 -------- src/model/IntervalTimer.test.ts | 130 ----------- src/model/IntervalTimer.ts | 66 ------ src/model/LegacyStore.ts | 20 -- src/model/Maze.ts | 11 - src/model/PacMan.test.ts | 31 --- src/model/PacMan.ts | 115 ---------- src/model/PacManStateChart.test.ts | 45 ---- src/model/PacManStateChart.ts | 73 ------- src/model/TimeoutTimer.test.ts | 154 ------------- src/model/TimeoutTimer.ts | 66 ------ src/model/Types.test.ts | 2 +- src/model/changeDirectionToOpposite.test.ts | 22 -- src/model/changeDirectionToOpposite.ts | 6 - src/model/chooseNextTile.ts | 5 +- src/model/getGhostDestination.test.ts | 16 -- src/model/getGhostDestination.ts | 12 - src/model/makeGhosts.ts | 54 ----- src/model/movePacManBy.test.ts | 42 ---- src/model/movePacManBy.ts | 15 -- src/model/store/Game.test.ts | 127 +++++++++++ src/model/store/Ghost.test.ts | 164 ++++++++++++++ src/model/store/PacMan.test.ts | 113 ++++++++++ src/model/store/gameStore.ts | 2 - src/model/store/ghostHelpers.ts | 2 +- src/model/store/initialState.ts | 1 - src/model/updateExternalTimeStamp.ts | 24 -- src/model/updateGameTimestamp.ts | 6 - src/model/updateGhostStatePhase.ts | 41 ---- src/model/updateGhosts.ts | 2 +- src/model/updatePacMan.ts | 2 +- src/pages/SpritePage/SpritePage.tsx | 2 +- yarn.lock | 22 -- 40 files changed, 411 insertions(+), 1544 deletions(-) delete mode 100644 src/model/DebugState.ts delete mode 100644 src/model/Game.test.ts delete mode 100644 src/model/Game.ts delete mode 100644 src/model/Ghost.test.ts delete mode 100644 src/model/Ghost.ts delete mode 100644 src/model/GhostStateChart.test.ts delete mode 100644 src/model/GhostStateChart.ts delete mode 100644 src/model/IntervalTimer.test.ts delete mode 100644 src/model/IntervalTimer.ts delete mode 100644 src/model/LegacyStore.ts delete mode 100644 src/model/Maze.ts delete mode 100644 src/model/PacMan.test.ts delete mode 100644 src/model/PacMan.ts delete mode 100644 src/model/PacManStateChart.test.ts delete mode 100644 src/model/PacManStateChart.ts delete mode 100644 src/model/TimeoutTimer.test.ts delete mode 100644 src/model/TimeoutTimer.ts delete mode 100644 src/model/changeDirectionToOpposite.test.ts delete mode 100644 src/model/changeDirectionToOpposite.ts delete mode 100644 src/model/getGhostDestination.test.ts delete mode 100644 src/model/getGhostDestination.ts delete mode 100644 src/model/makeGhosts.ts delete mode 100644 src/model/movePacManBy.test.ts delete mode 100644 src/model/movePacManBy.ts create mode 100644 src/model/store/Game.test.ts create mode 100644 src/model/store/Ghost.test.ts create mode 100644 src/model/store/PacMan.test.ts delete mode 100644 src/model/updateExternalTimeStamp.ts delete mode 100644 src/model/updateGameTimestamp.ts delete mode 100644 src/model/updateGhostStatePhase.ts diff --git a/package.json b/package.json index 81850e88..53bbb4a3 100644 --- a/package.json +++ b/package.json @@ -17,15 +17,12 @@ "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": { diff --git a/src/model/DebugState.ts b/src/model/DebugState.ts deleted file mode 100644 index b2daafd2..00000000 --- a/src/model/DebugState.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { observable, makeObservable } from 'mobx'; -import { Store } from './LegacyStore'; -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 9df7d87c..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 './LegacyStore'; -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 33f6e4b9..00000000 --- a/src/model/Ghost.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Game } from './Game'; -import { Ghost } from './Ghost'; -import { Store } from './LegacyStore'; - -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/LegacyStore.ts b/src/model/LegacyStore.ts deleted file mode 100644 index 4599d9cc..00000000 --- a/src/model/LegacyStore.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/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 86783494..00000000 --- a/src/model/PacMan.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Game } from './Game'; -import { Store } from './LegacyStore'; - -// 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/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 80a81957..00000000 --- a/src/model/changeDirectionToOpposite.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { changeDirectionToOpposite } from './changeDirectionToOpposite'; -import { Game } from './Game'; -import { Store } from './LegacyStore'; - -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/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/getGhostDestination.test.ts b/src/model/getGhostDestination.test.ts deleted file mode 100644 index eeee4a54..00000000 --- a/src/model/getGhostDestination.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Game } from './Game'; -import { getGhostDestination } from './getGhostDestination'; -import { Store } from './LegacyStore'; - -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 7b5d21c6..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 './LegacyStore'; -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/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/gameStore.ts b/src/model/store/gameStore.ts index f2988260..9e38ee2c 100644 --- a/src/model/store/gameStore.ts +++ b/src/model/store/gameStore.ts @@ -1,14 +1,12 @@ import { create } from 'zustand'; import { immer } from 'zustand/middleware/immer'; import { screenFromTile, tileFromScreen } from '../Coordinates'; -import { getPillsMatrix } from '../MazeData'; import { Store, PacManEventType, GhostEventType, GhostStateValue, PacManStateValue, - INITIAL_PACMAN_STATE, INITIAL_GHOST_STATE, } from './types'; import { diff --git a/src/model/store/ghostHelpers.ts b/src/model/store/ghostHelpers.ts index 16a5f432..c06ec478 100644 --- a/src/model/store/ghostHelpers.ts +++ b/src/model/store/ghostHelpers.ts @@ -1,4 +1,4 @@ -import { TileCoordinates, tileFromScreen, screenFromTile } from '../Coordinates'; +import { TileCoordinates, tileFromScreen } from '../Coordinates'; import { isTileCenter, isTileInBox as isTileInBoxWalls, isTileInBoxSpace } from '../Ways'; import { useGameStore } from './gameStore'; import { GhostState, GhostStateValue, PacManState } from './types'; diff --git a/src/model/store/initialState.ts b/src/model/store/initialState.ts index 7ab9e8f9..261648d0 100644 --- a/src/model/store/initialState.ts +++ b/src/model/store/initialState.ts @@ -32,7 +32,6 @@ const getStatePhaseLength = (state: GhostStateValue): number => { }; const ENERGIZER_DURATION = 5000; -const DEAD_WAITING_IN_BOX_DURATION = 3000; export const createTimerState = (duration: number): TimerState => ({ running: false, 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.ts b/src/model/updateGhosts.ts index f3e19810..c65fe234 100644 --- a/src/model/updateGhosts.ts +++ b/src/model/updateGhosts.ts @@ -8,7 +8,7 @@ import { } from './Coordinates'; import { getDirectionFromTileToTile } from './getDirectionFromTileToTile'; import { Direction } from './Types'; -import { directionToVector, isTileCenter, DIRECTION_TO_OPPOSITE_DIRECTION } from './Ways'; +import { directionToVector, DIRECTION_TO_OPPOSITE_DIRECTION } from './Ways'; import { Vector } from './Vector'; import { useGameStore, diff --git a/src/model/updatePacMan.ts b/src/model/updatePacMan.ts index 22d7c1d6..6fe12489 100644 --- a/src/model/updatePacMan.ts +++ b/src/model/updatePacMan.ts @@ -4,7 +4,7 @@ import { MAZE_WIDTH_IN_SCREEN_COORDINATES, screenFromTile, } from './Coordinates'; -import { useGameStore, PacManState, GhostState, INITIAL_PACMAN_STATE } from './store'; +import { useGameStore, INITIAL_PACMAN_STATE } from './store'; import { MilliSeconds } from './Types'; import { directionToVector as directionAsVector, 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/yarn.lock b/yarn.lock index 6867cf10..26112727 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7127,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" @@ -10425,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" @@ -10979,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"