mirror of
https://github.com/tiennm99/gsd-framework.git
synced 2026-08-10 22:24:31 +00:00
docs(06): add phase plans for Polish and UX
This commit is contained in:
@@ -113,13 +113,13 @@ Plans:
|
||||
2. Game responds accurately to touch input on mobile devices without lag
|
||||
3. Grid layout adapts responsively to phone and desktop screen sizes
|
||||
4. Connection path is drawn visually when a match succeeds
|
||||
**Plans**: TBD
|
||||
**Plans**: 4 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 06-01: Tile match animations and visual effects
|
||||
- [ ] 06-02: Connection path visualization
|
||||
- [ ] 06-03: Mobile touch optimization
|
||||
- [ ] 06-04: Responsive grid layout refinements
|
||||
- [ ] 06-01-PLAN.md — Tile match animations with scale+fade effect (MatchAnimation class, CONFIG.animation.matchDuration)
|
||||
- [ ] 06-02-PLAN.md — Path glow effect and animation wiring (shadowBlur, animateMatch integration)
|
||||
- [ ] 06-03-PLAN.md — Mobile touch optimization (RippleAnimation, touch-action: none, ripple triggering)
|
||||
- [ ] 06-04-PLAN.md — Responsive canvas scaling (CSS transform, scale-down-only, aspect ratio preservation)
|
||||
|
||||
## Progress
|
||||
|
||||
@@ -133,9 +133,9 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6
|
||||
| 3. Core Matching Mechanics | 3/3 | Complete | 2026-03-11 |
|
||||
| 4. Game State Management | 5/5 | Complete | 04-00, 04-01, 04-02, 04-03, 04-04 |
|
||||
| 5. Board Generation and Recovery | 0/3 | Not started | - |
|
||||
| 6. Polish and UX | 0/4 | Not started | - |
|
||||
| 6. Polish and UX | 0/4 | Planned | - |
|
||||
|
||||
---
|
||||
*Roadmap created: 2026-03-10*
|
||||
*Granularity: standard*
|
||||
*Last updated: 2026-03-11 after Phase 5 planning*
|
||||
*Last updated: 2026-03-11 after Phase 6 planning*
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
---
|
||||
phase: 06-polish-and-ux
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified: [src/rendering/Renderer.ts, src/config.ts]
|
||||
autonomous: true
|
||||
requirements: [UX-01]
|
||||
user_setup: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Matched tiles animate with scale+fade effect before disappearing"
|
||||
- "Animation feels satisfying with 'pop' effect (grow then shrink)"
|
||||
- "Animation runs concurrently with path display (not sequential)"
|
||||
artifacts:
|
||||
- path: "src/rendering/Renderer.ts"
|
||||
provides: "MatchAnimation class and integration"
|
||||
contains: "class MatchAnimation"
|
||||
exports: ["animateMatch"]
|
||||
- path: "src/config.ts"
|
||||
provides: "Animation duration constant"
|
||||
contains: "MATCH_ANIMATION_DURATION"
|
||||
key_links:
|
||||
- from: "Game.ts tilesMatched event"
|
||||
to: "Renderer.animateMatch()"
|
||||
via: "event handler"
|
||||
pattern: "tilesMatched.*animateMatch"
|
||||
- from: "Renderer.renderTile()"
|
||||
to: "MatchAnimation.getScaleAndAlpha()"
|
||||
via: "animation lookup and transform"
|
||||
pattern: "matchAnimations.get"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement tile match animations with scale+fade effect for satisfying visual feedback when tiles are cleared.
|
||||
|
||||
Purpose: Creates the satisfying "aha!" moment when players successfully match tiles - the core emotional reward of the game.
|
||||
Output: MatchAnimation class integrated into Renderer with scale+fade transforms applied during tile rendering.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@./.claude/get-shit-done/workflows/execute-plan.md
|
||||
@./.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/06-polish-and-ux/06-CONTEXT.md
|
||||
@.planning/phases/06-polish-and-ux/06-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts from existing codebase -->
|
||||
|
||||
From src/rendering/Renderer.ts (existing patterns):
|
||||
```typescript
|
||||
// Existing ShakeAnimation pattern to follow:
|
||||
class ShakeAnimation {
|
||||
private startTime: number;
|
||||
private readonly duration: number;
|
||||
start(): void;
|
||||
getOffset(): { x: number; y: number };
|
||||
isComplete(): boolean;
|
||||
}
|
||||
|
||||
// Renderer properties to extend:
|
||||
private shakeAnimations: Map<string, ShakeAnimation> = new Map();
|
||||
private readonly PATH_DISPLAY_DURATION = 300;
|
||||
|
||||
// Existing methods:
|
||||
render(): void;
|
||||
renderTile(ctx, tile, offsetX, offsetY): void;
|
||||
drawPath(path: TilePosition[]): void;
|
||||
```
|
||||
|
||||
From src/game/Game.ts:
|
||||
```typescript
|
||||
// Event emission pattern:
|
||||
this.events.emit('tilesMatched', { tile1, tile2, path, turns, score });
|
||||
|
||||
// Current timing (line 86-97):
|
||||
setTimeout(() => {
|
||||
this.gridManager.clearTiles([tile1, tile2]);
|
||||
}, 300); // Wait for path animation (300ms)
|
||||
```
|
||||
|
||||
From src/config.ts:
|
||||
```typescript
|
||||
export const CONFIG = {
|
||||
// Add animation constant here
|
||||
} as const;
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Create MatchAnimation class</name>
|
||||
<files>src/rendering/Renderer.ts</files>
|
||||
<behavior>
|
||||
- MatchAnimation starts with startTime=0 and duration=250ms
|
||||
- getScaleAndAlpha() returns {scale, alpha} based on elapsed time
|
||||
- Scale phases: 0-50% = grow to 1.2, 50-100% = shrink to 0
|
||||
- Alpha phases: linear fade from 1.0 to 0
|
||||
- isComplete() returns true when elapsed > duration
|
||||
- easeOutBack easing for "pop" feel in grow phase
|
||||
- easeInQuad easing for fade
|
||||
</behavior>
|
||||
<action>
|
||||
Create MatchAnimation class following ShakeAnimation pattern in Renderer.ts:
|
||||
|
||||
1. Add class MatchAnimation after ShakeAnimation class (line 75):
|
||||
- Properties: startTime (number), duration (number, default 250)
|
||||
- Constructor(duration = 250): sets this.duration, startTime = 0
|
||||
- start(): sets startTime = performance.now()
|
||||
- getScaleAndAlpha(): returns { scale: number, alpha: number }
|
||||
- elapsed = performance.now() - startTime
|
||||
- If elapsed > duration, return { scale: 0, alpha: 0 }
|
||||
- progress = elapsed / duration
|
||||
- Grow phase (progress < 0.5): scale = 1 + 0.2 * easeOutBack(progress * 2)
|
||||
- Shrink phase (progress >= 0.5): scale = 1.2 * (1 - (progress - 0.5) * 2)
|
||||
- Alpha: 1 - easeInQuad(progress)
|
||||
- isComplete(): returns performance.now() - startTime > duration
|
||||
- Private easeOutBack(t): c1 = 1.70158, c3 = c1 + 1, return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2)
|
||||
- Private easeInQuad(t): return t * t
|
||||
|
||||
Use standard easing coefficients. The "pop" feel comes from easeOutBack overshooting slightly.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run test -- --run src/__tests__/Renderer.test.ts</automated>
|
||||
</verify>
|
||||
<done>MatchAnimation class exists with start(), getScaleAndAlpha(), and isComplete() methods. Tests verify scale grows then shrinks, alpha fades, and isComplete returns true after duration.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Add animation constant to CONFIG</name>
|
||||
<files>src/config.ts</files>
|
||||
<behavior>
|
||||
- CONFIG has animation.MATCH_ANIMATION_DURATION = 250
|
||||
- Value matches research recommendation (200-300ms range)
|
||||
</behavior>
|
||||
<action>
|
||||
Add animation configuration to CONFIG in src/config.ts:
|
||||
|
||||
1. Add 'animation' property to CONFIG object:
|
||||
```typescript
|
||||
animation: {
|
||||
matchDuration: 250, // ms - scale+fade duration per CONTEXT.md
|
||||
},
|
||||
```
|
||||
|
||||
2. Place after 'colors' property, before closing brace.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run test -- --run src/__tests__/config.test.ts</automated>
|
||||
</verify>
|
||||
<done>CONFIG.animation.matchDuration exists with value 250. Existing tests pass.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Integrate MatchAnimation into Renderer</name>
|
||||
<files>src/rendering/Renderer.ts</files>
|
||||
<behavior>
|
||||
- Renderer has matchAnimations Map<string, MatchAnimation>
|
||||
- animateMatch(tiles) creates and starts animations for each tile
|
||||
- renderTile() applies scale and alpha transforms when animation exists
|
||||
- Completed animations are cleaned up from the map
|
||||
</behavior>
|
||||
<action>
|
||||
Integrate MatchAnimation into Renderer class:
|
||||
|
||||
1. Add property after shakeAnimations (line 82):
|
||||
```typescript
|
||||
private matchAnimations: Map<string, MatchAnimation> = new Map();
|
||||
```
|
||||
|
||||
2. Add constant after PATH_DISPLAY_DURATION (line 84):
|
||||
```typescript
|
||||
private readonly MATCH_ANIMATION_DURATION = CONFIG.animation.matchDuration;
|
||||
```
|
||||
|
||||
3. Add animateMatch() method after animateShake() (after line 299):
|
||||
```typescript
|
||||
/**
|
||||
* Start match animation for specified tiles
|
||||
* @param tiles - Tiles to animate with scale+fade effect
|
||||
*/
|
||||
animateMatch(tiles: Tile[]): void {
|
||||
for (const tile of tiles) {
|
||||
const animation = new MatchAnimation(this.MATCH_ANIMATION_DURATION);
|
||||
animation.start();
|
||||
this.matchAnimations.set(tile.id, animation);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. Modify renderTile() method to apply animation transforms (lines 151-184):
|
||||
- After calculating x, y position, calculate centerX and centerY
|
||||
- Check for matchAnimation before drawing
|
||||
- If animation exists:
|
||||
- Get { scale, alpha } from animation
|
||||
- ctx.save()
|
||||
- ctx.globalAlpha = alpha
|
||||
- ctx.translate(centerX, centerY)
|
||||
- ctx.scale(scale, scale)
|
||||
- ctx.translate(-centerX, -centerY)
|
||||
- Draw tile (existing code)
|
||||
- ctx.restore()
|
||||
- If animation.isComplete(), delete from matchAnimations
|
||||
- If no animation, draw tile normally (existing code)
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run test -- --run src/__tests__/Renderer.test.ts</automated>
|
||||
</verify>
|
||||
<done>animateMatch() method exists. renderTile() applies scale/alpha transforms for animating tiles. Tests verify animation integration.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- Unit tests pass for MatchAnimation class
|
||||
- Unit tests pass for Renderer.animateMatch() integration
|
||||
- Visual verification: matched tiles show scale+fade effect before clearing
|
||||
- Animation timing: 250ms concurrent with 300ms path display
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. MatchAnimation class follows ShakeAnimation pattern with easeOutBack easing
|
||||
2. CONFIG.animation.matchDuration = 250ms constant added
|
||||
3. Renderer.animateMatch(tiles) method available for Game.ts to call
|
||||
4. renderTile() applies scale/alpha transforms for animating tiles
|
||||
5. All existing tests continue to pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-polish-and-ux/06-01-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
phase: 06-polish-and-ux
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified: [src/rendering/Renderer.ts]
|
||||
autonomous: true
|
||||
requirements: [UX-01]
|
||||
user_setup: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Connection path displays with visible glow effect behind green line"
|
||||
- "Glow effect makes path more visible and satisfying"
|
||||
- "Path animation duration remains at 300ms"
|
||||
artifacts:
|
||||
- path: "src/rendering/Renderer.ts"
|
||||
provides: "Enhanced path drawing with glow"
|
||||
contains: "shadowBlur"
|
||||
min_lines: 10
|
||||
key_links:
|
||||
- from: "drawPathLine()"
|
||||
to: "Canvas shadowBlur API"
|
||||
via: "ctx.shadowBlur, ctx.shadowColor"
|
||||
pattern: "shadowBlur.*15"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add glow effect to connection path visualization for more visible and satisfying match feedback.
|
||||
|
||||
Purpose: Makes the successful connection path more prominent and rewarding, enhancing the visual satisfaction of finding valid matches.
|
||||
Output: Enhanced drawPathLine() method with canvas shadowBlur glow effect behind green line.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@./.claude/get-shit-done/workflows/execute-plan.md
|
||||
@./.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/06-polish-and-ux/06-CONTEXT.md
|
||||
@.planning/phases/06-polish-and-ux/06-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts from existing codebase -->
|
||||
|
||||
From src/rendering/Renderer.ts (current drawPathLine implementation):
|
||||
```typescript
|
||||
private drawPathLine(path: TilePosition[]): void {
|
||||
if (path.length < 2) return;
|
||||
|
||||
// Calculate grid offset
|
||||
const { size, gap } = CONFIG.tile;
|
||||
const gridWidth = CONFIG.grid.cols * (size + gap) + gap;
|
||||
const gridHeight = CONFIG.grid.rows * (size + gap) + gap;
|
||||
const offsetX = (this.canvas.width - gridWidth) / 2;
|
||||
const offsetY = (this.canvas.height - gridHeight) / 2;
|
||||
|
||||
// Set path style
|
||||
this.ctx.strokeStyle = '#00ff00'; // Green color per CONTEXT.md
|
||||
this.ctx.lineWidth = 3;
|
||||
this.ctx.lineCap = 'round';
|
||||
this.ctx.lineJoin = 'round';
|
||||
|
||||
// Begin path
|
||||
this.ctx.beginPath();
|
||||
// ... coordinate calculations ...
|
||||
this.ctx.stroke();
|
||||
}
|
||||
```
|
||||
|
||||
Canvas glow API (from RESEARCH.md):
|
||||
```typescript
|
||||
ctx.shadowColor = '#00ff00';
|
||||
ctx.shadowBlur = 15; // Glow intensity (keep under 20 for performance)
|
||||
ctx.strokeStyle = '#00ff00';
|
||||
ctx.lineWidth = 3;
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Add glow effect to drawPathLine()</name>
|
||||
<files>src/rendering/Renderer.ts</files>
|
||||
<behavior>
|
||||
- Path line has shadowBlur = 15 for glow effect
|
||||
- shadowColor matches strokeColor (#00ff00)
|
||||
- Glow is drawn first, then solid line on top for crispness
|
||||
- ctx.save() and ctx.restore() preserve context state
|
||||
</behavior>
|
||||
<action>
|
||||
Enhance drawPathLine() method in Renderer.ts (lines 357-394):
|
||||
|
||||
1. Wrap the entire path drawing in ctx.save()/ctx.restore():
|
||||
- Add ctx.save() before setting styles (after early return check)
|
||||
- Add ctx.restore() after ctx.stroke()
|
||||
|
||||
2. Add glow effect before drawing the path:
|
||||
- Set ctx.shadowColor = '#00ff00' (same as stroke color)
|
||||
- Set ctx.shadowBlur = 15 (per RESEARCH.md recommendation)
|
||||
- Keep existing ctx.strokeStyle = '#00ff00'
|
||||
- Keep existing ctx.lineWidth = 3
|
||||
- Keep existing ctx.lineCap = 'round'
|
||||
- Keep existing ctx.lineJoin = 'round'
|
||||
|
||||
3. The existing single stroke() call will now draw with glow
|
||||
|
||||
No changes to coordinate calculations or path logic. Only adding glow effect properties.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run test -- --run src/__tests__/Renderer.test.ts</automated>
|
||||
</verify>
|
||||
<done>drawPathLine() sets shadowBlur and shadowColor before stroking path. Tests verify glow properties are set. Visual: path has soft green glow.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wire animateMatch call in Game.ts</name>
|
||||
<files>src/game/Game.ts</files>
|
||||
<action>
|
||||
Connect the animateMatch method to the tilesMatched event in Game.ts:
|
||||
|
||||
1. Find the tilesMatched event handler (around line 69-84)
|
||||
|
||||
2. Add animateMatch call after drawPath:
|
||||
```typescript
|
||||
// Successful match - draw path first
|
||||
this.renderer.drawPath(result.path!);
|
||||
|
||||
// Start match animation (concurrent with path display)
|
||||
this.renderer.animateMatch([tile1, tile2]);
|
||||
```
|
||||
|
||||
3. The animation runs concurrently with path display (both 250-300ms)
|
||||
|
||||
This wires the MatchAnimation from plan 06-01 into the game flow.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run test -- --run src/__tests__/Game.test.ts</automated>
|
||||
</verify>
|
||||
<done>Game.ts calls renderer.animateMatch([tile1, tile2]) when tilesMatched. Animation starts immediately, concurrent with path display.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- Unit tests pass for drawPathLine glow effect
|
||||
- Visual verification: green path line has visible soft glow around it
|
||||
- Path remains crisp with glow enhancing visibility
|
||||
- Performance: shadowBlur under 20px as recommended
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. drawPathLine() uses canvas shadowBlur API for glow effect
|
||||
2. shadowBlur = 15, shadowColor = '#00ff00'
|
||||
3. Context state preserved with save/restore
|
||||
4. Game.ts wires animateMatch call to tilesMatched event
|
||||
5. All existing tests continue to pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-polish-and-ux/06-02-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,299 @@
|
||||
---
|
||||
phase: 06-polish-and-ux
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [06-01, 06-02]
|
||||
files_modified: [src/rendering/Renderer.ts, src/game/Game.ts, index.html]
|
||||
autonomous: true
|
||||
requirements: [UX-02]
|
||||
user_setup: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Touch input on mobile does not trigger zoom or scroll"
|
||||
- "Visual ripple effect appears at touch point when tile is selected"
|
||||
- "Game remains playable on mobile without accidental gestures"
|
||||
artifacts:
|
||||
- path: "src/rendering/Renderer.ts"
|
||||
provides: "RippleAnimation class"
|
||||
contains: "class RippleAnimation"
|
||||
exports: ["addRipple"]
|
||||
- path: "src/game/Game.ts"
|
||||
provides: "Touch prevention and ripple triggering"
|
||||
contains: "touch-action: none"
|
||||
- path: "index.html"
|
||||
provides: "CSS touch-action property"
|
||||
contains: "touch-action: none"
|
||||
key_links:
|
||||
- from: "Game.handleInput()"
|
||||
to: "Renderer.addRipple()"
|
||||
via: "touch event handling"
|
||||
pattern: "addRipple.*clientX.*clientY"
|
||||
- from: "index.html #game style"
|
||||
to: "Touch prevention"
|
||||
via: "CSS touch-action"
|
||||
pattern: "touch-action.*none"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add mobile touch optimization with ripple feedback and zoom/scroll prevention.
|
||||
|
||||
Purpose: Makes the game feel responsive and polished on mobile devices, preventing accidental browser gestures during gameplay.
|
||||
Output: RippleAnimation class, touch-action CSS, and ripple triggering on touch input.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@./.claude/get-shit-done/workflows/execute-plan.md
|
||||
@./.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/06-polish-and-ux/06-CONTEXT.md
|
||||
@.planning/phases/06-polish-and-ux/06-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts from existing codebase -->
|
||||
|
||||
From src/game/Game.ts (current touch handling):
|
||||
```typescript
|
||||
private handleTouch = (event: TouchEvent): void => {
|
||||
this.handleInput(event);
|
||||
};
|
||||
|
||||
public setupInputListeners(): void {
|
||||
this.canvas.addEventListener('click', this.handleClick);
|
||||
this.canvas.addEventListener('touchstart', this.handleTouch, { passive: true });
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
}
|
||||
```
|
||||
|
||||
From index.html (current canvas CSS):
|
||||
```css
|
||||
#game {
|
||||
border-radius: 8px;
|
||||
}
|
||||
```
|
||||
|
||||
From RESEARCH.md (RippleAnimation pattern):
|
||||
```typescript
|
||||
class RippleAnimation {
|
||||
private startTime: number;
|
||||
private readonly x: number;
|
||||
private readonly y: number;
|
||||
private readonly duration: number = 300;
|
||||
private readonly maxRadius: number = 40;
|
||||
|
||||
constructor(x: number, y: number) { ... }
|
||||
render(ctx: CanvasRenderingContext2D): boolean { ... }
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Add touch-action CSS to canvas</name>
|
||||
<files>index.html</files>
|
||||
<behavior>
|
||||
- Canvas has touch-action: none to prevent zoom/scroll
|
||||
- Body has overflow: hidden to prevent page scroll
|
||||
</behavior>
|
||||
<action>
|
||||
Update CSS in index.html:
|
||||
|
||||
1. Add touch-action to #game style (line 21-22):
|
||||
```css
|
||||
#game {
|
||||
border-radius: 8px;
|
||||
touch-action: none; /* Prevent all touch gestures */
|
||||
}
|
||||
```
|
||||
|
||||
2. Add overflow: hidden to body style (line 13-19):
|
||||
```css
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background-color: #1a1a2e;
|
||||
overflow: hidden; /* Prevent scroll */
|
||||
}
|
||||
```
|
||||
|
||||
This is the simplest and most reliable approach for touch prevention per RESEARCH.md.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>grep -q "touch-action: none" index.html</automated>
|
||||
</verify>
|
||||
<done>Canvas has touch-action: none CSS. Body has overflow: hidden. Visual: no zoom/scroll on mobile touch.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Create RippleAnimation class</name>
|
||||
<files>src/rendering/Renderer.ts</files>
|
||||
<behavior>
|
||||
- RippleAnimation starts with x, y coordinates and startTime
|
||||
- render(ctx) draws expanding circle with fading alpha
|
||||
- Returns false when animation complete (elapsed > duration)
|
||||
- Duration = 300ms, maxRadius = 40px
|
||||
- Color matches selection color (rgba(233, 69, 96, alpha))
|
||||
</behavior>
|
||||
<action>
|
||||
Create RippleAnimation class in Renderer.ts after MatchAnimation class:
|
||||
|
||||
1. Add class RippleAnimation (after MatchAnimation, before Renderer class):
|
||||
```typescript
|
||||
/**
|
||||
* RippleAnimation class for touch feedback effect
|
||||
* Creates expanding circle at touch point
|
||||
*/
|
||||
class RippleAnimation {
|
||||
private startTime: number;
|
||||
private readonly x: number;
|
||||
private readonly y: number;
|
||||
private readonly duration: number = 300;
|
||||
private readonly maxRadius: number = 40;
|
||||
|
||||
constructor(x: number, y: number) {
|
||||
this.startTime = performance.now();
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render ripple and return whether animation is still active
|
||||
* @returns true if still animating, false if complete
|
||||
*/
|
||||
render(ctx: CanvasRenderingContext2D): boolean {
|
||||
const elapsed = performance.now() - this.startTime;
|
||||
if (elapsed > this.duration) return false;
|
||||
|
||||
const progress = elapsed / this.duration;
|
||||
const radius = this.maxRadius * progress;
|
||||
const alpha = 0.3 * (1 - progress); // Fade out
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(233, 69, 96, ${alpha})`; // Selection color
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Add property to Renderer class (after matchAnimations):
|
||||
```typescript
|
||||
private rippleAnimations: RippleAnimation[] = [];
|
||||
```
|
||||
|
||||
3. Add addRipple() method to Renderer class (after animateMatch):
|
||||
```typescript
|
||||
/**
|
||||
* Add ripple effect at touch/click coordinates
|
||||
* @param x - Canvas X coordinate
|
||||
* @param y - Canvas Y coordinate
|
||||
*/
|
||||
addRipple(x: number, y: number): void {
|
||||
this.rippleAnimations.push(new RippleAnimation(x, y));
|
||||
}
|
||||
```
|
||||
|
||||
4. Add ripple rendering in render() method (after path animation, before tiles):
|
||||
```typescript
|
||||
// Draw ripple animations
|
||||
this.rippleAnimations = this.rippleAnimations.filter(ripple =>
|
||||
ripple.render(this.ctx)
|
||||
);
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run test -- --run src/__tests__/Renderer.test.ts</automated>
|
||||
</verify>
|
||||
<done>RippleAnimation class exists. addRipple() method available. Ripples render and auto-cleanup on completion.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Trigger ripple on touch input</name>
|
||||
<files>src/game/Game.ts</files>
|
||||
<behavior>
|
||||
- Touch input triggers ripple at touch coordinates
|
||||
- Ripple coordinates match tile selection coordinates
|
||||
- Works alongside existing tile selection logic
|
||||
</behavior>
|
||||
<action>
|
||||
Update handleInput() in Game.ts to trigger ripple effect:
|
||||
|
||||
1. Modify handleInput() method (around line 239-272) to add ripple:
|
||||
- After extracting clientX, clientY from event
|
||||
- Convert to canvas coordinates (existing logic)
|
||||
- Call this.renderer.addRipple(x, y) with canvas coordinates
|
||||
|
||||
```typescript
|
||||
private handleInput(event: MouseEvent | TouchEvent): void {
|
||||
// Block input if game is in GAME_OVER state
|
||||
if (!this.gameStateManager.canSelectTile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// Extract client coordinates
|
||||
let clientX: number, clientY: number;
|
||||
if ('changedTouches' in event) {
|
||||
clientX = event.changedTouches[0].clientX;
|
||||
clientY = event.changedTouches[0].clientY;
|
||||
} else {
|
||||
clientX = event.clientX;
|
||||
clientY = event.clientY;
|
||||
}
|
||||
|
||||
// Convert to canvas coordinates
|
||||
const x = (clientX - rect.left) * (this.canvas.width / rect.width / dpr);
|
||||
const y = (clientY - rect.top) * (this.canvas.height / rect.height / dpr);
|
||||
|
||||
// Add ripple effect at touch/click point
|
||||
this.renderer.addRipple(x, y);
|
||||
|
||||
// ... rest of existing tile selection logic ...
|
||||
}
|
||||
```
|
||||
|
||||
2. The ripple appears at the same coordinates used for tile hit detection.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run test -- --run src/__tests__/Game.test.ts</automated>
|
||||
</verify>
|
||||
<done>handleInput() calls renderer.addRipple(x, y). Ripple appears at touch/click point before tile selection.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- Unit tests pass for RippleAnimation class
|
||||
- Unit tests pass for addRipple() integration
|
||||
- Visual: ripple effect appears on touch/click
|
||||
- Mobile: no zoom/scroll when touching canvas
|
||||
- Touch selection still works correctly
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. index.html has touch-action: none on canvas
|
||||
2. RippleAnimation class with 300ms duration, 40px max radius
|
||||
3. Renderer.addRipple(x, y) method available
|
||||
4. Game.handleInput() triggers ripple at touch coordinates
|
||||
5. All existing tests continue to pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-polish-and-ux/06-03-SUMMARY.md`
|
||||
</output>
|
||||
@@ -0,0 +1,269 @@
|
||||
---
|
||||
phase: 06-polish-and-ux
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [06-01, 06-02]
|
||||
files_modified: [src/game/Game.ts, index.html]
|
||||
autonomous: true
|
||||
requirements: [UX-03]
|
||||
user_setup: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Game grid fits within viewport on mobile devices"
|
||||
- "Canvas scales down on small screens, never scales up on large screens"
|
||||
- "Aspect ratio is preserved (no stretching)"
|
||||
- "Both portrait and landscape orientations work"
|
||||
artifacts:
|
||||
- path: "src/game/Game.ts"
|
||||
provides: "Responsive canvas scaling logic"
|
||||
contains: "scale down only"
|
||||
min_lines: 20
|
||||
- path: "index.html"
|
||||
provides: "Viewport-relative positioning"
|
||||
contains: "transformOrigin"
|
||||
key_links:
|
||||
- from: "Game.setupCanvas()"
|
||||
to: "CSS transform scale"
|
||||
via: "canvas.style.transform"
|
||||
pattern: "scale.*displayWidth.*nativeWidth"
|
||||
- from: "handleResize()"
|
||||
to: "Responsive recalculation"
|
||||
via: "setupCanvas() call"
|
||||
pattern: "setupCanvas"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement responsive canvas scaling for mobile and desktop compatibility.
|
||||
|
||||
Purpose: Ensures the game is playable on all screen sizes by scaling the canvas to fit the viewport while maintaining aspect ratio and visual quality.
|
||||
Output: Enhanced setupCanvas() with CSS transform scaling, scale-down-only logic.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@./.claude/get-shit-done/workflows/execute-plan.md
|
||||
@./.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/06-polish-and-ux/06-CONTEXT.md
|
||||
@.planning/phases/06-polish-and-ux/06-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts from existing codebase -->
|
||||
|
||||
From src/game/Game.ts (current setupCanvas):
|
||||
```typescript
|
||||
private setupCanvas(): void {
|
||||
const { cols, rows } = CONFIG.grid;
|
||||
const { size, gap } = CONFIG.tile;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// Calculate logical canvas size
|
||||
const width = cols * (size + gap) + gap; // 832px
|
||||
const height = rows * (size + gap) + gap; // 528px
|
||||
|
||||
// Set actual canvas size (accounting for device pixel ratio)
|
||||
this.canvas.width = width * dpr;
|
||||
this.canvas.height = height * dpr;
|
||||
|
||||
// Set display size (CSS)
|
||||
this.canvas.style.width = `${width}px`;
|
||||
this.canvas.style.height = `${height}px`;
|
||||
|
||||
// Scale context to account for device pixel ratio
|
||||
this.ctx.scale(dpr, dpr);
|
||||
}
|
||||
```
|
||||
|
||||
From RESEARCH.md (responsive scaling pattern):
|
||||
```typescript
|
||||
// Native canvas size: 832 x 528
|
||||
const nativeWidth = 832;
|
||||
const nativeHeight = 528;
|
||||
const aspectRatio = nativeWidth / nativeHeight;
|
||||
|
||||
// Scale down only on small screens
|
||||
if (viewportWidth < nativeWidth || viewportHeight < nativeHeight) {
|
||||
const scale = Math.min(viewportWidth / nativeWidth, viewportHeight / nativeHeight, 1);
|
||||
displayWidth = nativeWidth * scale;
|
||||
displayHeight = nativeHeight * scale;
|
||||
}
|
||||
|
||||
// Use CSS transform for display scaling (preserves coordinates)
|
||||
this.canvas.style.transform = `scale(${scale})`;
|
||||
this.canvas.style.transformOrigin = 'center center';
|
||||
```
|
||||
|
||||
From index.html (current body CSS):
|
||||
```css
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background-color: #1a1a2e;
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Add responsive scaling to setupCanvas()</name>
|
||||
<files>src/game/Game.ts</files>
|
||||
<behavior>
|
||||
- Canvas scales down to fit viewport on small screens
|
||||
- Never scales up beyond native size (832x528)
|
||||
- Aspect ratio is preserved
|
||||
- CSS transform used for display scaling
|
||||
- DPR handling remains unchanged
|
||||
</behavior>
|
||||
<action>
|
||||
Enhance setupCanvas() method in Game.ts (lines 153-172):
|
||||
|
||||
1. Replace the entire method with responsive version:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Sets up canvas dimensions with responsive scaling
|
||||
* Scales down to fit viewport on small screens, never scales up
|
||||
*/
|
||||
private setupCanvas(): void {
|
||||
const { cols, rows } = CONFIG.grid;
|
||||
const { size, gap } = CONFIG.tile;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// Calculate native canvas size
|
||||
const nativeWidth = cols * (size + gap) + gap; // 832px
|
||||
const nativeHeight = rows * (size + gap) + gap; // 528px
|
||||
|
||||
// Get viewport dimensions (with padding for UI elements)
|
||||
const viewportWidth = window.innerWidth - 40; // 20px padding each side
|
||||
const viewportHeight = window.innerHeight - 80; // Space for score display
|
||||
|
||||
// Calculate scale (scale down only, never up)
|
||||
let scale = 1;
|
||||
if (viewportWidth < nativeWidth || viewportHeight < nativeHeight) {
|
||||
const scaleByWidth = viewportWidth / nativeWidth;
|
||||
const scaleByHeight = viewportHeight / nativeHeight;
|
||||
scale = Math.min(scaleByWidth, scaleByHeight, 1);
|
||||
}
|
||||
|
||||
// Calculate display size
|
||||
const displayWidth = nativeWidth * scale;
|
||||
const displayHeight = nativeHeight * scale;
|
||||
|
||||
// Set canvas internal size (with DPR for sharp rendering)
|
||||
this.canvas.width = nativeWidth * dpr;
|
||||
this.canvas.height = nativeHeight * dpr;
|
||||
|
||||
// Set display size via CSS (native size, scaled with transform)
|
||||
this.canvas.style.width = `${nativeWidth}px`;
|
||||
this.canvas.style.height = `${nativeHeight}px`;
|
||||
this.canvas.style.transform = `scale(${scale})`;
|
||||
this.canvas.style.transformOrigin = 'center center';
|
||||
|
||||
// Scale context for DPR (reset first to avoid accumulation)
|
||||
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
this.ctx.scale(dpr, dpr);
|
||||
}
|
||||
```
|
||||
|
||||
Key changes:
|
||||
- Added viewport dimension calculation with padding
|
||||
- Added scale calculation (scale down only)
|
||||
- Added CSS transform for display scaling
|
||||
- Added ctx.setTransform() to prevent scale accumulation on resize
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run test -- --run src/__tests__/Game.test.ts</automated>
|
||||
</verify>
|
||||
<done>setupCanvas() scales canvas down to fit viewport. Transform used for display. Tests verify scale calculation logic.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Update coordinate mapping for scaled canvas</name>
|
||||
<files>src/game/Game.ts</files>
|
||||
<behavior>
|
||||
- Touch/click coordinates correctly map to tiles on scaled canvas
|
||||
- Works with CSS transform scaling
|
||||
- DPR handling preserved
|
||||
</behavior>
|
||||
<action>
|
||||
Update handleInput() in Game.ts to account for CSS transform scaling:
|
||||
|
||||
1. The current coordinate calculation already works correctly with CSS transform
|
||||
because getBoundingClientRect() returns the actual displayed size.
|
||||
|
||||
2. Verify the calculation is correct (lines 259-260):
|
||||
```typescript
|
||||
const x = (clientX - rect.left) * (this.canvas.width / rect.width / dpr);
|
||||
const y = (clientY - rect.top) * (this.canvas.height / rect.height / dpr);
|
||||
```
|
||||
|
||||
This formula accounts for:
|
||||
- rect.left/top: position relative to viewport (accounts for CSS transform)
|
||||
- rect.width/height: displayed size (accounts for CSS transform scale)
|
||||
- this.canvas.width/height: internal size (native size * DPR)
|
||||
- DPR: device pixel ratio
|
||||
|
||||
3. No changes needed - the existing formula handles CSS transform correctly.
|
||||
The transform scales the visual display but getBoundingClientRect() reports
|
||||
the transformed dimensions, so the ratio calculation works.
|
||||
|
||||
This is a verification task - confirm the existing code handles scaling correctly.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npm run test -- --run src/__tests__/Game.test.ts</automated>
|
||||
</verify>
|
||||
<done>Coordinate mapping verified to work with CSS transform scaling. No code changes needed - existing formula is correct.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Add human verification checkpoint</name>
|
||||
<files>index.html</files>
|
||||
<action>
|
||||
This task is a checkpoint for human verification of responsive layout.
|
||||
|
||||
After implementation, test on:
|
||||
1. Desktop browser - canvas should be at native 832x528 size, centered
|
||||
2. Resize browser to mobile width - canvas should scale down proportionally
|
||||
3. Mobile device (or Chrome DevTools mobile emulation) - canvas should fit screen
|
||||
4. Both portrait and landscape orientations should work
|
||||
|
||||
No code changes for this task - visual verification only.
|
||||
</action>
|
||||
<verify>
|
||||
Manual: Test responsive scaling in browser by resizing window and using mobile emulation.
|
||||
</verify>
|
||||
<done>Visual verification complete - canvas scales correctly on different screen sizes.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- Unit tests pass for setupCanvas() scale calculation
|
||||
- Desktop: canvas displays at native 832x528
|
||||
- Mobile (narrow viewport): canvas scales down to fit
|
||||
- Aspect ratio preserved (no stretching)
|
||||
- Touch/click coordinates still map correctly
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. setupCanvas() calculates scale based on viewport dimensions
|
||||
2. CSS transform used for display scaling (not canvas.width/height)
|
||||
3. Scale down only - never scales up beyond native size
|
||||
4. ctx.setTransform() prevents scale accumulation on resize
|
||||
5. Coordinate mapping verified to work with scaling
|
||||
6. All existing tests continue to pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/06-polish-and-ux/06-04-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user