mirror of
https://github.com/tiennm99/gsd-framework.git
synced 2026-05-26 16:01:12 +00:00
feat(06-02): add glow effect to path line visualization
- Add shadowBlur=15 and shadowColor='#00ff00' for green glow - Wrap path drawing in ctx.save()/ctx.restore() for state preservation - Add tests for glow effect properties (shadowBlur, shadowColor, save/restore) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+160
-4
@@ -11,12 +11,13 @@ import { GameState } from '../state/GameStateManager';
|
||||
const mockCanvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
style: { width: '', height: '' },
|
||||
style: { width: '', height: '', transform: '', transformOrigin: '' },
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
getBoundingClientRect: vi.fn(() => ({ left: 0, top: 0, width: 836, height: 524 })),
|
||||
getContext: vi.fn(() => ({
|
||||
scale: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
fillStyle: '',
|
||||
fillRect: vi.fn(),
|
||||
})),
|
||||
@@ -34,6 +35,8 @@ describe('Game', () => {
|
||||
mockCanvas.height = 0;
|
||||
mockCanvas.style.width = '';
|
||||
mockCanvas.style.height = '';
|
||||
mockCanvas.style.transform = '';
|
||||
mockCanvas.style.transformOrigin = '';
|
||||
|
||||
// Save originals
|
||||
originalRaf = globalThis.requestAnimationFrame;
|
||||
@@ -73,9 +76,11 @@ describe('Game', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
// Mock window.devicePixelRatio
|
||||
// Mock window.devicePixelRatio and dimensions for responsive scaling
|
||||
vi.stubGlobal('window', {
|
||||
devicePixelRatio: 1,
|
||||
innerWidth: 1200, // Large viewport (no scaling needed)
|
||||
innerHeight: 800,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
@@ -205,8 +210,14 @@ describe('Game', () => {
|
||||
|
||||
describe('device pixel ratio handling', () => {
|
||||
it('should scale canvas by device pixel ratio', () => {
|
||||
// Set a custom device pixel ratio
|
||||
vi.stubGlobal('window', { devicePixelRatio: 2 });
|
||||
// Set a custom device pixel ratio with viewport dimensions
|
||||
vi.stubGlobal('window', {
|
||||
devicePixelRatio: 2,
|
||||
innerWidth: 1200,
|
||||
innerHeight: 800,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
game = new Game();
|
||||
|
||||
@@ -222,6 +233,151 @@ describe('Game', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('responsive scaling', () => {
|
||||
it('should not scale canvas on large viewport (no scaling needed)', () => {
|
||||
// Large viewport - no scaling needed
|
||||
vi.stubGlobal('window', {
|
||||
devicePixelRatio: 1,
|
||||
innerWidth: 1200,
|
||||
innerHeight: 800,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
game = new Game();
|
||||
|
||||
// Canvas should be at native size with scale(1)
|
||||
expect(mockCanvas.style.width).toBe('836px');
|
||||
expect(mockCanvas.style.height).toBe('524px');
|
||||
expect(mockCanvas.style.transform).toBe('scale(1)');
|
||||
expect(mockCanvas.style.transformOrigin).toBe('center center');
|
||||
});
|
||||
|
||||
it('should scale down canvas on narrow viewport', () => {
|
||||
// Narrow viewport - needs scaling
|
||||
vi.stubGlobal('window', {
|
||||
devicePixelRatio: 1,
|
||||
innerWidth: 600, // Smaller than native 836px + 40px padding
|
||||
innerHeight: 800,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
game = new Game();
|
||||
|
||||
// Canvas should be scaled down
|
||||
// viewportWidth (600-40=560) / nativeWidth (836) = 0.67
|
||||
const expectedScale = (600 - 40) / 836;
|
||||
expect(mockCanvas.style.transform).toBe(`scale(${expectedScale})`);
|
||||
expect(mockCanvas.style.transformOrigin).toBe('center center');
|
||||
// Native canvas size should remain unchanged
|
||||
expect(mockCanvas.width).toBe(836);
|
||||
expect(mockCanvas.height).toBe(524);
|
||||
});
|
||||
|
||||
it('should scale down canvas on short viewport', () => {
|
||||
// Short viewport - needs scaling
|
||||
vi.stubGlobal('window', {
|
||||
devicePixelRatio: 1,
|
||||
innerWidth: 1200,
|
||||
innerHeight: 400, // Smaller than native 524px + 80px padding
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
game = new Game();
|
||||
|
||||
// Canvas should be scaled down based on height constraint
|
||||
// viewportHeight (400-80=320) / nativeHeight (524) = 0.61
|
||||
const expectedScale = (400 - 80) / 524;
|
||||
expect(mockCanvas.style.transform).toBe(`scale(${expectedScale})`);
|
||||
expect(mockCanvas.style.transformOrigin).toBe('center center');
|
||||
});
|
||||
|
||||
it('should use smaller scale when both dimensions are constrained', () => {
|
||||
// Both narrow and short viewport
|
||||
vi.stubGlobal('window', {
|
||||
devicePixelRatio: 1,
|
||||
innerWidth: 500,
|
||||
innerHeight: 400,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
game = new Game();
|
||||
|
||||
// Should use the smaller scale factor
|
||||
const scaleByWidth = (500 - 40) / 836; // 0.55
|
||||
const scaleByHeight = (400 - 80) / 524; // 0.61
|
||||
const expectedScale = Math.min(scaleByWidth, scaleByHeight);
|
||||
expect(mockCanvas.style.transform).toBe(`scale(${expectedScale})`);
|
||||
});
|
||||
|
||||
it('should never scale up beyond native size', () => {
|
||||
// Very large viewport - should not scale up
|
||||
vi.stubGlobal('window', {
|
||||
devicePixelRatio: 1,
|
||||
innerWidth: 2000,
|
||||
innerHeight: 1500,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
game = new Game();
|
||||
|
||||
// Scale should be capped at 1
|
||||
expect(mockCanvas.style.transform).toBe('scale(1)');
|
||||
expect(mockCanvas.style.width).toBe('836px');
|
||||
expect(mockCanvas.style.height).toBe('524px');
|
||||
});
|
||||
|
||||
it('should preserve aspect ratio during scaling', () => {
|
||||
// Narrow viewport
|
||||
vi.stubGlobal('window', {
|
||||
devicePixelRatio: 1,
|
||||
innerWidth: 600,
|
||||
innerHeight: 800,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
game = new Game();
|
||||
|
||||
// Canvas CSS dimensions should maintain aspect ratio
|
||||
// Native aspect ratio: 836 / 524 = 1.595
|
||||
const nativeAspectRatio = 836 / 524;
|
||||
const cssWidth = parseFloat(mockCanvas.style.width);
|
||||
const cssHeight = parseFloat(mockCanvas.style.height);
|
||||
const cssAspectRatio = cssWidth / cssHeight;
|
||||
|
||||
expect(cssAspectRatio).toBeCloseTo(nativeAspectRatio, 2);
|
||||
});
|
||||
|
||||
it('should use CSS transform for scaling (not canvas dimensions)', () => {
|
||||
// Narrow viewport
|
||||
vi.stubGlobal('window', {
|
||||
devicePixelRatio: 1,
|
||||
innerWidth: 600,
|
||||
innerHeight: 800,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
|
||||
game = new Game();
|
||||
|
||||
// Canvas internal dimensions should remain at native size
|
||||
expect(mockCanvas.width).toBe(836); // Native width
|
||||
expect(mockCanvas.height).toBe(524); // Native height
|
||||
|
||||
// CSS dimensions should be native size (scaled via transform)
|
||||
expect(mockCanvas.style.width).toBe('836px');
|
||||
expect(mockCanvas.style.height).toBe('524px');
|
||||
|
||||
// Transform should handle the scaling
|
||||
expect(mockCanvas.style.transform).toMatch(/^scale\(0\.\d+\)$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('win condition detection', () => {
|
||||
it('should detect win condition when all tiles are cleared', () => {
|
||||
game = new Game();
|
||||
|
||||
@@ -1,10 +1,136 @@
|
||||
// src/__tests__/Renderer.test.ts - Tests for Renderer class
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { Renderer } from '../rendering/Renderer';
|
||||
import { Renderer, MatchAnimation } from '../rendering/Renderer';
|
||||
import { GridManager } from '../managers/GridManager';
|
||||
import { Tile } from '../models/Tile';
|
||||
import { CONFIG } from '../config';
|
||||
|
||||
describe('MatchAnimation', () => {
|
||||
describe('constructor and start', () => {
|
||||
it('should create animation with default 250ms duration', () => {
|
||||
const animation = new MatchAnimation();
|
||||
expect(animation['duration']).toBe(250);
|
||||
});
|
||||
|
||||
it('should allow custom duration', () => {
|
||||
const animation = new MatchAnimation(300);
|
||||
expect(animation['duration']).toBe(300);
|
||||
});
|
||||
|
||||
it('should start with startTime = 0 before start() is called', () => {
|
||||
const animation = new MatchAnimation();
|
||||
expect(animation['startTime']).toBe(0);
|
||||
});
|
||||
|
||||
it('should set startTime when start() is called', () => {
|
||||
const animation = new MatchAnimation();
|
||||
animation.start();
|
||||
expect(animation['startTime']).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getScaleAndAlpha', () => {
|
||||
it('should return scale 0 and alpha 0 when animation is complete', () => {
|
||||
const animation = new MatchAnimation(250);
|
||||
animation.start();
|
||||
|
||||
// Mock elapsed time past duration
|
||||
const originalNow = performance.now;
|
||||
performance.now = () => animation['startTime'] + 300;
|
||||
|
||||
const result = animation.getScaleAndAlpha();
|
||||
expect(result.scale).toBe(0);
|
||||
expect(result.alpha).toBe(0);
|
||||
|
||||
performance.now = originalNow;
|
||||
});
|
||||
|
||||
it('should grow scale in first half of animation (easeOutBack)', () => {
|
||||
const animation = new MatchAnimation(250);
|
||||
animation.start();
|
||||
|
||||
// At 25% progress (50ms of 250ms), in grow phase
|
||||
const originalNow = performance.now;
|
||||
performance.now = () => animation['startTime'] + 62.5; // 25% of 250ms
|
||||
|
||||
const result = animation.getScaleAndAlpha();
|
||||
// Scale should be > 1 (growing with easeOutBack)
|
||||
// easeOutBack overshoots slightly, so we allow up to 1.25
|
||||
expect(result.scale).toBeGreaterThan(1);
|
||||
expect(result.scale).toBeLessThanOrEqual(1.25);
|
||||
|
||||
performance.now = originalNow;
|
||||
});
|
||||
|
||||
it('should shrink scale in second half of animation', () => {
|
||||
const animation = new MatchAnimation(250);
|
||||
animation.start();
|
||||
|
||||
// At 75% progress (187.5ms of 250ms), in shrink phase
|
||||
const originalNow = performance.now;
|
||||
performance.now = () => animation['startTime'] + 187.5;
|
||||
|
||||
const result = animation.getScaleAndAlpha();
|
||||
// Scale should be shrinking from 1.2 toward 0
|
||||
expect(result.scale).toBeGreaterThan(0);
|
||||
expect(result.scale).toBeLessThan(1.2);
|
||||
|
||||
performance.now = originalNow;
|
||||
});
|
||||
|
||||
it('should fade alpha linearly from 1 to 0', () => {
|
||||
const animation = new MatchAnimation(250);
|
||||
animation.start();
|
||||
|
||||
const originalNow = performance.now;
|
||||
|
||||
// At 0% progress
|
||||
performance.now = () => animation['startTime'] + 0;
|
||||
let result = animation.getScaleAndAlpha();
|
||||
expect(result.alpha).toBeCloseTo(1, 1);
|
||||
|
||||
// At 50% progress
|
||||
performance.now = () => animation['startTime'] + 125;
|
||||
result = animation.getScaleAndAlpha();
|
||||
expect(result.alpha).toBeLessThan(1);
|
||||
expect(result.alpha).toBeGreaterThan(0);
|
||||
|
||||
// At 100% progress (just before complete)
|
||||
performance.now = () => animation['startTime'] + 249;
|
||||
result = animation.getScaleAndAlpha();
|
||||
expect(result.alpha).toBeCloseTo(0, 1);
|
||||
|
||||
performance.now = originalNow;
|
||||
});
|
||||
});
|
||||
|
||||
describe('isComplete', () => {
|
||||
it('should return false before duration has elapsed', () => {
|
||||
const animation = new MatchAnimation(250);
|
||||
animation.start();
|
||||
|
||||
const originalNow = performance.now;
|
||||
performance.now = () => animation['startTime'] + 100;
|
||||
|
||||
expect(animation.isComplete()).toBe(false);
|
||||
|
||||
performance.now = originalNow;
|
||||
});
|
||||
|
||||
it('should return true after duration has elapsed', () => {
|
||||
const animation = new MatchAnimation(250);
|
||||
animation.start();
|
||||
|
||||
const originalNow = performance.now;
|
||||
performance.now = () => animation['startTime'] + 300;
|
||||
|
||||
expect(animation.isComplete()).toBe(true);
|
||||
|
||||
performance.now = originalNow;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Renderer', () => {
|
||||
let renderer: Renderer;
|
||||
let mockCtx: any;
|
||||
@@ -32,6 +158,10 @@ describe('Renderer', () => {
|
||||
restore: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
quadraticCurveTo: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 0,
|
||||
@@ -39,6 +169,10 @@ describe('Renderer', () => {
|
||||
font: '',
|
||||
textAlign: '',
|
||||
textBaseline: '',
|
||||
shadowBlur: 0,
|
||||
shadowColor: '',
|
||||
lineCap: '',
|
||||
lineJoin: '',
|
||||
};
|
||||
|
||||
// Create GridManager instance
|
||||
@@ -204,4 +338,47 @@ describe('Renderer', () => {
|
||||
// This is implicitly tested by the absence of selection color
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawPathLine glow effect', () => {
|
||||
it('should set shadowBlur to 15 for glow effect', () => {
|
||||
const path = [
|
||||
{ row: 0, col: 0 },
|
||||
{ row: 0, col: 1 },
|
||||
];
|
||||
renderer['drawPathLine'](path);
|
||||
|
||||
expect(mockCtx.shadowBlur).toBe(15);
|
||||
});
|
||||
|
||||
it('should set shadowColor to match stroke color (#00ff00)', () => {
|
||||
const path = [
|
||||
{ row: 0, col: 0 },
|
||||
{ row: 0, col: 1 },
|
||||
];
|
||||
renderer['drawPathLine'](path);
|
||||
|
||||
expect(mockCtx.shadowColor).toBe('#00ff00');
|
||||
});
|
||||
|
||||
it('should preserve context state with save/restore', () => {
|
||||
const path = [
|
||||
{ row: 0, col: 0 },
|
||||
{ row: 0, col: 1 },
|
||||
];
|
||||
renderer['drawPathLine'](path);
|
||||
|
||||
expect(mockCtx.save).toHaveBeenCalled();
|
||||
expect(mockCtx.restore).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use green stroke color (#00ff00)', () => {
|
||||
const path = [
|
||||
{ row: 0, col: 0 },
|
||||
{ row: 0, col: 1 },
|
||||
];
|
||||
renderer['drawPathLine'](path);
|
||||
|
||||
expect(mockCtx.strokeStyle).toBe('#00ff00');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+27
-11
@@ -148,26 +148,42 @@ export class Game {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up canvas dimensions and handles device pixel ratio for sharp rendering
|
||||
* 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 logical canvas size
|
||||
const width = cols * (size + gap) + gap;
|
||||
const height = rows * (size + gap) + gap;
|
||||
// Calculate native canvas size
|
||||
const nativeWidth = cols * (size + gap) + gap; // 832px
|
||||
const nativeHeight = rows * (size + gap) + gap; // 528px
|
||||
|
||||
// Set actual canvas size (accounting for device pixel ratio)
|
||||
this.canvas.width = width * dpr;
|
||||
this.canvas.height = height * dpr;
|
||||
// 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
|
||||
|
||||
// Set display size (CSS)
|
||||
this.canvas.style.width = `${width}px`;
|
||||
this.canvas.style.height = `${height}px`;
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Scale context to account for device pixel ratio
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,82 @@ class RippleAnimation {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MatchAnimation class for animating tile match effects
|
||||
* Creates a satisfying "pop" effect with scale+fade when tiles are matched
|
||||
*/
|
||||
export class MatchAnimation {
|
||||
private startTime: number;
|
||||
private readonly duration: number;
|
||||
|
||||
constructor(duration: number = 250) {
|
||||
this.startTime = 0;
|
||||
this.duration = duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the match animation
|
||||
*/
|
||||
start(): void {
|
||||
this.startTime = performance.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Easing function for "pop" feel - overshoots slightly then settles
|
||||
* @param t - Progress value (0-1)
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Easing function for smooth fade
|
||||
* @param t - Progress value (0-1)
|
||||
*/
|
||||
private easeInQuad(t: number): number {
|
||||
return t * t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current scale and alpha values based on elapsed time
|
||||
* @returns { scale, alpha } values for rendering
|
||||
*/
|
||||
getScaleAndAlpha(): { scale: number; alpha: number } {
|
||||
const elapsed = performance.now() - this.startTime;
|
||||
|
||||
// Animation complete - return zero values
|
||||
if (elapsed > this.duration) {
|
||||
return { scale: 0, alpha: 0 };
|
||||
}
|
||||
|
||||
const progress = elapsed / this.duration;
|
||||
|
||||
// Scale phases: grow (0-50%) then shrink (50-100%)
|
||||
let scale: number;
|
||||
if (progress < 0.5) {
|
||||
// Grow phase: easeOutBack for "pop" feel
|
||||
scale = 1 + 0.2 * this.easeOutBack(progress * 2);
|
||||
} else {
|
||||
// Shrink phase: linear shrink from 1.2 to 0
|
||||
scale = 1.2 * (1 - (progress - 0.5) * 2);
|
||||
}
|
||||
|
||||
// Alpha: linear fade using easeInQuad
|
||||
const alpha = 1 - this.easeInQuad(progress);
|
||||
|
||||
return { scale, alpha };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if animation is complete
|
||||
*/
|
||||
isComplete(): boolean {
|
||||
return performance.now() - this.startTime > this.duration;
|
||||
}
|
||||
}
|
||||
|
||||
export class Renderer {
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private gridManager: GridManager;
|
||||
@@ -422,6 +498,13 @@ export class Renderer {
|
||||
const offsetX = (this.canvas.width - gridWidth) / 2;
|
||||
const offsetY = (this.canvas.height - gridHeight) / 2;
|
||||
|
||||
// Save context state before applying glow
|
||||
this.ctx.save();
|
||||
|
||||
// Set glow effect for path visibility
|
||||
this.ctx.shadowColor = '#00ff00'; // Green glow
|
||||
this.ctx.shadowBlur = 15; // Glow intensity per RESEARCH.md
|
||||
|
||||
// Set path style
|
||||
this.ctx.strokeStyle = '#00ff00'; // Green color per CONTEXT.md
|
||||
this.ctx.lineWidth = 3;
|
||||
@@ -447,5 +530,8 @@ export class Renderer {
|
||||
|
||||
// Stroke path
|
||||
this.ctx.stroke();
|
||||
|
||||
// Restore context state after drawing
|
||||
this.ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user