From a221c99345e0312d1bb0832fc32ff460ee056e99 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 11 Mar 2026 11:08:59 +0000 Subject: [PATCH] docs(06): research Phase 6 polish and UX domain --- .../phases/06-polish-and-ux/06-RESEARCH.md | 502 ++++++++++++++++++ 1 file changed, 502 insertions(+) create mode 100644 .planning/phases/06-polish-and-ux/06-RESEARCH.md diff --git a/.planning/phases/06-polish-and-ux/06-RESEARCH.md b/.planning/phases/06-polish-and-ux/06-RESEARCH.md new file mode 100644 index 0000000..d650269 --- /dev/null +++ b/.planning/phases/06-polish-and-ux/06-RESEARCH.md @@ -0,0 +1,502 @@ +# Phase 6: Polish and UX - Research + +**Researched:** 2026-03-11 +**Domain:** Canvas animations, responsive design, mobile touch optimization +**Confidence:** HIGH (based on established patterns in codebase and well-documented web standards) + +## Summary + +Phase 6 focuses on polish animations and responsive UX enhancements to make the game feel smooth and satisfying across all devices. The research covers four main areas: tile match animations (scale+fade), responsive canvas scaling, mobile touch optimization (ripple effect + zoom prevention), and enhanced path visualization (glow effect). + +The existing codebase provides strong foundations: `ShakeAnimation` class demonstrates time-based animation patterns with decay, `renderSelection()` shows fade-in animation using `performance.now()`, and the path drawing system is already in place. The key is extending these patterns rather than introducing new architectures. + +**Primary recommendation:** Extend existing animation classes and patterns in Renderer.ts rather than introducing new animation libraries. Use CSS `touch-action: none` for mobile zoom prevention (simplest, most reliable). Implement glow effect using canvas `shadowBlur` API. Scale canvas using CSS transforms for responsive layout. + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**Tile Match Animations:** +- Effect: Scale + Fade - tiles grow slightly then shrink to nothing while fading (satisfying "pop" feel) +- Duration: 200-300ms (matches existing animation timing patterns) +- Timing: Animate during path display - concurrent with green line, not sequential +- Multi-match handling: Single match only - no chaining or cascade animations + +**Responsive Layout:** +- Scaling approach: Scale entire canvas to fit viewport while maintaining aspect ratio +- Scaling direction: Scale down only on small screens - never scale up on large screens (stay at native 832x528) +- Orientation support: Works in both portrait and landscape - no orientation lock +- UI overlays: Keep current HTML overlay pattern (score, game-over, shuffle) - positioned relative to viewport + +**Mobile Touch Polish:** +- Touch feedback: Visual ripple effect at touch point when tile is selected (reinforces interaction, matches colorful aesthetic) +- Browser behavior: Prevent zoom (double-tap, pinch) and scroll on canvas - prevents accidental interaction during play +- Touch accuracy: Keep current implementation - coordinate mapping with DPR handling works well + +**Path Visualization:** +- Visual style: Add glow effect behind the green line - soft bloom/halo for more visibility +- Display duration: Keep current 300ms - matches existing animation patterns, snappy +- Color: Keep current green (#00ff00) - visible, matches success feel + +### Claude's Discretion + +- Exact easing curve for scale+fade animation +- Glow intensity and blur radius for path +- Ripple effect size and duration +- CSS vs canvas for ripple effect + +### Deferred Ideas (OUT OF SCOPE) + +None - discussion stayed within phase scope. + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| UX-01 | Matched tiles animate before disappearing | MatchAnimation class extending ShakeAnimation pattern; scale+fade with easing | +| UX-02 | Game responds to touch input on mobile devices | CSS `touch-action: none` + touch event handlers; ripple effect for visual feedback | +| UX-03 | Grid layout is responsive (works on phone and desktop) | CSS transform scaling with aspect ratio preservation; scale-down-only logic | + + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Canvas 2D API | Native | Rendering | Already in use, no dependencies needed | +| CSS transforms | Native | Responsive scaling | Browser-native, performant, no layout recalculation | +| `performance.now()` | Native | Animation timing | Already used in ShakeAnimation, consistent with codebase | + +### Supporting +| Property/Method | Purpose | When to Use | +|----------------|---------|-------------| +| `ctx.shadowBlur` / `ctx.shadowColor` | Glow effects | Path visualization enhancement | +| `touch-action: none` | Prevent touch gestures | Mobile canvas element | +| `canvas.style.transform` | Responsive scaling | Viewport fitting | +| `globalCompositeOperation` | Additive blending | Optional: stronger glow effect | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| CSS transform scaling | CSS width/height | Transform preserves internal coordinates, better for DPR handling | +| Canvas ripple effect | CSS pseudo-element | Canvas more flexible for positioning, matches game aesthetic | +| Custom easing | CSS easing functions | Canvas requires JS easing; ease-out-back recommended for "pop" | + +## Architecture Patterns + +### Recommended Project Structure +``` +src/ +├── rendering/ +│ └── Renderer.ts # Add MatchAnimation class, glow effect, ripple rendering +├── game/ +│ └── Game.ts # Add responsive scaling logic, touch prevention +├── config.ts # Add animation constants (MATCH_ANIMATION_DURATION, etc.) +└── index.html # Add touch-action CSS, viewport meta tweaks +``` + +### Pattern 1: Time-Based Animation with Decay +**What:** Animation using `performance.now()` with elapsed time calculation and decay factor +**When to use:** All canvas animations (shake, fade, scale, ripple) +**Example:** +```typescript +// Existing pattern from ShakeAnimation class (Renderer.ts:15-74) +class MatchAnimation { + private startTime: number; + private readonly duration: number; + + constructor(duration: number = 250) { + this.startTime = 0; + this.duration = duration; + } + + start(): void { + this.startTime = performance.now(); + } + + getScaleAndAlpha(): { scale: number; alpha: number } { + const elapsed = performance.now() - this.startTime; + if (elapsed > this.duration) { + return { scale: 0, alpha: 0 }; // Animation complete + } + + const progress = elapsed / this.duration; + // Scale: 1.0 -> 1.2 -> 0 (pop effect with overshoot) + // Alpha: 1.0 -> 0 (fade out) + const scale = progress < 0.5 + ? 1 + 0.2 * this.easeOutBack(progress * 2) // Grow phase + : 1.2 * (1 - (progress - 0.5) * 2); // Shrink phase + const alpha = 1 - this.easeInQuad(progress); + + return { scale, alpha }; + } + + private easeOutBack(t: number): number { + const c1 = 1.70158; + const c3 = c1 + 1; + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); + } + + private easeInQuad(t: number): number { + return t * t; + } +} +``` + +### Pattern 2: Canvas Glow Effect +**What:** Use `shadowBlur` and `shadowColor` for glow behind lines +**When to use:** Path visualization enhancement +**Example:** +```typescript +// Extend drawPathLine() in Renderer.ts (line 357-394) +private drawPathLine(path: TilePosition[]): void { + if (path.length < 2) return; + + // ... coordinate calculations ... + + // Glow effect (draw first, then line on top) + this.ctx.save(); + this.ctx.shadowColor = '#00ff00'; + this.ctx.shadowBlur = 15; // Glow intensity + this.ctx.strokeStyle = '#00ff00'; + this.ctx.lineWidth = 3; + this.ctx.lineCap = 'round'; + this.ctx.lineJoin = 'round'; + + this.ctx.beginPath(); + // ... path drawing ... + this.ctx.stroke(); + this.ctx.restore(); + + // Optional: Draw solid line on top for crispness + this.ctx.strokeStyle = '#00ff00'; + this.ctx.lineWidth = 2; + this.ctx.beginPath(); + // ... path drawing again ... + this.ctx.stroke(); +} +``` + +### Pattern 3: Responsive Canvas Scaling +**What:** Scale canvas to fit viewport while maintaining aspect ratio, scale-down only +**When to use:** Mobile and responsive layouts +**Example:** +```typescript +// Extend setupCanvas() in Game.ts (line 153-172) +private setupCanvas(): void { + const { cols, rows } = CONFIG.grid; + const { size, gap } = CONFIG.tile; + const dpr = window.devicePixelRatio || 1; + + // Native canvas size + const nativeWidth = cols * (size + gap) + gap; // 832px + const nativeHeight = rows * (size + gap) + gap; // 528px + const aspectRatio = nativeWidth / nativeHeight; + + // Get viewport dimensions + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Calculate scaled size (scale down only) + let displayWidth = nativeWidth; + let displayHeight = nativeHeight; + + if (viewportWidth < nativeWidth || viewportHeight < nativeHeight) { + // Scale to fit viewport + const scaleByWidth = viewportWidth / nativeWidth; + const scaleByHeight = viewportHeight / nativeHeight; + const scale = Math.min(scaleByWidth, scaleByHeight, 1); // Never scale up + + displayWidth = nativeWidth * scale; + displayHeight = nativeHeight * scale; + } + + // Set canvas internal size (with DPR) + this.canvas.width = nativeWidth * dpr; + this.canvas.height = nativeHeight * dpr; + + // Set display size via CSS transform (preserves coordinate system) + this.canvas.style.width = `${nativeWidth}px`; + this.canvas.style.height = `${nativeHeight}px`; + this.canvas.style.transform = `scale(${displayWidth / nativeWidth})`; + this.canvas.style.transformOrigin = 'center center'; + + // Scale context for DPR + this.ctx.scale(dpr, dpr); +} +``` + +### Pattern 4: Mobile Touch Prevention +**What:** Prevent zoom and scroll on canvas using CSS and event handlers +**When to use:** Mobile devices to prevent accidental gestures +**Example:** +```typescript +// Add to Game.ts setupInputListeners() +public setupInputListeners(): void { + // CSS approach (most reliable) + this.canvas.style.touchAction = 'none'; + + // Event-based backup for older browsers + ['touchstart', 'touchmove', 'touchend'].forEach(event => { + this.canvas.addEventListener(event, (e) => { + // Allow input handling but prevent zoom/scroll + if (e.touches && e.touches.length > 1) { + e.preventDefault(); // Multi-touch (pinch zoom) + } + }, { passive: false }); + }); + + // Existing handlers... + this.canvas.addEventListener('click', this.handleClick); + this.canvas.addEventListener('touchstart', this.handleTouch, { passive: true }); +} +``` + +### Pattern 5: Touch Ripple Effect +**What:** Visual feedback at touch point with expanding circle animation +**When to use:** Touch input on mobile devices +**Example:** +```typescript +// Add to Renderer.ts +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(ctx: CanvasRenderingContext2D): boolean { + const elapsed = performance.now() - this.startTime; + if (elapsed > this.duration) return false; // Complete + + 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; // Still animating + } +} + +// In Renderer class: +private rippleAnimations: RippleAnimation[] = []; + +addRipple(x: number, y: number): void { + this.rippleAnimations.push(new RippleAnimation(x, y)); +} +``` + +### Anti-Patterns to Avoid +- **Don't use `requestAnimationFrame` inside animations:** Already handled by GameLoop - use `performance.now()` for timing +- **Don't change canvas.width/height for scaling:** Breaks coordinate system - use CSS transforms instead +- **Don't use `passive: false` for all touch events:** Only needed for events where `preventDefault()` is called +- **Don't implement complex easing libraries:** Simple easing functions are sufficient for 200-300ms animations + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Animation timing | Custom requestAnimationFrame loop | Existing GameLoop + `performance.now()` | GameLoop already runs at 60fps, timing is consistent | +| Easing functions | Complex easing library | 2-3 simple easing functions | Animations are short (200-300ms), simple easing sufficient | +| Touch detection | Custom touch handling library | Native TouchEvent API | Already working, just needs prevention logic | +| Responsive scaling | ResizeObserver + layout recalculation | CSS transforms | Preserves coordinate system, simpler implementation | + +**Key insight:** The existing codebase has well-established patterns (ShakeAnimation, fade-in selection, path drawing). Extending these is more reliable than introducing new animation systems. + +## Common Pitfalls + +### Pitfall 1: Canvas Scaling Breaking Coordinate System +**What goes wrong:** Changing `canvas.width` or `canvas.height` for responsive scaling breaks mouse/touch coordinate mapping +**Why it happens:** Coordinate mapping uses canvas dimensions; changing them invalidates all hit detection +**How to avoid:** Use CSS transforms (`transform: scale()`) for display scaling; keep canvas internal dimensions constant +**Warning signs:** Clicks register on wrong tiles after resize + +### Pitfall 2: Touch Event Passive Handler Conflict +**What goes wrong:** `preventDefault()` doesn't work on touch events +**Why it happens:** Modern browsers default touch events to `passive: true` for scroll performance +**How to avoid:** Use `{ passive: false }` only when calling `preventDefault()`, or use CSS `touch-action: none` (preferred) +**Warning signs:** Pinch zoom still works despite `preventDefault()` call + +### Pitfall 3: Animation Jank During Path Display +**What goes wrong:** Match animation and path animation compete for rendering time +**Why it happens:** Both animations running sequentially instead of concurrently +**How to avoid:** Trigger both animations simultaneously; use same render loop; both check `performance.now()` independently +**Warning signs:** Stuttering or delayed animations when matching tiles + +### Pitfall 4: Glow Effect Performance +**What goes wrong:** Canvas rendering becomes slow with shadowBlur +**Why it happens:** `shadowBlur` is computationally expensive, especially at high values +**How to avoid:** Keep `shadowBlur` under 20px; draw glow once per frame; avoid multiple shadowed elements +**Warning signs:** Frame rate drops when path is displayed + +### Pitfall 5: DPR Mismatch After Scaling +**What goes wrong:** Canvas appears blurry on high-DPI displays after responsive scaling +**Why it happens:** CSS transform doesn't update device pixel ratio handling +**How to avoid:** Keep DPR handling in context scaling separate from display scaling; test on Retina displays +**Warning signs:** Text and tiles look fuzzy on mobile devices + +## Code Examples + +### Match Animation Integration +```typescript +// In Renderer.ts - extend existing animation system +private matchAnimations: Map = new Map(); +private readonly MATCH_ANIMATION_DURATION = 250; + +// Called from Game.ts when tilesMatched event fires +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); + } +} + +// In renderTile() - apply animation transform +private renderTile(ctx: CanvasRenderingContext2D, tile: Tile, offsetX: number, offsetY: number): void { + const matchAnimation = this.matchAnimations.get(tile.id); + + // Calculate tile center for scale transform + const x = offsetX + tile.position.col * (CONFIG.tile.size + CONFIG.tile.gap) + CONFIG.tile.gap; + const y = offsetY + tile.position.row * (CONFIG.tile.size + CONFIG.tile.gap) + CONFIG.tile.gap; + const centerX = x + CONFIG.tile.size / 2; + const centerY = y + CONFIG.tile.size / 2; + + ctx.save(); + + if (matchAnimation) { + const { scale, alpha } = matchAnimation.getScaleAndAlpha(); + ctx.globalAlpha = alpha; + ctx.translate(centerX, centerY); + ctx.scale(scale, scale); + ctx.translate(-centerX, -centerY); + + // Clean up completed animations + if (matchAnimation.isComplete()) { + this.matchAnimations.delete(tile.id); + } + } + + // ... existing tile drawing code ... + + ctx.restore(); +} +``` + +### Responsive Scaling CSS Addition +```css +/* Add to index.html