docs(02-core-gameplay): create phase 2 plan
Two-wave plan for Core Gameplay phase: paddle2 + AI system (wave 1), then game state machine + scoring + rendering (wave 2). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
367
.planning/phases/02-core-gameplay/02-01-PLAN.md
Normal file
367
.planning/phases/02-core-gameplay/02-01-PLAN.md
Normal file
@@ -0,0 +1,367 @@
|
||||
---
|
||||
phase: 02-core-gameplay
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified: [index.html]
|
||||
autonomous: true
|
||||
requirements: [CORE-08, AI-01, AI-02, AI-03, AI-04]
|
||||
must_haves:
|
||||
truths:
|
||||
- "Player 2 can move their paddle up and down with ArrowUp/ArrowDown keys"
|
||||
- "AI paddle tracks and intercepts the ball using predictive ray-cast logic"
|
||||
- "Easy AI is visibly slow with a large reaction delay — beatable by any player"
|
||||
- "Medium AI reacts at moderate speed — provides a fair challenge"
|
||||
- "Hard AI reacts fast but not perfectly — still beatable with skill"
|
||||
- "Ball deflects off paddle2 the same way it deflects off paddle1 (zone-based angle)"
|
||||
artifacts:
|
||||
- path: "index.html"
|
||||
provides: "GameState.paddle2 object, AI module, extended Input, extended Physics"
|
||||
contains: "AI_EASY, AI_MEDIUM, AI_HARD, getVerticalInput2, _checkPaddle2Collision"
|
||||
key_links:
|
||||
- from: "Physics.update()"
|
||||
to: "AI.update(deltaTime)"
|
||||
via: "called when GameState.mode === 'ai'"
|
||||
pattern: "AI\\.update"
|
||||
- from: "Physics.update()"
|
||||
to: "_checkPaddle2Collision"
|
||||
via: "called when ball.vx > 0"
|
||||
pattern: "ball\\.vx > 0"
|
||||
- from: "AI._predictBallY()"
|
||||
to: "wall bounce simulation"
|
||||
via: "stepped ray cast loop"
|
||||
pattern: "while.*steps < maxSteps"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add paddle2 (Player 2 or AI-controlled) and the full AI opponent system to index.html.
|
||||
|
||||
Purpose: Make the game two-sided — ball now has a right paddle to deflect off. Player 2 can be human (Up/Down arrows) or AI (predictive interception with 3 tunable difficulty levels).
|
||||
Output: index.html with GameState.paddle2, extended Input (ArrowUp/ArrowDown + named handler refs for cleanup), AI object with predictive ray-cast interception, extended Physics.update() with paddle2 movement branch and paddle2 collision detection.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/dabit/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/dabit/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/02-core-gameplay/02-CONTEXT.md
|
||||
@.planning/phases/02-core-gameplay/02-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Existing code contracts the executor MUST extend (not replace). Extracted from index.html. -->
|
||||
|
||||
GameConfig (existing — extend, do NOT replace):
|
||||
```javascript
|
||||
const GameConfig = {
|
||||
initialBallSpeed: 220,
|
||||
speedIncrement: 18,
|
||||
paddleSpeed: 400
|
||||
// Plan 02-01 adds: WIN_SCORE, AI_EASY, AI_MEDIUM, AI_HARD
|
||||
};
|
||||
```
|
||||
|
||||
GameState.paddle1 (existing structure — paddle2 MUST mirror this):
|
||||
```javascript
|
||||
paddle1: {
|
||||
x: 30, y: <centered>,
|
||||
width: 12, height: 80,
|
||||
speed: 400,
|
||||
color: '#fff'
|
||||
}
|
||||
```
|
||||
|
||||
GameState.ball (existing):
|
||||
```javascript
|
||||
ball: { x, y, radius: 8, vx, vy, speed, color: '#fff' }
|
||||
```
|
||||
|
||||
Input (existing — extend, do NOT replace):
|
||||
```javascript
|
||||
const Input = {
|
||||
keys: { w: false, s: false },
|
||||
init() { /* anonymous addEventListener keydown/keyup */ },
|
||||
getVerticalInput() { /* returns -1/0/1 for W/S */ }
|
||||
};
|
||||
```
|
||||
|
||||
Physics._checkPaddleCollision(ball, paddle) (existing — reuse for paddle2):
|
||||
- AABB collision + zone-based deflection
|
||||
- Sets ball.vx positive (away from paddle1, left side)
|
||||
- Needs a mirrored version for paddle2 that sets ball.vx negative and pushes ball LEFT
|
||||
|
||||
Physics.update(deltaTime) (existing — extend):
|
||||
```javascript
|
||||
update(deltaTime) {
|
||||
// Moves paddle1 (Input.getVerticalInput)
|
||||
// Moves ball (vx*dt, vy*dt)
|
||||
// Wall bounces (top/bottom)
|
||||
// Out-of-bounds reset (left or right) → this.serveBall()
|
||||
// Paddle1 collision (only when ball.vx < 0)
|
||||
}
|
||||
```
|
||||
|
||||
GameLoop.main(currentTime) (existing — NOT modified in this plan):
|
||||
```javascript
|
||||
main(currentTime) {
|
||||
const deltaTime = Math.min((currentTime - this.lastTime) / 1000, 0.05);
|
||||
Physics.update(deltaTime);
|
||||
Renderer.clear();
|
||||
// draws debug speed, paddle1, ball
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Extend GameConfig, GameState, and Input for two-player support</name>
|
||||
<files>index.html</files>
|
||||
<action>
|
||||
Make these additions to index.html inside the existing `<script>` tag. Do NOT rewrite the whole file — surgical additions only.
|
||||
|
||||
**1. Extend GameConfig** (add after `paddleSpeed: 400`):
|
||||
```javascript
|
||||
WIN_SCORE: 7,
|
||||
|
||||
AI_EASY: { speed: 200, reactionDelay: 0.3, errorMargin: 20 },
|
||||
AI_MEDIUM: { speed: 320, reactionDelay: 0.1, errorMargin: 5 },
|
||||
AI_HARD: { speed: 400, reactionDelay: 0.05, errorMargin: 2 }
|
||||
```
|
||||
|
||||
**2. Extend GameState** (add after `ball: {...}`, inside the GameState object):
|
||||
```javascript
|
||||
paddle2: {
|
||||
x: 0, y: 0, // Set on Physics.init()
|
||||
width: 12, height: 80,
|
||||
speed: 400,
|
||||
color: '#fff'
|
||||
},
|
||||
score1: 0,
|
||||
score2: 0,
|
||||
mode: null, // null = mode select screen; 'ai' = Solo vs AI; '2p' = 2-Player local
|
||||
difficulty: 'medium', // 'easy', 'medium', 'hard'
|
||||
gameState: 'modeSelect',// 'modeSelect', 'diffSelect', 'playing', 'scored', 'gameover'
|
||||
winner: null // null, 'player1', 'player2', 'ai'
|
||||
```
|
||||
|
||||
**3. Refactor Input** to use named handler refs for cleanup (replace the existing Input object entirely — the existing one has anonymous handlers that can't be removed):
|
||||
```javascript
|
||||
const Input = {
|
||||
keys: { w: false, s: false, arrowUp: false, arrowDown: false },
|
||||
_handleKeyDown: null,
|
||||
_handleKeyUp: null,
|
||||
|
||||
init() {
|
||||
this._handleKeyDown = (e) => {
|
||||
if (e.code === 'KeyW') { this.keys.w = true; e.preventDefault(); }
|
||||
if (e.code === 'KeyS') { this.keys.s = true; e.preventDefault(); }
|
||||
if (e.code === 'ArrowUp') { this.keys.arrowUp = true; e.preventDefault(); }
|
||||
if (e.code === 'ArrowDown') { this.keys.arrowDown = true; e.preventDefault(); }
|
||||
};
|
||||
this._handleKeyUp = (e) => {
|
||||
if (e.code === 'KeyW') this.keys.w = false;
|
||||
if (e.code === 'KeyS') this.keys.s = false;
|
||||
if (e.code === 'ArrowUp') this.keys.arrowUp = false;
|
||||
if (e.code === 'ArrowDown') this.keys.arrowDown = false;
|
||||
};
|
||||
document.addEventListener('keydown', this._handleKeyDown);
|
||||
document.addEventListener('keyup', this._handleKeyUp);
|
||||
},
|
||||
|
||||
cleanup() {
|
||||
document.removeEventListener('keydown', this._handleKeyDown);
|
||||
document.removeEventListener('keyup', this._handleKeyUp);
|
||||
},
|
||||
|
||||
getVerticalInput() { return this.keys.w ? -1 : this.keys.s ? 1 : 0; },
|
||||
getVerticalInput2() { return this.keys.arrowUp ? -1 : this.keys.arrowDown ? 1 : 0; }
|
||||
};
|
||||
```
|
||||
|
||||
**4. Extend Physics.init()** — add paddle2 positioning after the existing paddle1 positioning:
|
||||
```javascript
|
||||
// Position paddle2 on right side, vertically centered
|
||||
GameState.paddle2.x = width - 30 - GameState.paddle2.width;
|
||||
GameState.paddle2.y = height / 2 - GameState.paddle2.height / 2;
|
||||
GameState.paddle2.speed = GameConfig.paddleSpeed;
|
||||
```
|
||||
|
||||
Also extend `Physics.onResize()` to call `this.init(width, height)` so paddle2 is repositioned on resize (the existing `onResize` only updates `this.width/height`).
|
||||
</action>
|
||||
<verify>
|
||||
Open index.html in browser. Open DevTools console — no errors on load.
|
||||
Verify in console: `GameState.paddle2` has x, y, width, height, speed, color. `GameConfig.AI_EASY` exists. `Input.getVerticalInput2` is a function.
|
||||
</verify>
|
||||
<done>GameState has paddle2 mirroring paddle1 structure. GameConfig has WIN_SCORE and all three AI difficulty presets. Input has named handler refs, cleanup(), getVerticalInput2(). No console errors.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add AI module and extend Physics for paddle2 movement and collision</name>
|
||||
<files>index.html</files>
|
||||
<action>
|
||||
Add the AI object between Physics and GameLoop. Then extend Physics.update() to handle paddle2.
|
||||
|
||||
**1. Add AI object** (insert between the closing `};` of Physics and the opening `const GameLoop = {`):
|
||||
```javascript
|
||||
const AI = {
|
||||
reactionElapsed: 0,
|
||||
|
||||
init() {
|
||||
this.reactionElapsed = 0;
|
||||
},
|
||||
|
||||
update(deltaTime) {
|
||||
const difficulty = GameState.difficulty;
|
||||
const config = GameConfig['AI_' + difficulty.toUpperCase()];
|
||||
const ball = GameState.ball;
|
||||
const paddle2 = GameState.paddle2;
|
||||
|
||||
// Reset reaction timer when ball is moving away (toward player side)
|
||||
if (ball.vx < 0) {
|
||||
this.reactionElapsed = 0;
|
||||
return; // Ball heading toward player — AI waits
|
||||
}
|
||||
|
||||
this.reactionElapsed += deltaTime;
|
||||
if (this.reactionElapsed < config.reactionDelay) {
|
||||
return; // Still in reaction delay window
|
||||
}
|
||||
|
||||
// Predict ball y-position when it reaches paddle2 x
|
||||
const targetY = this._predictBallY(ball, paddle2.x);
|
||||
|
||||
// Add difficulty-based error margin (random jitter each frame)
|
||||
const error = (Math.random() - 0.5) * config.errorMargin * 2;
|
||||
const aimY = targetY + error;
|
||||
|
||||
// Move paddle toward aim Y (clamped to field)
|
||||
const centerY = paddle2.y + paddle2.height / 2;
|
||||
const diff = aimY - centerY;
|
||||
|
||||
if (Math.abs(diff) > 3) { // Deadzone prevents jitter
|
||||
const dir = Math.sign(diff);
|
||||
paddle2.y += dir * config.speed * deltaTime;
|
||||
paddle2.y = Math.max(0, Math.min(Physics.height - paddle2.height, paddle2.y));
|
||||
}
|
||||
},
|
||||
|
||||
_predictBallY(ball, targetX) {
|
||||
let x = ball.x, y = ball.y, vx = ball.vx, vy = ball.vy;
|
||||
const maxSteps = 500;
|
||||
let steps = 0;
|
||||
|
||||
while (steps < maxSteps) {
|
||||
if (Math.abs(x - targetX) < 1) break;
|
||||
const timeToTarget = (targetX - x) / vx;
|
||||
const timeStep = Math.min(0.016, Math.abs(timeToTarget));
|
||||
|
||||
x += vx * timeStep;
|
||||
y += vy * timeStep;
|
||||
|
||||
// Wall bounces during prediction
|
||||
if (y - ball.radius < 0) {
|
||||
y = ball.radius;
|
||||
vy = Math.abs(vy);
|
||||
} else if (y + ball.radius > Physics.height) {
|
||||
y = Physics.height - ball.radius;
|
||||
vy = -Math.abs(vy);
|
||||
}
|
||||
steps++;
|
||||
}
|
||||
|
||||
return y;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**2. Add `_checkPaddle2Collision(ball, paddle)` to Physics** (add after `_checkPaddleCollision`):
|
||||
```javascript
|
||||
_checkPaddle2Collision(ball, paddle) {
|
||||
const inX = ball.x + ball.radius > paddle.x &&
|
||||
ball.x - ball.radius < paddle.x + paddle.width;
|
||||
const inY = ball.y + ball.radius > paddle.y &&
|
||||
ball.y - ball.radius < paddle.y + paddle.height;
|
||||
|
||||
if (!inX || !inY) return;
|
||||
|
||||
const relativeHitPos = (ball.y - paddle.y) / paddle.height;
|
||||
const hitZone = Math.max(0, Math.min(4, Math.floor(relativeHitPos * 5)));
|
||||
const anglesDeg = [-60, -30, 5, 30, 60];
|
||||
const angleRad = anglesDeg[hitZone] * Math.PI / 180;
|
||||
|
||||
ball.speed += GameConfig.speedIncrement;
|
||||
|
||||
// Ball leaves paddle2 going LEFT (negative vx)
|
||||
ball.vx = -Math.cos(angleRad) * ball.speed;
|
||||
ball.vy = Math.sin(angleRad) * ball.speed;
|
||||
|
||||
// Push ball left of paddle2 to prevent double-collision
|
||||
ball.x = paddle.x - ball.radius - 1;
|
||||
},
|
||||
```
|
||||
|
||||
**3. Extend Physics.update()** — after the existing paddle1 movement block, add paddle2 movement (replace the existing out-of-bounds reset with scoring-aware version in Plan 02-02; for now keep the existing reset but ADD paddle2 movement and collision):
|
||||
|
||||
Add after `paddle.y = Math.max(0, Math.min(...));` (end of paddle1 movement):
|
||||
```javascript
|
||||
// --- Move paddle2 (human or AI) ---
|
||||
const paddle2 = GameState.paddle2;
|
||||
if (GameState.mode === '2p') {
|
||||
const dir2 = Input.getVerticalInput2();
|
||||
paddle2.y += dir2 * paddle2.speed * deltaTime;
|
||||
paddle2.y = Math.max(0, Math.min(this.height - paddle2.height, paddle2.y));
|
||||
} else if (GameState.mode === 'ai') {
|
||||
AI.update(deltaTime);
|
||||
}
|
||||
```
|
||||
|
||||
Add after the existing `if (ball.vx < 0) this._checkPaddleCollision(ball, paddle);`:
|
||||
```javascript
|
||||
// --- Paddle2 collision (only when ball moving right toward paddle2) ---
|
||||
if (ball.vx > 0) {
|
||||
this._checkPaddle2Collision(ball, GameState.paddle2);
|
||||
}
|
||||
```
|
||||
|
||||
**4. Add `AI.init()` to the initialization block** at the bottom of the script (after `Input.init()`):
|
||||
```javascript
|
||||
AI.init();
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
Open index.html. Ball should still bounce and Player 1 W/S still works. Open console — no errors. Verify `typeof AI.update === 'function'` and `typeof Physics._checkPaddle2Collision === 'function'`. Paddle2 is not yet visible (rendering added in Plan 02-02) but it exists in GameState with valid coordinates.
|
||||
</verify>
|
||||
<done>AI object exists with init(), update(), _predictBallY(). Physics has _checkPaddle2Collision(). Physics.update() includes paddle2 movement branch (2p/ai). No console errors. Paddle1 W/S still works.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After both tasks:
|
||||
- `GameState.paddle2` has x, y, width, height, speed, color (mirrors paddle1 structure)
|
||||
- `Input.cleanup()` is callable (named handlers stored on Input object)
|
||||
- `Input.getVerticalInput2()` returns -1/0/1 for ArrowUp/ArrowDown
|
||||
- `AI.update(deltaTime)` moves paddle2 toward predicted ball intercept when mode === 'ai'
|
||||
- `AI._predictBallY()` simulates wall bounces in its ray cast loop (not linear interpolation)
|
||||
- `Physics._checkPaddle2Collision()` uses same 5-zone deflection as paddle1 but sends ball LEFT
|
||||
- Ball still bounces off walls and Player 1 can still hit it (existing behavior unchanged)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- ArrowUp moves paddle2 up; ArrowDown moves paddle2 down (requires mode selection in Plan 02-02 to fully test, but can test by temporarily setting `GameState.mode = '2p'` in console)
|
||||
- In AI mode, paddle2 moves toward ball intercept position
|
||||
- Ball deflects off paddle2 (check by setting mode in console and playing)
|
||||
- `typeof AI._predictBallY === 'function'` is true
|
||||
- No console errors on load or during play
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-core-gameplay/02-01-SUMMARY.md` following the template at `@/home/dabit/.claude/get-shit-done/templates/summary.md`.
|
||||
</output>
|
||||
376
.planning/phases/02-core-gameplay/02-02-PLAN.md
Normal file
376
.planning/phases/02-core-gameplay/02-02-PLAN.md
Normal file
@@ -0,0 +1,376 @@
|
||||
---
|
||||
phase: 02-core-gameplay
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [02-01]
|
||||
files_modified: [index.html]
|
||||
autonomous: false
|
||||
requirements: [CORE-05, CORE-06]
|
||||
must_haves:
|
||||
truths:
|
||||
- "Player scores a point when ball exits past opponent's paddle edge"
|
||||
- "Scores display on canvas in real time, visible to both players"
|
||||
- "Ball pauses ~1 second after a score, then auto-serves"
|
||||
- "Match ends when a player reaches 7 points with a winner message on screen"
|
||||
- "R key restarts the match (scores reset, mode/difficulty selection reappears)"
|
||||
- "Mode is selected by pressing 1 (Solo vs AI) or 2 (2-Player)"
|
||||
- "Difficulty is selected by pressing 1/2/3 (Easy/Medium/Hard) before match starts"
|
||||
- "Both paddles render on screen during gameplay"
|
||||
artifacts:
|
||||
- path: "index.html"
|
||||
provides: "GameLoop with scoring detection, state machine, rendering of paddle2 and scores"
|
||||
contains: "gameState.*modeSelect, score1, score2, gameover, WIN_SCORE"
|
||||
key_links:
|
||||
- from: "GameLoop.main()"
|
||||
to: "score detection"
|
||||
via: "checks ball.x after Physics.update()"
|
||||
pattern: "score[12]\\+\\+"
|
||||
- from: "GameLoop.main()"
|
||||
to: "match-end check"
|
||||
via: "score >= WIN_SCORE condition"
|
||||
pattern: "WIN_SCORE"
|
||||
- from: "GameLoop.main()"
|
||||
to: "re-serve after pause"
|
||||
via: "scored state + elapsed > 1.0"
|
||||
pattern: "elapsed > 1"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire game state machine, scoring detection, match-end condition, re-serve timing, rendering of paddle2 and score display, and mode/difficulty selection flow into index.html.
|
||||
|
||||
Purpose: Transform the two-paddle foundation (from Plan 02-01) into a fully playable game with score tracking, match progression, and a clear start/restart flow.
|
||||
Output: Complete Phase 2 — a playable single-player (vs AI) or 2-player local Pong match that ends at 7 points with mode/difficulty selection and R-key restart.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/home/dabit/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/home/dabit/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/02-core-gameplay/02-CONTEXT.md
|
||||
@.planning/phases/02-core-gameplay/02-RESEARCH.md
|
||||
@.planning/phases/02-core-gameplay/02-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Contracts from Plan 02-01 that this plan builds on. Read index.html before editing. -->
|
||||
|
||||
GameState fields added in Plan 02-01:
|
||||
```javascript
|
||||
GameState.score1 // 0
|
||||
GameState.score2 // 0
|
||||
GameState.mode // null | 'ai' | '2p'
|
||||
GameState.difficulty // 'easy' | 'medium' | 'hard'
|
||||
GameState.gameState // 'modeSelect' | 'diffSelect' | 'playing' | 'scored' | 'gameover'
|
||||
GameState.winner // null | 'player1' | 'player2' | 'ai'
|
||||
GameState.paddle2 // { x, y, width, height, speed, color }
|
||||
```
|
||||
|
||||
GameConfig fields added in Plan 02-01:
|
||||
```javascript
|
||||
GameConfig.WIN_SCORE // 7
|
||||
GameConfig.AI_EASY/MEDIUM/HARD // { speed, reactionDelay, errorMargin }
|
||||
```
|
||||
|
||||
Physics.serveBall() (existing) — resets ball to center with random direction; does NOT reset scores.
|
||||
|
||||
Physics.init(width, height) — positions both paddles and calls serveBall(); can be used for full restart.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: State machine — mode/difficulty selection, scoring, match end, and restart</name>
|
||||
<files>index.html</files>
|
||||
<action>
|
||||
Rewrite `GameLoop.main()` to implement the full game state machine. Also add key handlers for mode/difficulty selection and R-key restart to Input (append to the existing `_handleKeyDown` handler — do NOT replace the arrow/WASD key handling already there).
|
||||
|
||||
**1. Add mode/difficulty/restart key handling** inside Input._handleKeyDown (add AFTER the ArrowDown block):
|
||||
```javascript
|
||||
// Mode selection (only when in modeSelect state)
|
||||
if (e.code === 'Digit1' && GameState.gameState === 'modeSelect') {
|
||||
GameState.mode = 'ai';
|
||||
GameState.gameState = 'diffSelect';
|
||||
}
|
||||
if (e.code === 'Digit2' && GameState.gameState === 'modeSelect') {
|
||||
GameState.mode = '2p';
|
||||
// Skip difficulty selection for 2-player — go straight to playing
|
||||
GameState.gameState = 'playing';
|
||||
Physics.serveBall();
|
||||
}
|
||||
|
||||
// Difficulty selection (only when in diffSelect state)
|
||||
if (e.code === 'Digit1' && GameState.gameState === 'diffSelect') {
|
||||
GameState.difficulty = 'easy';
|
||||
AI.init();
|
||||
GameState.gameState = 'playing';
|
||||
Physics.serveBall();
|
||||
}
|
||||
if (e.code === 'Digit2' && GameState.gameState === 'diffSelect') {
|
||||
GameState.difficulty = 'medium';
|
||||
AI.init();
|
||||
GameState.gameState = 'playing';
|
||||
Physics.serveBall();
|
||||
}
|
||||
if (e.code === 'Digit3' && GameState.gameState === 'diffSelect') {
|
||||
GameState.difficulty = 'hard';
|
||||
AI.init();
|
||||
GameState.gameState = 'playing';
|
||||
Physics.serveBall();
|
||||
}
|
||||
|
||||
// Restart (only when gameover)
|
||||
if (e.code === 'KeyR' && GameState.gameState === 'gameover') {
|
||||
GameState.score1 = 0;
|
||||
GameState.score2 = 0;
|
||||
GameState.winner = null;
|
||||
GameState.mode = null;
|
||||
GameState.difficulty = 'medium';
|
||||
GameState.gameState = 'modeSelect';
|
||||
AI.init();
|
||||
Physics.init(Renderer.logicalWidth, Renderer.logicalHeight);
|
||||
}
|
||||
```
|
||||
|
||||
**2. Rewrite GameLoop.main()** (replace the entire existing `main(currentTime)` method):
|
||||
```javascript
|
||||
main(currentTime) {
|
||||
this.stopHandle = window.requestAnimationFrame(this.main.bind(this));
|
||||
const deltaTime = Math.min((currentTime - this.lastTime) / 1000, 0.05);
|
||||
this.lastTime = currentTime;
|
||||
|
||||
const gs = GameState;
|
||||
const ball = gs.ball;
|
||||
|
||||
// --- Physics update (only during active play) ---
|
||||
if (gs.gameState === 'playing') {
|
||||
Physics.update(deltaTime);
|
||||
|
||||
// Score detection — AFTER Physics.update(), NOT inside Physics
|
||||
if (ball.x + ball.radius < 0) {
|
||||
// Ball exited left edge → Player 2 (or AI) scores
|
||||
gs.score2++;
|
||||
gs.gameState = 'scored';
|
||||
this.pauseTime = currentTime;
|
||||
} else if (ball.x - ball.radius > Physics.width) {
|
||||
// Ball exited right edge → Player 1 scores
|
||||
gs.score1++;
|
||||
gs.gameState = 'scored';
|
||||
this.pauseTime = currentTime;
|
||||
}
|
||||
|
||||
// Match-end check immediately after scoring
|
||||
if (gs.score1 >= GameConfig.WIN_SCORE) {
|
||||
gs.gameState = 'gameover';
|
||||
gs.winner = 'player1';
|
||||
} else if (gs.score2 >= GameConfig.WIN_SCORE) {
|
||||
gs.gameState = 'gameover';
|
||||
gs.winner = gs.mode === 'ai' ? 'ai' : 'player2';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auto-serve after ~1s pause ---
|
||||
if (gs.gameState === 'scored') {
|
||||
const elapsed = (currentTime - this.pauseTime) / 1000;
|
||||
if (elapsed >= 1.0) {
|
||||
Physics.serveBall();
|
||||
gs.gameState = 'playing';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
Renderer.clear();
|
||||
|
||||
if (gs.gameState === 'modeSelect') {
|
||||
// Mode selection prompt
|
||||
Renderer.ctx.fillStyle = '#fff';
|
||||
Renderer.ctx.font = 'bold 28px monospace';
|
||||
Renderer.ctx.textAlign = 'center';
|
||||
Renderer.ctx.fillText('SUPER PONG NEXT GEN', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 - 60);
|
||||
Renderer.ctx.font = '20px monospace';
|
||||
Renderer.ctx.fillText('Press 1 — Solo vs AI', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2);
|
||||
Renderer.ctx.fillText('Press 2 — 2 Player Local', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 + 36);
|
||||
Renderer.ctx.textAlign = 'left';
|
||||
return;
|
||||
}
|
||||
|
||||
if (gs.gameState === 'diffSelect') {
|
||||
Renderer.ctx.fillStyle = '#fff';
|
||||
Renderer.ctx.font = 'bold 24px monospace';
|
||||
Renderer.ctx.textAlign = 'center';
|
||||
Renderer.ctx.fillText('SELECT DIFFICULTY', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 - 50);
|
||||
Renderer.ctx.font = '20px monospace';
|
||||
Renderer.ctx.fillText('1 — Easy', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2);
|
||||
Renderer.ctx.fillText('2 — Medium', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 + 36);
|
||||
Renderer.ctx.fillText('3 — Hard', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 + 72);
|
||||
Renderer.ctx.textAlign = 'left';
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Gameplay rendering (playing, scored, gameover) ---
|
||||
|
||||
// Paddles
|
||||
const p1 = gs.paddle1;
|
||||
const p2 = gs.paddle2;
|
||||
Renderer.drawRect(p1.x, p1.y, p1.width, p1.height, p1.color);
|
||||
Renderer.drawRect(p2.x, p2.y, p2.width, p2.height, p2.color);
|
||||
|
||||
// Ball (stationary during scored pause)
|
||||
if (gs.gameState !== 'scored') {
|
||||
const b = gs.ball;
|
||||
Renderer.drawCircle(b.x, b.y, b.radius, b.color);
|
||||
}
|
||||
|
||||
// Center divider line (dashed)
|
||||
Renderer.ctx.setLineDash([10, 14]);
|
||||
Renderer.ctx.strokeStyle = 'rgba(255,255,255,0.2)';
|
||||
Renderer.ctx.lineWidth = 2;
|
||||
Renderer.ctx.beginPath();
|
||||
Renderer.ctx.moveTo(Renderer.logicalWidth / 2, 0);
|
||||
Renderer.ctx.lineTo(Renderer.logicalWidth / 2, Renderer.logicalHeight);
|
||||
Renderer.ctx.stroke();
|
||||
Renderer.ctx.setLineDash([]);
|
||||
|
||||
// Scores
|
||||
Renderer.ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||
Renderer.ctx.font = 'bold 48px monospace';
|
||||
Renderer.ctx.textAlign = 'center';
|
||||
Renderer.ctx.fillText(gs.score1, Renderer.logicalWidth / 4, 64);
|
||||
Renderer.ctx.fillText(gs.score2, Renderer.logicalWidth * 3 / 4, 64);
|
||||
Renderer.ctx.textAlign = 'left';
|
||||
|
||||
// Game over overlay
|
||||
if (gs.gameState === 'gameover') {
|
||||
const winnerName = gs.winner === 'player1' ? 'Player 1' :
|
||||
gs.winner === 'ai' ? 'AI' : 'Player 2';
|
||||
Renderer.ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
Renderer.ctx.fillRect(0, 0, Renderer.logicalWidth, Renderer.logicalHeight);
|
||||
Renderer.ctx.fillStyle = '#fff';
|
||||
Renderer.ctx.font = 'bold 36px monospace';
|
||||
Renderer.ctx.textAlign = 'center';
|
||||
Renderer.ctx.fillText(winnerName + ' Wins!', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 - 20);
|
||||
Renderer.ctx.font = '20px monospace';
|
||||
Renderer.ctx.fillText('Press R to play again', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 + 24);
|
||||
Renderer.ctx.textAlign = 'left';
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
**3. Add `pauseTime` field to GameLoop object** (add alongside `stopHandle: null, lastTime: 0`):
|
||||
```javascript
|
||||
pauseTime: 0,
|
||||
```
|
||||
|
||||
**4. Remove the Phase 1 debug speed display** — delete this block from wherever it appears in the old main():
|
||||
```javascript
|
||||
// Debug: show ball speed (keep for Phase 1 verification)
|
||||
Renderer.ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||||
Renderer.ctx.font = '14px monospace';
|
||||
Renderer.ctx.fillText('speed: ' + Math.round(GameState.ball.speed), 10, 20);
|
||||
```
|
||||
|
||||
**5. Remove the Phase 1 automatic re-serve** from Physics.update() — the old code called `this.serveBall()` when ball went out of bounds. Replace that entire out-of-bounds block with NOTHING (Plan 02-02's GameLoop now owns scoring and serve). Remove:
|
||||
```javascript
|
||||
// --- Ball out of bounds (left or right) → reset ---
|
||||
if (ball.x + ball.radius < 0 || ball.x - ball.radius > this.width) {
|
||||
this.serveBall();
|
||||
return;
|
||||
}
|
||||
```
|
||||
Physics.update() should no longer call serveBall() or detect out-of-bounds. Scoring detection is GameLoop's responsibility.
|
||||
|
||||
**Critical:** Do not skip this step — if Physics still auto-resets the ball, scores will never increment.
|
||||
</action>
|
||||
<verify>
|
||||
Open index.html in browser.
|
||||
1. Verify mode select screen appears on load (title + "Press 1 — Solo vs AI / Press 2 — 2 Player Local")
|
||||
2. Press 2 → 2-Player mode starts immediately; both paddles visible; W/S moves left paddle, Up/Down moves right paddle
|
||||
3. Play until ball exits right edge → score on left side increments; ~1s pause; ball re-serves
|
||||
4. Play until ball exits left edge → score on right side increments
|
||||
5. Play to 7 points → winner overlay appears with "Player X Wins!" and "Press R to play again"
|
||||
6. Press R → returns to mode select screen with scores reset
|
||||
7. Press 1 → difficulty select screen appears; Press 2 → Medium difficulty; AI paddle tracks ball
|
||||
8. Verify no console errors throughout
|
||||
</verify>
|
||||
<done>
|
||||
- Mode select screen shows on load
|
||||
- 2-Player: both paddles controllable, scoring works, match ends at 7
|
||||
- Solo vs AI: difficulty selection works, AI tracks ball, match ends at 7
|
||||
- R key resets to mode select with clean state
|
||||
- No Phase 1 debug speed text on screen
|
||||
- No console errors
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Checkpoint: Verify Phase 2 complete gameplay</name>
|
||||
<files>index.html</files>
|
||||
<action>Human verification of completed Phase 2 gameplay. No code changes — user plays the game and confirms all behaviors match the checklist in how-to-verify.</action>
|
||||
<verify>All items in how-to-verify checklist confirmed by user.</verify>
|
||||
<done>User types "approved" — all Phase 2 success criteria verified through manual play.</done>
|
||||
<what-built>
|
||||
Complete Phase 2: paddle2 (human or AI), scoring system, match-end condition, re-serve timing, mode/difficulty selection, and winner display — all in index.html.
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
Run through this checklist:
|
||||
|
||||
**Player 2 Input (2-Player mode):**
|
||||
1. Load index.html. Press 2 (2-Player mode).
|
||||
2. Press ArrowUp — right paddle moves up smoothly, no lag.
|
||||
3. Press ArrowDown — right paddle moves down.
|
||||
4. Press both simultaneously — paddle stops.
|
||||
5. Release — paddle stops immediately.
|
||||
|
||||
**Scoring:**
|
||||
6. Let ball exit left edge — right score (top-right number) increments.
|
||||
7. Let ball exit right edge — left score (top-left number) increments.
|
||||
8. Ball pauses ~1 second, then auto-serves.
|
||||
|
||||
**Match End:**
|
||||
9. Play until 7-0 — winner overlay appears: "Player X Wins!" with "Press R to play again".
|
||||
10. Press R — mode select screen returns, scores show 0.
|
||||
|
||||
**AI Mode:**
|
||||
11. Press 1 (Solo vs AI) — difficulty select shows (Easy/Medium/Hard).
|
||||
12. Press 1 — Easy. Watch AI paddle for 2 minutes — Easy AI should miss regularly.
|
||||
13. Press R, press 1, press 3 (Hard). Verify Hard AI is noticeably faster and more accurate than Easy.
|
||||
14. Verify AI still intercepts balls that bounce off top/bottom walls.
|
||||
|
||||
**Memory and Stability:**
|
||||
15. Play 3 complete matches. Open DevTools Console — no errors or warnings.
|
||||
16. Scores reset correctly on each restart.
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" if all checks pass, or describe any issues found.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
Phase 2 complete when all of the following are true:
|
||||
1. Mode select screen displays on load (no mid-game start)
|
||||
2. 2-Player: W/S controls paddle1, ArrowUp/ArrowDown controls paddle2
|
||||
3. AI mode: paddle2 moves autonomously toward ball intercept position
|
||||
4. Ball exits left edge → score2 increments; exits right edge → score1 increments
|
||||
5. 1-second pause after each score, then auto-serve
|
||||
6. First to 7 points triggers gameover state with winner message
|
||||
7. R key restarts cleanly (scores reset, mode = null, gameState = modeSelect)
|
||||
8. No console errors after 10+ rounds
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
All 6 Phase 2 success criteria from ROADMAP.md are met:
|
||||
1. Player 2 can control paddle with Up/Down arrow keys independently from Player 1 (CORE-08)
|
||||
2. AI opponent successfully tracks and intercepts the ball with behavior varying by difficulty (AI-01)
|
||||
3. Easy AI is beatable by average players; Medium provides fair challenge; Hard requires skill (AI-02, AI-03, AI-04)
|
||||
4. Player scores point when ball passes opponent's paddle (CORE-05)
|
||||
5. Match ends when a player reaches 7 points with clear game-over state (CORE-06)
|
||||
6. No memory leaks or event listener cleanup issues after multiple rounds (Input.cleanup() implemented)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-core-gameplay/02-02-SUMMARY.md` following the template at `@/home/dabit/.claude/get-shit-done/templates/summary.md`.
|
||||
</output>
|
||||
Reference in New Issue
Block a user