From d41c5d9e8a061e873f31254a5df0e5f9af60d08e Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 11 Mar 2026 11:18:34 +0000 Subject: [PATCH] docs(06): add phase plans for Polish and UX --- .planning/ROADMAP.md | 14 +- .../phases/06-polish-and-ux/06-01-PLAN.md | 241 ++++++++++++++ .../phases/06-polish-and-ux/06-02-PLAN.md | 168 ++++++++++ .../phases/06-polish-and-ux/06-03-PLAN.md | 299 ++++++++++++++++++ .../phases/06-polish-and-ux/06-04-PLAN.md | 269 ++++++++++++++++ 5 files changed, 984 insertions(+), 7 deletions(-) create mode 100644 .planning/phases/06-polish-and-ux/06-01-PLAN.md create mode 100644 .planning/phases/06-polish-and-ux/06-02-PLAN.md create mode 100644 .planning/phases/06-polish-and-ux/06-03-PLAN.md create mode 100644 .planning/phases/06-polish-and-ux/06-04-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 7f9eff6..672f828 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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* diff --git a/.planning/phases/06-polish-and-ux/06-01-PLAN.md b/.planning/phases/06-polish-and-ux/06-01-PLAN.md new file mode 100644 index 0000000..76121ed --- /dev/null +++ b/.planning/phases/06-polish-and-ux/06-01-PLAN.md @@ -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" +--- + + +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. + + + +@./.claude/get-shit-done/workflows/execute-plan.md +@./.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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 = 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; +``` + + + + + + + Task 1: Create MatchAnimation class + src/rendering/Renderer.ts + + - 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 + + + 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. + + + npm run test -- --run src/__tests__/Renderer.test.ts + + MatchAnimation class exists with start(), getScaleAndAlpha(), and isComplete() methods. Tests verify scale grows then shrinks, alpha fades, and isComplete returns true after duration. + + + + Task 2: Add animation constant to CONFIG + src/config.ts + + - CONFIG has animation.MATCH_ANIMATION_DURATION = 250 + - Value matches research recommendation (200-300ms range) + + + 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. + + + npm run test -- --run src/__tests__/config.test.ts + + CONFIG.animation.matchDuration exists with value 250. Existing tests pass. + + + + Task 3: Integrate MatchAnimation into Renderer + src/rendering/Renderer.ts + + - 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 + + + Integrate MatchAnimation into Renderer class: + + 1. Add property after shakeAnimations (line 82): + ```typescript + private matchAnimations: Map = 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) + + + npm run test -- --run src/__tests__/Renderer.test.ts + + animateMatch() method exists. renderTile() applies scale/alpha transforms for animating tiles. Tests verify animation integration. + + + + + +- 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 + + + +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 + + + +After completion, create `.planning/phases/06-polish-and-ux/06-01-SUMMARY.md` + diff --git a/.planning/phases/06-polish-and-ux/06-02-PLAN.md b/.planning/phases/06-polish-and-ux/06-02-PLAN.md new file mode 100644 index 0000000..176a8dd --- /dev/null +++ b/.planning/phases/06-polish-and-ux/06-02-PLAN.md @@ -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" +--- + + +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. + + + +@./.claude/get-shit-done/workflows/execute-plan.md +@./.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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; +``` + + + + + + + Task 1: Add glow effect to drawPathLine() + src/rendering/Renderer.ts + + - 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 + + + 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. + + + npm run test -- --run src/__tests__/Renderer.test.ts + + drawPathLine() sets shadowBlur and shadowColor before stroking path. Tests verify glow properties are set. Visual: path has soft green glow. + + + + Task 2: Wire animateMatch call in Game.ts + src/game/Game.ts + + 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. + + + npm run test -- --run src/__tests__/Game.test.ts + + Game.ts calls renderer.animateMatch([tile1, tile2]) when tilesMatched. Animation starts immediately, concurrent with path display. + + + + + +- 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 + + + +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 + + + +After completion, create `.planning/phases/06-polish-and-ux/06-02-SUMMARY.md` + diff --git a/.planning/phases/06-polish-and-ux/06-03-PLAN.md b/.planning/phases/06-polish-and-ux/06-03-PLAN.md new file mode 100644 index 0000000..fca515d --- /dev/null +++ b/.planning/phases/06-polish-and-ux/06-03-PLAN.md @@ -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" +--- + + +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. + + + +@./.claude/get-shit-done/workflows/execute-plan.md +@./.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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 { ... } +} +``` + + + + + + + Task 1: Add touch-action CSS to canvas + index.html + + - Canvas has touch-action: none to prevent zoom/scroll + - Body has overflow: hidden to prevent page scroll + + + 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. + + + grep -q "touch-action: none" index.html + + Canvas has touch-action: none CSS. Body has overflow: hidden. Visual: no zoom/scroll on mobile touch. + + + + Task 2: Create RippleAnimation class + src/rendering/Renderer.ts + + - 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)) + + + 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) + ); + ``` + + + npm run test -- --run src/__tests__/Renderer.test.ts + + RippleAnimation class exists. addRipple() method available. Ripples render and auto-cleanup on completion. + + + + Task 3: Trigger ripple on touch input + src/game/Game.ts + + - Touch input triggers ripple at touch coordinates + - Ripple coordinates match tile selection coordinates + - Works alongside existing tile selection logic + + + 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. + + + npm run test -- --run src/__tests__/Game.test.ts + + handleInput() calls renderer.addRipple(x, y). Ripple appears at touch/click point before tile selection. + + + + + +- 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 + + + +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 + + + +After completion, create `.planning/phases/06-polish-and-ux/06-03-SUMMARY.md` + diff --git a/.planning/phases/06-polish-and-ux/06-04-PLAN.md b/.planning/phases/06-polish-and-ux/06-04-PLAN.md new file mode 100644 index 0000000..08aebb1 --- /dev/null +++ b/.planning/phases/06-polish-and-ux/06-04-PLAN.md @@ -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" +--- + + +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. + + + +@./.claude/get-shit-done/workflows/execute-plan.md +@./.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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; +} +``` + + + + + + + Task 1: Add responsive scaling to setupCanvas() + src/game/Game.ts + + - 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 + + + 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 + + + npm run test -- --run src/__tests__/Game.test.ts + + setupCanvas() scales canvas down to fit viewport. Transform used for display. Tests verify scale calculation logic. + + + + Task 2: Update coordinate mapping for scaled canvas + src/game/Game.ts + + - Touch/click coordinates correctly map to tiles on scaled canvas + - Works with CSS transform scaling + - DPR handling preserved + + + 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. + + + npm run test -- --run src/__tests__/Game.test.ts + + Coordinate mapping verified to work with CSS transform scaling. No code changes needed - existing formula is correct. + + + + Task 3: Add human verification checkpoint + index.html + + 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. + + + Manual: Test responsive scaling in browser by resizing window and using mobile emulation. + + Visual verification complete - canvas scales correctly on different screen sizes. + + + + + +- 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 + + + +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 + + + +After completion, create `.planning/phases/06-polish-and-ux/06-04-SUMMARY.md` +