Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ yarn lint # ESLint
```

Run single test:

```bash
yarn test -- --testPathPattern="Game.test"
yarn test:ci src/model/Game.test.ts
```

Pre-push hook runs: `yarn compile && yarn lint && yarn test:ci`
Expand All @@ -25,36 +26,42 @@ Pre-push hook runs: `yarn compile && yarn lint && yarn test:ci`
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()`

### 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.)

### Game Loop

- `useGameLoop` hook drives animation via requestAnimationFrame
- `onAnimationFrame` updates PacMan, Ghosts, timers each frame
- `TimeoutTimer` / `IntervalTimer` for game timing (energizer duration, state phases)

### Coordinate System

- `TileCoordinates` - grid position (x, y in tiles)
- `ScreenCoordinates` - pixel position
- Conversion functions in src/model/Coordinates.ts

### Key Directories

- `src/model/` - game logic, state machines, movement, collision
- `src/pages/GamePage/` - main game UI components
- `src/components/` - shared components (Board, Sprite, Grid)
- `src/mapData/` - maze tile data

### Tech Stack
- React 17, TypeScript, MobX 5, XState, styled-components, Ant Design, react-router-dom

- React 18, TypeScript, MobX 5, XState, styled-components, Ant Design, react-router-dom

## Deployment

Auto-deployed to Netlify: https://pacman-react.netlify.com/
Auto-deployed to Vercel: https://pacman-react.stefanwille.com/
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
# Pac Man built with React 17
# Pac Man built with React 18

### URL

https://pacman-react.stefanwille.com


### Tile Editor

I have used [Tiled](https://www.mapeditor.org/).
Expand Down Expand Up @@ -73,4 +72,3 @@ You don’t have to ever use `eject`. The curated feature set is suitable for sm
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).

To learn React, check out the [React documentation](https://reactjs.org/).

27 changes: 13 additions & 14 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@
"private": true,
"license": "private",
"dependencies": {
"@testing-library/jest-dom": "^5.11.9",
"@testing-library/react": "^11.2.3",
"@testing-library/user-event": "^12.6.2",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.5.0",
"@types/classnames": "^2.2.9",
"@types/lodash": "^4.14.149",
"@types/node": "^18.0.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react-router-dom": "^5.1.7",
"antd": "4.10.3",
"antd": "^4.24.0",
"classnames": "^2.2.6",
"lodash": "^4.17.15",
"mobx": "^5.15.1",
"mobx-react-lite": "2.0.6",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"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",
Expand All @@ -35,8 +35,7 @@
"test:ci": "CI=true react-scripts test",
"eject": "react-scripts eject",
"prettier": "prettier --write **/*.{ts,tsx,md,json,js,sx}",
"lint": "eslint 'src/**/*.{ts,tsx}'",
"netlify-build": "tsc --noEmit && yarn lint && yarn test:ci && yarn build"
"lint": "eslint 'src/**/*.{ts,tsx}'"
},
"eslintConfig": {
"extends": "react-app"
Expand All @@ -54,8 +53,8 @@
]
},
"resolutions": {
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0"
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0"
},
"devDependencies": {
"@types/styled-components": "^5.0.1",
Expand Down
4 changes: 2 additions & 2 deletions src/components/Board.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
width: 100%;
max-width: calc(28 * var(--SCREEN_TILE_SIZE));
overflow: hidden;
height: calc(620px * min(1, (100vw - 32px) / 560px));
height: calc(620px * min(1, (100vw - 32px) / 560));
}

.Board {
Expand All @@ -18,5 +18,5 @@
overflow: hidden;
transform-origin: top left;
/* Scale down to fit viewport on mobile, but never scale up beyond 1 */
transform: scale(min(1, calc((100vw - 32px) / 560px)));
transform: scale(min(1, (100vw - 32px) / 560));
}
2 changes: 1 addition & 1 deletion src/components/Board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import './Board.css';
import classNames from 'classnames';

export const Board: React.FC<{ className?: string }> = ({
export const Board: React.FC<React.PropsWithChildren<{ className?: string }>> = ({
className,
children,
}) => (
Expand Down
6 changes: 4 additions & 2 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';

ReactDOM.render(<App />, document.getElementById('root'));
const container = document.getElementById('root');
const root = createRoot(container!);
root.render(<App />);
3 changes: 2 additions & 1 deletion src/model/DebugState.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { observable } from 'mobx';
import { observable, makeObservable } from 'mobx';
import { Store } from './Store';
import { GhostViewOptions } from './GhostViewOptions';
import { PacManViewOptions } from '../pages/GamePage/components/PacManViewOptions';
import { GameViewOptions } from './GameViewOptions';

export class DebugState {
constructor(store: Store) {
makeObservable(this);
this.store = store;
}

Expand Down
3 changes: 2 additions & 1 deletion src/model/Game.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { action, computed, observable } from 'mobx';
import { action, computed, observable, makeObservable } from 'mobx';
import { Ghost } from './Ghost';
import { makeGhosts, resetGhosts } from './makeGhosts';
import { Maze } from './Maze';
Expand All @@ -13,6 +13,7 @@ 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);
Expand Down
34 changes: 17 additions & 17 deletions src/model/Ghost.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { action, computed, observable } from 'mobx';
import { action, computed, observable, makeObservable } from 'mobx';
import { changeDirectionToOpposite } from './changeDirectionToOpposite';
import {
ScreenCoordinates,
Expand Down Expand Up @@ -38,9 +38,16 @@ 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<typeof this.stateChart.onTransition>[0]);
this.stateChart.start();
}

@action.bound
Expand All @@ -52,31 +59,24 @@ export class Ghost {
this.stateChanges++;
}

stateChart = makeGhostStateChart({
onScatterToChase: this.onScatterToChase,
onChaseToScatter: this.onChaseToScatter,
onDead: this.onDead,
});
stateChart: ReturnType<typeof makeGhostStateChart>;

@action.bound
onDead() {
onDead = () => {
this.game.killedGhosts++;
this.game.score += KILL_GHOST_SCORE[this.game.killedGhosts];
this.deadWaitingTimeInBoxLeft = DEAD_WAITING_IN_BOX_DURATION;
}
};

@action.bound
onScatterToChase() {
onScatterToChase = () => {
changeDirectionToOpposite(this);
}
};

@action.bound
onChaseToScatter() {
onChaseToScatter = () => {
changeDirectionToOpposite(this);
}
};

@observable.ref
stateChartState: GhostState = this.stateChart.state as unknown as GhostState;
stateChartState!: GhostState;

@computed
get state(): StateValue {
Expand Down
3 changes: 2 additions & 1 deletion src/model/IntervalTimer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { MilliSeconds } from './Types';
import { observable, computed, action } from 'mobx';
import { observable, computed, action, makeObservable } from 'mobx';

export type TimerCallback = () => void;

Expand All @@ -14,6 +14,7 @@ export class IntervalTimer {
timeSpent: MilliSeconds;

constructor(duration: MilliSeconds, onTimedOut: TimerCallback | null = null) {
makeObservable(this);
this.duration = duration;
this.onTimedOut = onTimedOut;
this.running = false;
Expand Down
6 changes: 5 additions & 1 deletion src/model/Maze.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { observable } from 'mobx';
import { observable, makeObservable } from 'mobx';
import { getPillsMatrix, TileId } from './MazeData';

export class Maze {
constructor() {
makeObservable(this);
}

@observable
pills: TileId[][] = getPillsMatrix();
}
27 changes: 14 additions & 13 deletions src/model/PacMan.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { observable, action, computed } from 'mobx';
import { observable, action, computed, makeObservable } from 'mobx';

import { Direction, MilliSeconds } from './Types';
import {
Expand All @@ -20,9 +20,15 @@ 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<typeof this.stateChart.onTransition>[0]);
this.stateChart.start();
}

@action.bound
Expand All @@ -35,23 +41,18 @@ export class PacMan {

game: Game;

stateChart = makePacManStateChart({
onChasing: this.onChasing,
onDead: this.onDead,
});
stateChart: ReturnType<typeof makePacManStateChart>;

@observable.ref
stateChartState: PacManState = this.stateChart.state as unknown as PacManState;
stateChartState!: PacManState;

@action.bound
onChasing() {
onChasing = () => {
this.game.energizerTimer.start();
}
};

@action.bound
onDead() {
onDead = () => {
this.diedAtTimestamp = this.game.timestamp;
}
};

@computed
get dead(): boolean {
Expand Down
6 changes: 5 additions & 1 deletion src/model/Store.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { observable, action } from 'mobx';
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);

Expand Down
3 changes: 2 additions & 1 deletion src/model/TimeoutTimer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { MilliSeconds } from './Types';
import { observable, computed, action } from 'mobx';
import { observable, computed, action, makeObservable } from 'mobx';

export type TimerCallback = () => void;

Expand All @@ -14,6 +14,7 @@ export class TimeoutTimer {
timeSpent: MilliSeconds;

constructor(duration: MilliSeconds, onTimedOut: TimerCallback | null = null) {
makeObservable(this);
this.duration = duration;
this.onTimedOut = onTimedOut;
this.running = false;
Expand Down
2 changes: 1 addition & 1 deletion src/pages/GamePage/components/PacManDebugView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const PacManDebugView = observer<{ className?: string }>(
<Layout className="PacManDebugView">
<Card title="Pac Man" size="small" bordered={false}>
<Row>
<Col flex="0 0 104px">State: {game.pacMan.state}</Col>
<Col flex="0 0 104px">{`State: ${game.pacMan.state}`}</Col>

<Col flex="0 0 48px"></Col>

Expand Down
3 changes: 0 additions & 3 deletions src/setupTests.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
// https://www.npmjs.com/package/mobx-react-lite#observer-batching
import 'mobx-react-lite/batchingForReactDom';

// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
Expand Down
2 changes: 1 addition & 1 deletion src/test-util/TestApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const TestApp: FC<{ store?: Store; route?: string }> = ({
store = new Store(),
route = '/',
}) => {
const Router: FC<{}> = ({ children }) => (
const Router: FC<React.PropsWithChildren<{}>> = ({ children }) => (
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
);
return <App store={store} Router={Router} />;
Expand Down
Loading