chore: reset

This commit is contained in:
2026-03-11 22:35:40 +07:00
parent 1fad9a89a9
commit 12e66bd2da
264 changed files with 0 additions and 62636 deletions
-176
View File
@@ -1,176 +0,0 @@
// src/__tests__/EventEmitter.test.ts - Unit tests for TypedEventEmitter class
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TypedEventEmitter } from '../game/EventEmitter';
// Define a test event map
interface TestEvents {
'test:event': { value: number };
'test:message': string;
'test:empty': void;
'error': Error;
}
describe('TypedEventEmitter', () => {
let emitter: TypedEventEmitter<TestEvents>;
beforeEach(() => {
emitter = new TypedEventEmitter<TestEvents>();
});
describe('on()', () => {
it('should register a listener for an event', () => {
const listener = vi.fn();
emitter.on('test:event', listener);
emitter.emit('test:event', { value: 42 });
expect(listener).toHaveBeenCalledWith({ value: 42 });
});
it('should register multiple listeners for the same event', () => {
const listener1 = vi.fn();
const listener2 = vi.fn();
emitter.on('test:event', listener1);
emitter.on('test:event', listener2);
emitter.emit('test:event', { value: 42 });
expect(listener1).toHaveBeenCalledWith({ value: 42 });
expect(listener2).toHaveBeenCalledWith({ value: 42 });
});
it('should return this for chaining', () => {
const listener = vi.fn();
const result = emitter.on('test:event', listener);
expect(result).toBe(emitter);
});
});
describe('emit()', () => {
it('should call all registered listeners with payload', () => {
const listener = vi.fn();
emitter.on('test:message', listener);
emitter.emit('test:message', 'hello');
expect(listener).toHaveBeenCalledWith('hello');
});
it('should return true when listeners were called', () => {
emitter.on('test:event', vi.fn());
const result = emitter.emit('test:event', { value: 1 });
expect(result).toBe(true);
});
it('should return false when no listeners are registered', () => {
const result = emitter.emit('test:event', { value: 1 });
expect(result).toBe(false);
});
it('should emit void events', () => {
const listener = vi.fn();
emitter.on('test:empty', listener);
emitter.emit('test:empty', undefined);
expect(listener).toHaveBeenCalledWith(undefined);
});
});
describe('off()', () => {
it('should remove a specific listener', () => {
const listener = vi.fn();
emitter.on('test:event', listener);
emitter.off('test:event', listener);
emitter.emit('test:event', { value: 42 });
expect(listener).not.toHaveBeenCalled();
});
it('should not affect other listeners', () => {
const listener1 = vi.fn();
const listener2 = vi.fn();
emitter.on('test:event', listener1);
emitter.on('test:event', listener2);
emitter.off('test:event', listener1);
emitter.emit('test:event', { value: 42 });
expect(listener1).not.toHaveBeenCalled();
expect(listener2).toHaveBeenCalledWith({ value: 42 });
});
it('should return this for chaining', () => {
const listener = vi.fn();
emitter.on('test:event', listener);
const result = emitter.off('test:event', listener);
expect(result).toBe(emitter);
});
});
describe('type safety', () => {
it('should accept correctly typed payloads for events', () => {
const listener = vi.fn<(data: { value: number }) => void>();
emitter.on('test:event', listener);
emitter.emit('test:event', { value: 123 });
expect(listener).toHaveBeenCalledWith({ value: 123 });
});
it('should accept string payloads for string events', () => {
const listener = vi.fn<(data: string) => void>();
emitter.on('test:message', listener);
emitter.emit('test:message', 'test message');
expect(listener).toHaveBeenCalledWith('test message');
});
});
describe('error handling', () => {
it('should handle error event without throwing', () => {
const errorHandler = vi.fn();
emitter.on('error', errorHandler);
const error = new Error('test error');
emitter.emit('error', error);
expect(errorHandler).toHaveBeenCalledWith(error);
});
it('should allow multiple error handlers', () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
emitter.on('error', handler1);
emitter.on('error', handler2);
const error = new Error('test error');
emitter.emit('error', error);
expect(handler1).toHaveBeenCalledWith(error);
expect(handler2).toHaveBeenCalledWith(error);
});
});
describe('once()', () => {
it('should register a one-time listener', () => {
const listener = vi.fn();
emitter.once('test:event', listener);
emitter.emit('test:event', { value: 1 });
emitter.emit('test:event', { value: 2 });
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({ value: 1 });
});
it('should return this for chaining', () => {
const listener = vi.fn();
const result = emitter.once('test:event', listener);
expect(result).toBe(emitter);
});
});
describe('removeAllListeners()', () => {
it('should remove all listeners for a specific event', () => {
const listener1 = vi.fn();
const listener2 = vi.fn();
emitter.on('test:event', listener1);
emitter.on('test:event', listener2);
emitter.removeAllListeners('test:event');
emitter.emit('test:event', { value: 42 });
expect(listener1).not.toHaveBeenCalled();
expect(listener2).not.toHaveBeenCalled();
});
it('should not affect listeners for other events', () => {
const eventListener = vi.fn();
const messageListener = vi.fn();
emitter.on('test:event', eventListener);
emitter.on('test:message', messageListener);
emitter.removeAllListeners('test:event');
emitter.emit('test:message', 'hello');
expect(messageListener).toHaveBeenCalledWith('hello');
});
});
});
-120
View File
@@ -1,120 +0,0 @@
// src/__tests__/Game.integration.test.ts - Integration tests for Game state management
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
import { Game } from '../game/Game';
import { GameState } from '../state/GameStateManager';
describe('Game Integration - State Management', () => {
let game: Game | null = null;
beforeEach(() => {
// Set up DOM environment
document.body.innerHTML = `
<canvas id="game"></canvas>
<div id="score-display">Score: 0</div>
<div id="game-over-overlay" class="hidden"></div>
`;
// Mock canvas context
const canvas = document.getElementById('game') as HTMLCanvasElement;
const mockContext = {
scale: vi.fn(),
fillStyle: '',
fillRect: vi.fn(),
clearRect: vi.fn(),
};
vi.spyOn(canvas, 'getContext').mockReturnValue(mockContext as any);
// Create game instance
game = new Game();
});
afterEach(() => {
// Clean up DOM after each test
if (game) {
game.stop();
game = null;
}
document.body.innerHTML = '';
});
describe('Plan 04-01: State Machine', () => {
test('should initialize in IDLE state', () => {
// TODO: Implement test
expect(true).toBe(true);
});
test('should transition to SELECTING when first tile selected', () => {
// TODO: Implement test
expect(true).toBe(true);
});
test('should transition to MATCHING when match processing', () => {
// TODO: Implement test
expect(true).toBe(true);
});
test('should block input during MATCHING state', () => {
// TODO: Implement test
expect(true).toBe(true);
});
});
describe('Plan 04-02: Win/Lose Detection', () => {
test('should detect win condition when all tiles cleared', () => {
// TODO: Implement test
expect(true).toBe(true);
});
test('should detect no-moves condition when no valid pairs', () => {
// TODO: Implement test
expect(true).toBe(true);
});
test('should show game over overlay on win', () => {
// TODO: Implement test
expect(true).toBe(true);
});
test('should show game over overlay on no-moves', () => {
// TODO: Implement test
expect(true).toBe(true);
});
test('should transition to GAME_OVER state on game end', () => {
// TODO: Implement test
expect(true).toBe(true);
});
});
describe('Plan 04-03: Restart Functionality', () => {
test('should reset grid when restart called', () => {
// TODO: Implement test
expect(true).toBe(true);
});
test('should reset score to 0 when restart called', () => {
// TODO: Implement test
expect(true).toBe(true);
});
test('should reset state to IDLE when restart called', () => {
// TODO: Implement test
expect(true).toBe(true);
});
test('should hide game over overlay when restart called', () => {
// TODO: Implement test
expect(true).toBe(true);
});
test('should preserve previous score when restart called', () => {
// TODO: Implement test
expect(true).toBe(true);
});
test('should show previous score display after restart', () => {
// TODO: Implement test
expect(true).toBe(true);
});
});
});
File diff suppressed because it is too large Load Diff
-191
View File
@@ -1,191 +0,0 @@
// src/__tests__/GameLoop.test.ts - Unit tests for GameLoop class
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import { GameLoop } from '../game/GameLoop';
describe('GameLoop', () => {
let gameLoop: GameLoop;
let updateCallback: Mock<(deltaTime: number) => void>;
let originalRaf: typeof globalThis.requestAnimationFrame;
let originalCancelRaf: typeof globalThis.cancelAnimationFrame;
beforeEach(() => {
updateCallback = vi.fn<(deltaTime: number) => void>();
originalRaf = globalThis.requestAnimationFrame;
originalCancelRaf = globalThis.cancelAnimationFrame;
// Mock requestAnimationFrame and cancelAnimationFrame on global
let rafId = 0;
const rafCallbacks: Map<number, FrameRequestCallback> = new Map();
globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback): number => {
rafId++;
rafCallbacks.set(rafId, cb);
return rafId;
});
globalThis.cancelAnimationFrame = vi.fn((id: number) => {
rafCallbacks.delete(id);
});
});
afterEach(() => {
gameLoop.stop();
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCancelRaf;
vi.restoreAllMocks();
});
describe('start()', () => {
it('should set running to true', () => {
gameLoop = new GameLoop(updateCallback);
gameLoop.start();
expect(gameLoop.isRunning()).toBe(true);
});
it('should call requestAnimationFrame', () => {
gameLoop = new GameLoop(updateCallback);
gameLoop.start();
expect(globalThis.requestAnimationFrame).toHaveBeenCalled();
});
it('should not start again if already running', () => {
gameLoop = new GameLoop(updateCallback);
gameLoop.start();
const callCount = (globalThis.requestAnimationFrame as Mock).mock.calls.length;
gameLoop.start(); // Should be ignored
expect((globalThis.requestAnimationFrame as Mock).mock.calls.length).toBe(callCount);
});
});
describe('stop()', () => {
it('should set running to false', () => {
gameLoop = new GameLoop(updateCallback);
gameLoop.start();
gameLoop.stop();
expect(gameLoop.isRunning()).toBe(false);
});
it('should cancel the animation frame', () => {
gameLoop = new GameLoop(updateCallback);
gameLoop.start();
const rafId = gameLoop.getRafId();
gameLoop.stop();
expect(globalThis.cancelAnimationFrame).toHaveBeenCalledWith(rafId);
});
it('should be safe to call stop without start', () => {
gameLoop = new GameLoop(updateCallback);
expect(() => gameLoop.stop()).not.toThrow();
expect(gameLoop.isRunning()).toBe(false);
});
it('should be safe to call stop multiple times', () => {
gameLoop = new GameLoop(updateCallback);
gameLoop.start();
gameLoop.stop();
gameLoop.stop();
expect(gameLoop.isRunning()).toBe(false);
});
});
describe('update callback', () => {
it('should call update with deltaTime when time has passed', () => {
vi.useFakeTimers();
const capturedCallbacks: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback): number => {
capturedCallbacks.push(cb);
return 1;
});
const callback = vi.fn<(deltaTime: number) => void>();
gameLoop = new GameLoop(callback);
gameLoop.start();
// Simulate a frame with enough time for one tick (~16.67ms)
if (capturedCallbacks.length > 0) {
capturedCallbacks[0](20); // 20ms passed
}
// One tick should have been called
expect(callback).toHaveBeenCalledWith(1000 / 60);
vi.useRealTimers();
});
it('should not call update when no time has passed', () => {
vi.useFakeTimers();
const capturedCallbacks: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback): number => {
capturedCallbacks.push(cb);
return 1;
});
const callback = vi.fn<(deltaTime: number) => void>();
gameLoop = new GameLoop(callback);
gameLoop.start();
// Simulate a frame with no time passed
if (capturedCallbacks.length > 0) {
capturedCallbacks[0](0); // No time passed
}
expect(callback).not.toHaveBeenCalled();
vi.useRealTimers();
});
});
describe('loop lifecycle', () => {
it('should be able to start and stop multiple times', () => {
gameLoop = new GameLoop(updateCallback);
gameLoop.start();
expect(gameLoop.isRunning()).toBe(true);
gameLoop.stop();
expect(gameLoop.isRunning()).toBe(false);
gameLoop.start();
expect(gameLoop.isRunning()).toBe(true);
gameLoop.stop();
expect(gameLoop.isRunning()).toBe(false);
});
});
describe('delta time accumulation', () => {
it('should accumulate multiple ticks correctly', () => {
vi.useFakeTimers();
const capturedCallbacks: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback): number => {
capturedCallbacks.push(cb);
return 1;
});
const callback = vi.fn<(deltaTime: number) => void>();
gameLoop = new GameLoop(callback);
gameLoop.start();
// Simulate a frame with enough time for 3 ticks (~50ms)
if (capturedCallbacks.length > 0) {
capturedCallbacks[0](50);
}
// Three ticks should have been called
expect(callback).toHaveBeenCalledTimes(3);
expect(callback).toHaveBeenCalledWith(1000 / 60);
vi.useRealTimers();
});
});
describe('tickLength', () => {
it('should target 60fps (16.67ms per tick)', () => {
gameLoop = new GameLoop(updateCallback);
expect(gameLoop.getTickLength()).toBeCloseTo(1000 / 60, 2);
});
});
});
-104
View File
@@ -1,104 +0,0 @@
// src/__tests__/GameStateManager.test.ts - Unit tests for GameStateManager class
import { describe, test, expect, beforeEach, vi } from 'vitest';
import { GameStateManager, GameState } from '../state/GameStateManager';
import { TypedEventEmitter } from '../game/EventEmitter';
import type { GameEvents } from '../types';
// Mock TypedEventEmitter for testing
vi.mock('../game/EventEmitter', () => ({
TypedEventEmitter: class MockTypedEventEmitter {
on = vi.fn();
emit = vi.fn();
off = vi.fn();
removeAllListeners = vi.fn();
},
}));
describe('GameStateManager', () => {
let mockEventEmitter: TypedEventEmitter<GameEvents>;
let stateManager: GameStateManager;
beforeEach(() => {
// Create fresh mock event emitter for each test
mockEventEmitter = new TypedEventEmitter() as any;
stateManager = new GameStateManager(mockEventEmitter);
vi.clearAllMocks();
});
describe('initialization', () => {
test('should initialize in IDLE state', () => {
expect(stateManager.getState()).toBe(GameState.IDLE);
});
});
describe('state transitions', () => {
test('should validate state transitions correctly', () => {
// Valid transition: IDLE → SELECTING
expect(stateManager.transitionTo(GameState.SELECTING)).toBe(true);
expect(stateManager.getState()).toBe(GameState.SELECTING);
// Valid transition: SELECTING → MATCHING
expect(stateManager.transitionTo(GameState.MATCHING)).toBe(true);
expect(stateManager.getState()).toBe(GameState.MATCHING);
});
test('should emit state change events on valid transition', () => {
stateManager.transitionTo(GameState.SELECTING);
expect(mockEventEmitter.emit).toHaveBeenCalledWith('game:stateChange', {
from: GameState.IDLE,
to: GameState.SELECTING
});
});
test('should return false for invalid transitions', () => {
// Invalid: IDLE → GAME_OVER (must go through MATCHING first)
expect(stateManager.transitionTo(GameState.GAME_OVER)).toBe(false);
expect(stateManager.getState()).toBe(GameState.IDLE);
// Invalid: SELECTING → IDLE is valid
expect(stateManager.transitionTo(GameState.SELECTING)).toBe(true);
expect(stateManager.transitionTo(GameState.IDLE)).toBe(true);
});
});
describe('tile selection by state', () => {
test('should allow tile selection in IDLE state', () => {
expect(stateManager.canSelectTile()).toBe(true);
});
test('should allow tile selection in SELECTING state', () => {
stateManager.transitionTo(GameState.SELECTING);
expect(stateManager.canSelectTile()).toBe(true);
});
test('should block tile selection in MATCHING state', () => {
stateManager.transitionTo(GameState.SELECTING);
stateManager.transitionTo(GameState.MATCHING);
expect(stateManager.canSelectTile()).toBe(false);
});
test('should block tile selection in GAME_OVER state', () => {
stateManager.transitionTo(GameState.SELECTING);
stateManager.transitionTo(GameState.MATCHING);
stateManager.transitionTo(GameState.GAME_OVER);
expect(stateManager.canSelectTile()).toBe(false);
});
});
describe('game reset', () => {
test('should reset from GAME_OVER to IDLE', () => {
stateManager.transitionTo(GameState.SELECTING);
stateManager.transitionTo(GameState.MATCHING);
stateManager.transitionTo(GameState.GAME_OVER);
stateManager.reset();
expect(stateManager.getState()).toBe(GameState.IDLE);
expect(mockEventEmitter.emit).toHaveBeenCalledWith('game:stateChange', {
from: GameState.GAME_OVER,
to: GameState.IDLE
});
});
});
});
-492
View File
@@ -1,492 +0,0 @@
// src/__tests__/GridManager.test.ts - GridManager unit tests
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { GridManager } from '../managers/GridManager';
import { Tile } from '../models/Tile';
import { TypedEventEmitter } from '../game/EventEmitter';
import { GameEvents } from '../types';
import { NoMovesDetector } from '../detection/NoMovesDetector';
describe('GridManager', () => {
let gridManager: GridManager;
let mockEmitter: TypedEventEmitter<GameEvents>;
beforeEach(() => {
mockEmitter = new TypedEventEmitter<GameEvents>();
gridManager = new GridManager(mockEmitter);
});
describe('initializeGrid', () => {
it('should create a 10x16 grid of Tile objects (160 total)', () => {
gridManager.initializeGrid();
let totalTiles = 0;
for (let row = 0; row < 10; row++) {
for (let col = 0; col < 16; col++) {
const tile = gridManager.getTileAt(row, col);
expect(tile).not.toBeNull();
if (tile) totalTiles++;
}
}
expect(totalTiles).toBe(160);
});
it('should assign unique IDs to all tiles', () => {
gridManager.initializeGrid();
const ids = new Set<string>();
for (let row = 0; row < 10; row++) {
for (let col = 0; col < 16; col++) {
const tile = gridManager.getTileAt(row, col);
expect(tile).not.toBeNull();
if (tile) {
expect(ids.has(tile.id)).toBe(false);
ids.add(tile.id);
}
}
}
expect(ids.size).toBe(160);
});
});
describe('getTileAt', () => {
beforeEach(() => {
gridManager.initializeGrid();
});
it('should return the correct tile at valid coordinates', () => {
const tile = gridManager.getTileAt(5, 8);
expect(tile).not.toBeNull();
expect(tile?.position.row).toBe(5);
expect(tile?.position.col).toBe(8);
});
it('should return null for out-of-bounds coordinates', () => {
expect(gridManager.getTileAt(-1, 0)).toBeNull();
expect(gridManager.getTileAt(0, -1)).toBeNull();
expect(gridManager.getTileAt(10, 0)).toBeNull();
expect(gridManager.getTileAt(0, 16)).toBeNull();
});
});
describe('selectTile', () => {
beforeEach(() => {
gridManager.initializeGrid();
});
it('should add first tile to selection', () => {
const tile = gridManager.getTileAt(0, 0);
expect(tile).not.toBeNull();
if (tile) {
gridManager.selectTile(tile);
expect(gridManager.selectedTilesList.length).toBe(1);
expect(gridManager.selectedTilesList[0]).toBe(tile);
}
});
it('should add second tile to selection', () => {
const tile1 = gridManager.getTileAt(0, 0);
const tile2 = gridManager.getTileAt(0, 1);
expect(tile1 && tile2).not.toBeNull();
if (tile1 && tile2) {
gridManager.selectTile(tile1);
gridManager.selectTile(tile2);
expect(gridManager.selectedTilesList.length).toBe(2);
expect(gridManager.selectedTilesList[0]).toBe(tile1);
expect(gridManager.selectedTilesList[1]).toBe(tile2);
}
});
it('should toggle deselect when same tile clicked', () => {
const tile = gridManager.getTileAt(0, 0);
expect(tile).not.toBeNull();
if (tile) {
gridManager.selectTile(tile);
expect(gridManager.selectedTilesList.length).toBe(1);
gridManager.selectTile(tile); // Click same tile again
expect(gridManager.selectedTilesList.length).toBe(0);
}
});
it('should ignore cleared tiles', () => {
const tile1 = gridManager.getTileAt(0, 0);
const tile2 = gridManager.getTileAt(0, 1);
expect(tile1 && tile2).not.toBeNull();
if (tile1 && tile2) {
gridManager.selectTile(tile1);
tile2.cleared = true;
gridManager.selectTile(tile2);
expect(gridManager.selectedTilesList.length).toBe(1);
expect(gridManager.selectedTilesList[0]).toBe(tile1);
}
});
it('should emit tilesSelected event when 2 tiles selected', () => {
const tile1 = gridManager.getTileAt(0, 0);
const tile2 = gridManager.getTileAt(0, 1);
expect(tile1 && tile2).not.toBeNull();
const emitSpy = vi.spyOn(mockEmitter, 'emit');
if (tile1 && tile2) {
gridManager.selectTile(tile1);
gridManager.selectTile(tile2);
expect(emitSpy).toHaveBeenCalledWith('tilesSelected', {
tile1: tile1,
tile2: tile2,
});
}
});
it('should block selection of 3rd tile when 2 already selected', () => {
const tile1 = gridManager.getTileAt(0, 0);
const tile2 = gridManager.getTileAt(0, 1);
const tile3 = gridManager.getTileAt(0, 2);
expect(tile1 && tile2 && tile3).not.toBeNull();
if (tile1 && tile2 && tile3) {
gridManager.selectTile(tile1);
gridManager.selectTile(tile2);
gridManager.selectTile(tile3); // Should be ignored
expect(gridManager.selectedTilesList.length).toBe(2);
expect(gridManager.selectedTilesList[0]).toBe(tile1);
expect(gridManager.selectedTilesList[1]).toBe(tile2);
}
});
});
describe('deselectAll', () => {
beforeEach(() => {
gridManager.initializeGrid();
});
it('should clear all selected tiles', () => {
const tile1 = gridManager.getTileAt(0, 0);
const tile2 = gridManager.getTileAt(0, 1);
expect(tile1 && tile2).not.toBeNull();
if (tile1 && tile2) {
gridManager.selectTile(tile1);
gridManager.selectTile(tile2);
expect(gridManager.selectedTilesList.length).toBe(2);
gridManager.deselectAll();
expect(gridManager.selectedTilesList.length).toBe(0);
}
});
});
describe('initial selection state', () => {
it('should have empty selection initially (0 tiles selected)', () => {
gridManager.initializeGrid();
expect(gridManager.selectedTilesList.length).toBe(0);
});
});
describe('random board generation', () => {
it('should create a grid with exactly 10 pairs of each of the 16 types', () => {
gridManager.initializeGrid();
const typeCounts = new Map<number, number>();
for (let row = 0; row < 10; row++) {
for (let col = 0; col < 16; col++) {
const tile = gridManager.getTileAt(row, col);
expect(tile).not.toBeNull();
if (tile) {
const count = typeCounts.get(tile.type) || 0;
typeCounts.set(tile.type, count + 1);
}
}
}
// Should have exactly 16 types
expect(typeCounts.size).toBe(16);
// Each type should have exactly 10 pairs (20 tiles each)
for (const [type, count] of typeCounts.entries()) {
expect(count).toBe(10);
}
});
it('should produce different tile arrangements on successive calls (statistical)', () => {
// Generate two boards and compare first row arrangements
gridManager.initializeGrid();
const firstBoard: number[] = [];
for (let col = 0; col < 16; col++) {
const tile = gridManager.getTileAt(0, col);
if (tile) firstBoard.push(tile.type);
}
gridManager.initializeGrid();
const secondBoard: number[] = [];
for (let col = 0; col < 16; col++) {
const tile = gridManager.getTileAt(0, col);
if (tile) secondBoard.push(tile.type);
}
// Arrays should be different (statistically very unlikely to be same with shuffle)
expect(firstBoard).not.toEqual(secondBoard);
});
it('should assign correct positions to all tiles', () => {
gridManager.initializeGrid();
for (let row = 0; row < 10; row++) {
for (let col = 0; col < 16; col++) {
const tile = gridManager.getTileAt(row, col);
expect(tile).not.toBeNull();
if (tile) {
expect(tile.position.row).toBe(row);
expect(tile.position.col).toBe(col);
}
}
}
});
it('should emit board:generated event with solvable=true when solvable board found', () => {
const emitSpy = vi.spyOn(mockEmitter, 'emit');
gridManager.initializeGrid();
// Should have emitted board:generated event
const calls = emitSpy.mock.calls.filter(call => call[0] === 'board:generated');
expect(calls.length).toBeGreaterThanOrEqual(1);
// Check event payload structure
const eventPayload = calls[0][1] as { solvable: boolean; attempts: number };
expect(eventPayload).toHaveProperty('solvable');
expect(eventPayload).toHaveProperty('attempts');
expect(typeof eventPayload.solvable).toBe('boolean');
expect(typeof eventPayload.attempts).toBe('number');
});
it('should emit board:generated event with attempts count', () => {
const emitSpy = vi.spyOn(mockEmitter, 'emit');
gridManager.initializeGrid();
const calls = emitSpy.mock.calls.filter(call => call[0] === 'board:generated');
expect(calls.length).toBeGreaterThanOrEqual(1);
const eventPayload = calls[0][1] as { solvable: boolean; attempts: number };
expect(eventPayload.attempts).toBeGreaterThanOrEqual(1);
expect(eventPayload.attempts).toBeLessThanOrEqual(100);
});
it('should generate solvable boards when possible, or fallback to last board', () => {
// This test verifies the board generation logic works correctly
// Either a solvable board is found OR the fallback mechanism is used
const emitSpy = vi.spyOn(mockEmitter, 'emit');
gridManager.initializeGrid();
const calls = emitSpy.mock.calls.filter(call => call[0] === 'board:generated');
expect(calls.length).toBeGreaterThanOrEqual(1);
const eventPayload = calls[0][1] as { solvable: boolean; attempts: number };
// Verify the event was emitted with valid data
expect(typeof eventPayload.solvable).toBe('boolean');
expect(eventPayload.attempts).toBeGreaterThanOrEqual(1);
expect(eventPayload.attempts).toBeLessThanOrEqual(100);
// If solvable, verify the board actually has valid moves
if (eventPayload.solvable) {
const tiles = gridManager.getAllTiles();
const hasValidMoves = NoMovesDetector.hasValidMoves(tiles);
expect(hasValidMoves).toBe(true);
}
// If not solvable (fallback), verify attempts = 100
if (!eventPayload.solvable) {
expect(eventPayload.attempts).toBe(100);
}
});
});
describe('shuffleTiles', () => {
beforeEach(() => {
gridManager.initializeGrid();
});
it('should collect all uncleared tiles and preserve tile count', () => {
const beforeCount = gridManager.getAllTiles().flat().filter(t => !t.cleared).length;
gridManager.shuffleTiles();
const afterCount = gridManager.getAllTiles().flat().filter(t => !t.cleared).length;
expect(afterCount).toBe(beforeCount);
});
it('should preserve type distribution (same count of each type)', () => {
// Get type distribution before shuffle
const beforeCounts = new Map<number, number>();
for (let row = 0; row < 10; row++) {
for (let col = 0; col < 16; col++) {
const tile = gridManager.getTileAt(row, col);
if (tile && !tile.cleared) {
const count = beforeCounts.get(tile.type) || 0;
beforeCounts.set(tile.type, count + 1);
}
}
}
gridManager.shuffleTiles();
// Get type distribution after shuffle
const afterCounts = new Map<number, number>();
for (let row = 0; row < 10; row++) {
for (let col = 0; col < 16; col++) {
const tile = gridManager.getTileAt(row, col);
if (tile && !tile.cleared) {
const count = afterCounts.get(tile.type) || 0;
afterCounts.set(tile.type, count + 1);
}
}
}
// Compare distributions
expect(afterCounts.size).toBe(beforeCounts.size);
for (const [type, count] of beforeCounts.entries()) {
expect(afterCounts.get(type)).toBe(count);
}
});
it('should produce different type arrangements on successive calls (statistical)', () => {
// Get types before shuffle
const beforeTypes: number[] = [];
for (let row = 0; row < 10; row++) {
for (let col = 0; col < 16; col++) {
const tile = gridManager.getTileAt(row, col);
if (tile) beforeTypes.push(tile.type);
}
}
gridManager.shuffleTiles();
// Get types after shuffle
const afterTypes: number[] = [];
for (let row = 0; row < 10; row++) {
for (let col = 0; col < 16; col++) {
const tile = gridManager.getTileAt(row, col);
if (tile) afterTypes.push(tile.type);
}
}
// Arrays should be different (statistically very unlikely to be same with Fisher-Yates)
expect(afterTypes).not.toEqual(beforeTypes);
});
it('should clear selection (selectedTilesList is empty after)', () => {
// Select some tiles first
const tile1 = gridManager.getTileAt(0, 0);
const tile2 = gridManager.getTileAt(0, 1);
if (tile1 && tile2) {
gridManager.selectTile(tile1);
gridManager.selectTile(tile2);
expect(gridManager.selectedTilesList.length).toBe(2);
}
gridManager.shuffleTiles();
expect(gridManager.selectedTilesList.length).toBe(0);
});
it('should emit board:shuffling event before shuffle', () => {
const emitSpy = vi.spyOn(mockEmitter, 'emit');
gridManager.shuffleTiles();
// Should have emitted board:shuffling event
const shufflingCalls = emitSpy.mock.calls.filter(call => call[0] === 'board:shuffling');
expect(shufflingCalls.length).toBe(1);
// Check event payload
const payload = shufflingCalls[0][1] as { tilesRemaining: number };
expect(payload).toHaveProperty('tilesRemaining');
expect(payload.tilesRemaining).toBe(160);
});
it('should emit board:shuffled event after shuffle', () => {
const emitSpy = vi.spyOn(mockEmitter, 'emit');
gridManager.shuffleTiles();
// Should have emitted board:shuffled event
const shuffledCalls = emitSpy.mock.calls.filter(call => call[0] === 'board:shuffled');
expect(shuffledCalls.length).toBe(1);
// Check event payload
const payload = shuffledCalls[0][1] as { tilesRemaining: number };
expect(payload).toHaveProperty('tilesRemaining');
expect(payload.tilesRemaining).toBe(160);
});
it('should emit events in correct order (shuffling before shuffled)', () => {
const emitSpy = vi.spyOn(mockEmitter, 'emit');
gridManager.shuffleTiles();
// Get all board:shuffl* events (note: 'board:shuffle' won't match 'board:shuffling')
const shuffleEvents = emitSpy.mock.calls
.filter(call => call[0] === 'board:shuffling' || call[0] === 'board:shuffled')
.map(call => call[0]);
expect(shuffleEvents[0]).toBe('board:shuffling');
expect(shuffleEvents[1]).toBe('board:shuffled');
});
it('should preserve tile positions (tiles stay in same grid locations)', () => {
// Store original positions
const originalPositions: Map<string, { row: number; col: number }> = new Map();
for (let row = 0; row < 10; row++) {
for (let col = 0; col < 16; col++) {
const tile = gridManager.getTileAt(row, col);
if (tile) {
originalPositions.set(tile.id, { ...tile.position });
}
}
}
gridManager.shuffleTiles();
// Verify positions are unchanged
for (let row = 0; row < 10; row++) {
for (let col = 0; col < 16; col++) {
const tile = gridManager.getTileAt(row, col);
if (tile) {
const original = originalPositions.get(tile.id);
expect(original).toBeDefined();
expect(tile.position.row).toBe(original!.row);
expect(tile.position.col).toBe(original!.col);
}
}
}
});
it('should handle partially cleared board (skip cleared tiles)', () => {
// Clear some tiles first
const tile1 = gridManager.getTileAt(0, 0);
const tile2 = gridManager.getTileAt(0, 1);
if (tile1 && tile2) {
tile1.cleared = true;
tile2.cleared = true;
}
const remainingBefore = gridManager.getAllTiles().flat().filter(t => !t.cleared).length;
expect(remainingBefore).toBe(158);
const emitSpy = vi.spyOn(mockEmitter, 'emit');
gridManager.shuffleTiles();
// Check tilesRemaining in events reflects cleared tiles
const shufflingCalls = emitSpy.mock.calls.filter(call => call[0] === 'board:shuffling');
const payload = shufflingCalls[0][1] as { tilesRemaining: number };
expect(payload.tilesRemaining).toBe(158);
// Verify cleared tiles are still cleared
expect(tile1?.cleared).toBe(true);
expect(tile2?.cleared).toBe(true);
// Verify remaining count unchanged
const remainingAfter = gridManager.getAllTiles().flat().filter(t => !t.cleared).length;
expect(remainingAfter).toBe(158);
});
});
});
-157
View File
@@ -1,157 +0,0 @@
// src/__tests__/MatchEngine.test.ts - Test suite for MatchEngine
import { describe, it, expect, beforeEach } from 'vitest';
import { MatchEngine } from '../matching/MatchEngine';
import { GridManager } from '../managers/GridManager';
import { TypedEventEmitter } from '../game/EventEmitter';
import { GameEvents, Tile, TilePosition } from '../types';
import { CONFIG } from '../config';
describe('MatchEngine', () => {
let gridManager: GridManager;
let events: TypedEventEmitter<GameEvents>;
let matchEngine: MatchEngine;
let grid: Tile[][];
beforeEach(() => {
events = new TypedEventEmitter<GameEvents>();
gridManager = new GridManager(events);
gridManager.initializeGrid();
matchEngine = new MatchEngine(gridManager, events);
grid = gridManager.getAllTiles();
});
describe('validateMatch', () => {
it('should return valid=false with reason different-type for different tile types', () => {
const tile1 = grid[0][0]; // type 0
const tile2 = grid[0][1]; // type 1
const result = matchEngine.validateMatch(tile1, tile2);
expect(result.valid).toBe(false);
expect(result.reason).toBe('different-type');
});
it('should return valid=false with reason same-tile for same tile ID', () => {
const tile1 = grid[0][0];
const tile2 = grid[0][0]; // Same tile
const result = matchEngine.validateMatch(tile1, tile2);
expect(result.valid).toBe(false);
expect(result.reason).toBe('same-tile');
});
it('should return valid=true with path and turns=0 for matching tiles with valid 0-turn path', () => {
// Create two tiles of same type in same row with cleared tiles between
const tile1 = grid[0][0];
const tile2 = grid[0][1];
// Make them same type
(tile2 as any).type = tile1.type;
// Clear tiles between them (they're adjacent, so no tiles between)
// Mark destination as cleared so it's passable
(tile2 as any).cleared = true;
const result = matchEngine.validateMatch(tile1, tile2);
expect(result.valid).toBe(true);
expect(result.path).toBeDefined();
expect(result.turns).toBe(0);
expect(result.score).toBeDefined();
});
it('should return valid=true with path and turns=1 for matching tiles with valid 1-turn path', () => {
// Create L-shaped path scenario
const tile1 = grid[0][0];
const tile2 = grid[1][1];
// Make them same type
(tile2 as any).type = tile1.type;
// Clear the path: (0,0) -> (0,1) -> (1,1)
grid[0][1].cleared = true;
tile2.cleared = true;
const result = matchEngine.validateMatch(tile1, tile2);
expect(result.valid).toBe(true);
expect(result.path).toBeDefined();
expect(result.turns).toBe(1);
expect(result.score).toBeDefined();
});
it('should return valid=true with path and turns=2 for matching tiles with valid 2-turn path', () => {
// Create Z-shaped path scenario
const tile1 = grid[0][0];
const tile2 = grid[2][1];
// Make them same type
(tile2 as any).type = tile1.type;
// Clear the path: (0,0) -> (0,1) -> (1,1) -> (2,1)
grid[0][1].cleared = true;
grid[1][1].cleared = true;
tile2.cleared = true;
const result = matchEngine.validateMatch(tile1, tile2);
expect(result.valid).toBe(true);
expect(result.path).toBeDefined();
expect(result.turns).toBe(2);
expect(result.score).toBeDefined();
});
it('should return valid=false with reason too-many-turns for matching tiles with 3+ turn path', () => {
// Create a path that requires 3 turns
const tile1 = grid[0][0];
const tile2 = grid[2][2];
// Make them same type
(tile2 as any).type = tile1.type;
// Clear a winding path that requires 3 turns
grid[0][1].cleared = true;
grid[0][2].cleared = true;
grid[1][2].cleared = true;
tile2.cleared = true;
const result = matchEngine.validateMatch(tile1, tile2);
expect(result.valid).toBe(false);
expect(result.reason).toBe('too-many-turns');
expect(result.turns).toBeGreaterThan(2);
});
it('should return valid=false with reason no-path for matching tiles with no valid path (blocked)', () => {
const tile1 = grid[0][0];
const tile2 = grid[0][2];
// Make them same type
(tile2 as any).type = tile1.type;
// Don't clear any tiles - path is blocked
// Only destination is cleared
tile2.cleared = true;
const result = matchEngine.validateMatch(tile1, tile2);
expect(result.valid).toBe(false);
expect(result.reason).toBe('no-path');
});
it('should calculate correct score for valid matches', () => {
const tile1 = grid[0][0];
const tile2 = grid[0][1];
// Make them same type
(tile2 as any).type = tile1.type;
tile2.cleared = true;
const result = matchEngine.validateMatch(tile1, tile2);
expect(result.valid).toBe(true);
expect(result.score).toBe(150); // 0-turn = 150 points
});
});
});
-238
View File
@@ -1,238 +0,0 @@
// src/__tests__/NoMovesDetector.test.ts - Tests for NoMovesDetector
import { describe, it, expect } from 'vitest';
import { NoMovesDetector } from '../detection/NoMovesDetector';
import { Tile, TilePosition } from '../types';
import { CONFIG } from '../config';
describe('NoMovesDetector', () => {
/**
* Helper function to create a test tile
*/
function createTile(row: number, col: number, type: number, cleared: boolean = false): Tile {
return {
id: `tile-${row}-${col}`,
type,
position: { row, col },
cleared
};
}
/**
* Helper function to create a test grid with all tiles uncleared
*/
function createGrid(types: number[][]): Tile[][] {
const grid: Tile[][] = [];
for (let row = 0; row < types.length; row++) {
const rowTiles: Tile[] = [];
for (let col = 0; col < types[row].length; col++) {
rowTiles.push(createTile(row, col, types[row][col]));
}
grid.push(rowTiles);
}
return grid;
}
describe('hasValidMoves', () => {
it('should return true if at least one valid pair exists (same type, path with ≤2 turns)', () => {
// Create a simple 3x3 grid with two matching tiles
// Tiles at (0,0) and (0,2) are both type 1 and can connect with a straight line
const grid: Tile[][] = [];
for (let row = 0; row < 3; row++) {
const rowTiles: Tile[] = [];
for (let col = 0; col < 3; col++) {
let type = 0;
if (row === 0 && col === 0) type = 1;
else if (row === 0 && col === 2) type = 1;
else type = (row * 3 + col) % 15 + 2; // Different types
rowTiles.push(createTile(row, col, type));
}
grid.push(rowTiles);
}
// Clear the middle tile to create a path
grid[0][1].cleared = true;
const result = NoMovesDetector.hasValidMoves(grid);
expect(result).toBe(true);
});
it('should return false if no valid pairs exist', () => {
// Create a 2x2 grid where no two tiles of the same type can connect
const grid: Tile[][] = [
[
createTile(0, 0, 1), // type 1
createTile(0, 1, 2), // type 2
],
[
createTile(1, 0, 1), // type 1 - blocked by type 2 tiles
createTile(1, 1, 2), // type 2 - blocked by type 1 tiles
]
];
const result = NoMovesDetector.hasValidMoves(grid);
expect(result).toBe(false);
});
it('should use type-optimized algorithm (groups tiles by type first)', () => {
// Create a grid where only one type has multiple tiles
// This tests that the algorithm groups by type before checking pairs
const grid: Tile[][] = [];
for (let row = 0; row < 4; row++) {
const rowTiles: Tile[] = [];
for (let col = 0; col < 4; col++) {
// Only create type 1 pairs at (0,0) and (3,3)
// All other tiles are unique types
let type: number;
if (row === 0 && col === 0) type = 1;
else if (row === 3 && col === 3) type = 1;
else type = 10 + row * 4 + col; // Unique types
rowTiles.push(createTile(row, col, type));
}
grid.push(rowTiles);
}
// Clear a diagonal path to enable the connection
for (let i = 1; i < 3; i++) {
grid[i][i].cleared = true;
}
const result = NoMovesDetector.hasValidMoves(grid);
expect(result).toBe(true);
});
it('should skip cleared tiles when checking for valid moves', () => {
// Create a grid where matching tiles are cleared
const grid: Tile[][] = [];
for (let row = 0; row < 3; row++) {
const rowTiles: Tile[] = [];
for (let col = 0; col < 3; col++) {
const tile = createTile(row, col, 1);
// Clear all tiles
tile.cleared = true;
rowTiles.push(tile);
}
grid.push(rowTiles);
}
const result = NoMovesDetector.hasValidMoves(grid);
expect(result).toBe(false);
});
it('should handle empty board (returns false)', () => {
// Create an empty grid
const grid: Tile[][] = [];
const result = NoMovesDetector.hasValidMoves(grid);
expect(result).toBe(false);
});
it('should detect valid moves with 1 turn', () => {
// Create a grid where tiles connect with an L-shaped path
const grid: Tile[][] = [];
for (let row = 0; row < 3; row++) {
const rowTiles: Tile[] = [];
for (let col = 0; col < 3; col++) {
let type = 0;
if (row === 0 && col === 0) type = 1;
else if (row === 2 && col === 2) type = 1;
else type = (row * 3 + col) % 15 + 2;
rowTiles.push(createTile(row, col, type));
}
grid.push(rowTiles);
}
// Clear path: (0,1) and (1,1) to create L-shape
grid[0][1].cleared = true;
grid[1][1].cleared = true;
grid[2][1].cleared = true;
const result = NoMovesDetector.hasValidMoves(grid);
expect(result).toBe(true);
});
it('should detect valid moves with 2 turns', () => {
// Create a grid where tiles connect with a Z-shaped path
const grid: Tile[][] = [];
for (let row = 0; row < 4; row++) {
const rowTiles: Tile[] = [];
for (let col = 0; col < 4; col++) {
let type = 0;
if (row === 0 && col === 0) type = 1;
else if (row === 3 && col === 3) type = 1;
else type = (row * 4 + col) % 15 + 2;
rowTiles.push(createTile(row, col, type));
}
grid.push(rowTiles);
}
// Clear a Z-shaped path
grid[0][1].cleared = true;
grid[0][2].cleared = true;
grid[1][2].cleared = true;
grid[2][2].cleared = true;
grid[3][2].cleared = true;
const result = NoMovesDetector.hasValidMoves(grid);
expect(result).toBe(true);
});
it('should reject pairs that require 3 turns', () => {
// Create a grid where tiles would need 3 turns to connect
const grid: Tile[][] = [];
for (let row = 0; row < 5; row++) {
const rowTiles: Tile[] = [];
for (let col = 0; col < 5; col++) {
let type = 0;
if (row === 0 && col === 0) type = 1;
else if (row === 4 && col === 4) type = 1;
else type = (row * 5 + col) % 15 + 2;
rowTiles.push(createTile(row, col, type));
}
grid.push(rowTiles);
}
// Don't clear any path - tiles are blocked
const result = NoMovesDetector.hasValidMoves(grid);
expect(result).toBe(false);
});
it('should handle grid with only one tile of each type (no pairs)', () => {
// Create a grid where all tiles have unique types
const grid: Tile[][] = [];
for (let row = 0; row < 3; row++) {
const rowTiles: Tile[] = [];
for (let col = 0; col < 3; col++) {
const type = row * 3 + col + 1; // All unique
rowTiles.push(createTile(row, col, type));
}
grid.push(rowTiles);
}
const result = NoMovesDetector.hasValidMoves(grid);
expect(result).toBe(false);
});
it('should find valid move quickly when it exists (early exit optimization)', () => {
// Create a large grid with many tiles but only one valid pair
const grid: Tile[][] = [];
for (let row = 0; row < 6; row++) {
const rowTiles: Tile[] = [];
for (let col = 0; col < 8; col++) {
let type = 0;
// Only one valid pair at (0,0) and (0,2)
if (row === 0 && col === 0) type = 1;
else if (row === 0 && col === 2) type = 1;
else type = ((row * 8 + col) % 15) + 2; // All other types form no pairs
rowTiles.push(createTile(row, col, type));
}
grid.push(rowTiles);
}
// Clear the path
grid[0][1].cleared = true;
const result = NoMovesDetector.hasValidMoves(grid);
expect(result).toBe(true);
});
});
});
-328
View File
@@ -1,328 +0,0 @@
// src/__tests__/PathFinder.test.ts - Tests for PathFinder class
import { describe, it, expect, beforeEach } from 'vitest';
import { PathNode, MatchResult, Tile, TilePosition } from '../types';
import { PathFinder } from '../matching/PathFinder';
import { CONFIG } from '../config';
// Helper function to create a test grid
function createTestGrid(clearedPositions: TilePosition[][]): Tile[][] {
const grid: Tile[][] = [];
for (let row = 0; row < CONFIG.grid.rows; row++) {
grid[row] = [];
for (let col = 0; col < CONFIG.grid.cols; col++) {
// Check if this position should be cleared
const isCleared = clearedPositions.some(pos => pos[0].row === row && pos[0].col === col);
grid[row][col] = {
id: `tile-${row}-${col}`,
type: 0,
position: { row, col },
cleared: isCleared
};
}
}
return grid;
}
describe('PathFinder Types', () => {
describe('PathNode interface', () => {
it('should have row property', () => {
const node: PathNode = {
row: 0,
col: 0,
direction: -1,
turns: 0,
path: []
};
expect(node.row).toBeDefined();
expect(typeof node.row).toBe('number');
});
it('should have col property', () => {
const node: PathNode = {
row: 0,
col: 0,
direction: -1,
turns: 0,
path: []
};
expect(node.col).toBeDefined();
expect(typeof node.col).toBe('number');
});
it('should have direction property', () => {
const node: PathNode = {
row: 0,
col: 0,
direction: -1,
turns: 0,
path: []
};
expect(node.direction).toBeDefined();
expect(typeof node.direction).toBe('number');
});
it('should have turns property', () => {
const node: PathNode = {
row: 0,
col: 0,
direction: -1,
turns: 0,
path: []
};
expect(node.turns).toBeDefined();
expect(typeof node.turns).toBe('number');
});
it('should have path property', () => {
const node: PathNode = {
row: 0,
col: 0,
direction: -1,
turns: 0,
path: []
};
expect(node.path).toBeDefined();
expect(Array.isArray(node.path)).toBe(true);
});
});
describe('MatchResult interface', () => {
it('should have valid property', () => {
const result: MatchResult = {
valid: true,
path: [],
turns: 0
};
expect(result.valid).toBeDefined();
expect(typeof result.valid).toBe('boolean');
});
it('should have optional reason property', () => {
const result: MatchResult = {
valid: false,
reason: 'Invalid match'
};
expect(result.reason).toBeDefined();
expect(typeof result.reason).toBe('string');
});
it('should have optional path property', () => {
const result: MatchResult = {
valid: true,
path: [{ row: 0, col: 0 }],
turns: 0
};
expect(result.path).toBeDefined();
expect(Array.isArray(result.path)).toBe(true);
});
it('should have optional turns property', () => {
const result: MatchResult = {
valid: true,
path: [],
turns: 2
};
expect(result.turns).toBeDefined();
expect(typeof result.turns).toBe('number');
});
});
});
describe('PathFinder', () => {
describe('Test 1: Direct horizontal path (0 turns)', () => {
it('should find path when tiles are on same row with cleared tiles between', () => {
// Create grid with cleared path from (0,0) to (0,4)
const clearedPositions = [
[{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }, { row: 0, col: 3 }, { row: 0, col: 4 }]
];
const grid = createTestGrid(clearedPositions);
const start = { row: 0, col: 0 };
const end = { row: 0, col: 4 };
const result = PathFinder.findPath(start, end, grid);
expect(result).not.toBeNull();
expect(result!.turns).toBe(0);
expect(result!.path).toHaveLength(5);
expect(result!.path[0]).toEqual(start);
expect(result!.path[4]).toEqual(end);
});
});
describe('Test 2: Direct vertical path (0 turns)', () => {
it('should find path when tiles are on same column with cleared tiles between', () => {
const clearedPositions = [
[{ row: 0, col: 0 }, { row: 1, col: 0 }, { row: 2, col: 0 }, { row: 3, col: 0 }, { row: 4, col: 0 }]
];
const grid = createTestGrid(clearedPositions);
const start = { row: 0, col: 0 };
const end = { row: 4, col: 0 };
const result = PathFinder.findPath(start, end, grid);
expect(result).not.toBeNull();
expect(result!.turns).toBe(0);
expect(result!.path).toHaveLength(5);
expect(result!.path[0]).toEqual(start);
expect(result!.path[4]).toEqual(end);
});
});
describe('Test 3: L-shaped path (1 turn)', () => {
it('should find path with one turn around a corner', () => {
// Create L-shaped path: (0,0) -> (0,2) -> (2,2)
const clearedPositions = [
[{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }],
[{ row: 1, col: 2 }],
[{ row: 2, col: 2 }]
];
const grid = createTestGrid(clearedPositions);
const start = { row: 0, col: 0 };
const end = { row: 2, col: 2 };
const result = PathFinder.findPath(start, end, grid);
expect(result).not.toBeNull();
expect(result!.turns).toBe(1);
expect(result!.path[0]).toEqual(start);
expect(result!.path[result!.path.length - 1]).toEqual(end);
});
});
describe('Test 4: Z-shaped path (2 turns)', () => {
it('should find path with two turns (Z-shaped)', () => {
// Create Z-shaped path: (0,0) -> (0,2) -> (2,2) -> (2,4)
const clearedPositions = [
[{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }],
[{ row: 1, col: 2 }],
[{ row: 2, col: 2 }, { row: 2, col: 3 }, { row: 2, col: 4 }]
];
const grid = createTestGrid(clearedPositions);
const start = { row: 0, col: 0 };
const end = { row: 2, col: 4 };
const result = PathFinder.findPath(start, end, grid);
expect(result).not.toBeNull();
expect(result!.turns).toBe(2);
expect(result!.path[0]).toEqual(start);
expect(result!.path[result!.path.length - 1]).toEqual(end);
});
});
describe('Test 5: Path with 3 turns is rejected', () => {
it('should return null when path requires 3 or more turns', () => {
// Create a path that requires 3 turns
const clearedPositions = [
[{ row: 0, col: 0 }, { row: 0, col: 1 }],
[{ row: 1, col: 1 }, { row: 2, col: 1 }],
[{ row: 2, col: 2 }],
[{ row: 3, col: 2 }, { row: 4, col: 2 }]
];
const grid = createTestGrid(clearedPositions);
const start = { row: 0, col: 0 };
const end = { row: 4, col: 2 };
const result = PathFinder.findPath(start, end, grid, 2);
expect(result).toBeNull();
});
});
describe('Test 6: Path through uncleared tile is rejected', () => {
it('should return null when direct path is blocked by uncleared tile', () => {
// Clear only start and end, but not the middle
const clearedPositions = [
[{ row: 0, col: 0 }],
[{ row: 0, col: 2 }]
];
const grid = createTestGrid(clearedPositions);
const start = { row: 0, col: 0 };
const end = { row: 0, col: 2 };
const result = PathFinder.findPath(start, end, grid);
expect(result).toBeNull();
});
});
describe('Test 7: No path exists returns null', () => {
it('should return null when no valid path exists', () => {
// Clear only isolated tiles with no connection
const clearedPositions = [
[{ row: 0, col: 0 }],
[{ row: 5, col: 5 }]
];
const grid = createTestGrid(clearedPositions);
const start = { row: 0, col: 0 };
const end = { row: 5, col: 5 };
const result = PathFinder.findPath(start, end, grid);
expect(result).toBeNull();
});
});
describe('Test 8: Returns path including start and end positions', () => {
it('should include both start and end positions in the path', () => {
const clearedPositions = [
[{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }]
];
const grid = createTestGrid(clearedPositions);
const start = { row: 0, col: 0 };
const end = { row: 0, col: 2 };
const result = PathFinder.findPath(start, end, grid);
expect(result).not.toBeNull();
expect(result!.path[0]).toEqual(start);
expect(result!.path[result!.path.length - 1]).toEqual(end);
});
});
describe('Test 9: Correctly counts turns (direction changes only)', () => {
it('should count only direction changes as turns', () => {
// Path: right 3 steps, down 1 step, right 2 steps = 1 turn
const clearedPositions = [
[{ row: 0, col: 0 }, { row: 0, col: 1 }, { row: 0, col: 2 }, { row: 0, col: 3 }],
[{ row: 1, col: 3 }],
[{ row: 1, col: 4 }, { row: 1, col: 5 }]
];
const grid = createTestGrid(clearedPositions);
const start = { row: 0, col: 0 };
const end = { row: 1, col: 5 };
const result = PathFinder.findPath(start, end, grid);
expect(result).not.toBeNull();
expect(result!.turns).toBe(1); // Only one direction change (right -> down)
});
});
describe('Test 10: Start position with direction=-1 has 0 turns', () => {
it('should not count the first move as a turn', () => {
// Moving in any direction from start should be 0 turns initially
const clearedPositions = [
[{ row: 0, col: 0 }, { row: 0, col: 1 }]
];
const grid = createTestGrid(clearedPositions);
const start = { row: 0, col: 0 };
const end = { row: 0, col: 1 };
const result = PathFinder.findPath(start, end, grid);
expect(result).not.toBeNull();
expect(result!.turns).toBe(0); // First move doesn't count as turn
});
});
});
-410
View File
@@ -1,410 +0,0 @@
// src/__tests__/Renderer.test.ts - Tests for Renderer class
import { describe, it, expect, beforeEach, vi } from 'vitest';
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;
let gridManager: GridManager;
let mockCanvas: any;
beforeEach(() => {
// Mock canvas
mockCanvas = {
width: 800,
height: 600,
};
// Mock CanvasRenderingContext2D
mockCtx = {
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
strokeText: vi.fn(),
clearRect: vi.fn(),
beginPath: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
save: vi.fn(),
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,
globalAlpha: 1,
font: '',
textAlign: '',
textBaseline: '',
shadowBlur: 0,
shadowColor: '',
lineCap: '',
lineJoin: '',
};
// Create GridManager instance
gridManager = new GridManager();
gridManager.initializeGrid();
// Create Renderer instance
renderer = new Renderer(mockCtx as any, gridManager);
});
describe('render', () => {
it('should draw all non-cleared tiles from GridManager', () => {
renderer.render();
// Verify that fillRect was called for each tile (160 tiles in 10x16 grid)
// At minimum, it should be called many times for tile backgrounds
expect(mockCtx.fillRect).toHaveBeenCalled();
});
it('should center the grid within canvas', () => {
renderer.render();
// Verify canvas was cleared
expect(mockCtx.clearRect).toHaveBeenCalledWith(0, 0, mockCanvas.width, mockCanvas.height);
// The grid should be centered, so we expect tile drawing to start at an offset
expect(mockCtx.fillRect).toHaveBeenCalled();
});
it('should clear canvas with background color before rendering', () => {
renderer.render();
expect(mockCtx.fillStyle).toBe(CONFIG.colors.background);
expect(mockCtx.fillRect).toHaveBeenCalledWith(0, 0, mockCanvas.width, mockCanvas.height);
});
});
describe('renderTile', () => {
it('should draw tile at correct x,y position based on row/col', () => {
const tile = gridManager.getTileAt(0, 0);
if (tile) {
renderer['renderTile'](mockCtx, tile, 10, 20); // offsetX=10, offsetY=20
const expectedX = 10 + 0 * (CONFIG.tile.size + CONFIG.tile.gap) + CONFIG.tile.gap;
const expectedY = 20 + 0 * (CONFIG.tile.size + CONFIG.tile.gap) + CONFIG.tile.gap;
expect(mockCtx.fillRect).toHaveBeenCalledWith(
expectedX,
expectedY,
CONFIG.tile.size,
CONFIG.tile.size
);
}
});
it('should center emoji within tile bounds', () => {
const tile = gridManager.getTileAt(0, 0);
if (tile) {
const offsetX = 10;
const offsetY = 20;
renderer['renderTile'](mockCtx, tile, offsetX, offsetY);
const expectedX = offsetX + 0 * (CONFIG.tile.size + CONFIG.tile.gap) + CONFIG.tile.gap;
const expectedY = offsetY + 0 * (CONFIG.tile.size + CONFIG.tile.gap) + CONFIG.tile.gap;
const centerX = expectedX + CONFIG.tile.size / 2;
const centerY = expectedY + CONFIG.tile.size / 2;
expect(mockCtx.textAlign).toBe('center');
expect(mockCtx.textBaseline).toBe('middle');
expect(mockCtx.fillText).toHaveBeenCalledWith(tile.emoji, centerX, centerY);
}
});
it('should use CONFIG colors for tile background', () => {
const tile = gridManager.getTileAt(0, 0);
if (tile) {
renderer['renderTile'](mockCtx, tile, 10, 20);
expect(mockCtx.fillStyle).toBe(CONFIG.colors.tile);
}
});
});
describe('renderSelection', () => {
it('should draw border with CONFIG.colors.selection', () => {
const tile = gridManager.getTileAt(0, 0);
if (tile) {
renderer['renderSelection'](mockCtx, tile, 10, 20);
expect(mockCtx.strokeStyle).toBe(CONFIG.colors.selection);
expect(mockCtx.lineWidth).toBe(3);
expect(mockCtx.strokeRect).toHaveBeenCalled();
}
});
it('should draw background tint with 30% max opacity', () => {
const tile = gridManager.getTileAt(0, 0);
if (tile) {
renderer['renderSelection'](mockCtx, tile, 10, 20);
// Should set globalAlpha for tint
expect(mockCtx.save).toHaveBeenCalled();
expect(mockCtx.restore).toHaveBeenCalled();
}
});
it('should fade in highlight over ~100ms', () => {
const tile = gridManager.getTileAt(0, 0);
if (tile) {
const startAlpha = renderer['getSelectionAlpha'](tile, 0); // 0ms elapsed
const midAlpha = renderer['getSelectionAlpha'](tile, 50); // 50ms elapsed
const endAlpha = renderer['getSelectionAlpha'](tile, 100); // 100ms elapsed
const overAlpha = renderer['getSelectionAlpha'](tile, 150); // 150ms elapsed (should be clamped)
expect(startAlpha).toBe(0);
expect(midAlpha).toBeGreaterThan(0);
expect(midAlpha).toBeLessThan(0.3);
expect(endAlpha).toBe(0.3);
expect(overAlpha).toBe(0.3); // Should clamp at max
}
});
});
describe('selection behavior', () => {
it('should not draw cleared tiles', () => {
const tile = gridManager.getTileAt(0, 0);
if (tile) {
tile.cleared = true;
renderer.render();
// Verify that cleared tiles are skipped
// This is tested by ensuring renderTile is NOT called for cleared tiles
// We can't easily test this without spying on private methods,
// but the visual result would be that the tile doesn't appear
}
});
it('should only highlight selected tiles', () => {
const tile1 = gridManager.getTileAt(0, 0);
const tile2 = gridManager.getTileAt(0, 1);
if (tile1 && tile2) {
gridManager.selectTile(tile1);
gridManager.selectTile(tile2);
renderer.render();
// Should have selection highlights for selected tiles
expect(mockCtx.strokeRect).toHaveBeenCalled();
}
});
it('should not highlight non-selected tiles', () => {
// Don't select any tiles
renderer.render();
// strokeRect should not be called for selections
// (it may be called for other purposes like rounded rectangles)
const strokeRectCalls = mockCtx.strokeRect.mock.calls;
// We expect strokeRect not to be called for selection highlights
// 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');
});
});
describe('animateMatch', () => {
it('should create and start match animations for tiles', () => {
const tile = gridManager.getTileAt(0, 0);
if (tile) {
renderer.animateMatch([tile]);
// Verify animation was created (internal map has entry)
const animations = renderer['matchAnimations'];
expect(animations.has(tile.id)).toBe(true);
}
});
it('should support multiple tiles', () => {
const tile1 = gridManager.getTileAt(0, 0);
const tile2 = gridManager.getTileAt(0, 1);
if (tile1 && tile2) {
renderer.animateMatch([tile1, tile2]);
const animations = renderer['matchAnimations'];
expect(animations.has(tile1.id)).toBe(true);
expect(animations.has(tile2.id)).toBe(true);
}
});
});
});
-33
View File
@@ -1,33 +0,0 @@
// src/__tests__/Scoring.test.ts - Test suite for Scoring system
import { describe, it, expect } from 'vitest';
import { Scoring } from '../matching/Scoring';
describe('Scoring', () => {
describe('calculate', () => {
it('should return base score of 100 for 2-turn match', () => {
const score = Scoring.calculate(2);
expect(score).toBe(100);
});
it('should return 150 (50% bonus) for 0-turn match', () => {
const score = Scoring.calculate(0);
expect(score).toBe(150);
});
it('should return 125 (25% bonus) for 1-turn match', () => {
const score = Scoring.calculate(1);
expect(score).toBe(125);
});
it('should default to base score for invalid turn count', () => {
const score = Scoring.calculate(5);
expect(score).toBe(100);
});
it('should return integer scores (no floating point)', () => {
const score = Scoring.calculate(0);
expect(Number.isInteger(score)).toBe(true);
expect(score).toBe(150);
});
});
});
-123
View File
@@ -1,123 +0,0 @@
// src/__tests__/Tile.test.ts - Unit tests for Tile model class
import { describe, it, expect } from 'vitest';
import { Tile } from '../models/Tile';
import { TilePosition } from '../types';
import { CONFIG } from '../config';
describe('Tile', () => {
describe('constructor', () => {
it('should set id, type, and position properties', () => {
const position: TilePosition = { row: 0, col: 1 };
const tile = new Tile('tile-1', 0, position);
expect(tile.id).toBe('tile-1');
expect(tile.type).toBe(0);
expect(tile.position).toEqual(position);
});
it('should set cleared property to false by default', () => {
const tile = new Tile('tile-1', 0, { row: 0, col: 0 });
expect(tile.cleared).toBe(false);
});
it('should allow cleared to be set to true', () => {
const tile = new Tile('tile-1', 0, { row: 0, col: 0 });
tile.cleared = true;
expect(tile.cleared).toBe(true);
});
});
describe('emoji getter', () => {
it('should return correct emoji for type 0', () => {
const tile = new Tile('tile-1', 0, { row: 0, col: 0 });
expect(tile.emoji).toBe(CONFIG.emojis[0]);
});
it('should return correct emoji for type 7', () => {
const tile = new Tile('tile-1', 7, { row: 0, col: 0 });
expect(tile.emoji).toBe(CONFIG.emojis[7]);
});
it('should return correct emoji for type 15 (last)', () => {
const tile = new Tile('tile-1', 15, { row: 0, col: 0 });
expect(tile.emoji).toBe(CONFIG.emojis[15]);
});
it('should return all 16 emojis correctly', () => {
for (let i = 0; i < 16; i++) {
const tile = new Tile(`tile-${i}`, i, { row: 0, col: 0 });
expect(tile.emoji).toBe(CONFIG.emojis[i]);
}
});
});
describe('isAdjacent()', () => {
it('should return true for tiles adjacent horizontally', () => {
const tile1 = new Tile('tile-1', 0, { row: 0, col: 0 });
const tile2 = new Tile('tile-2', 0, { row: 0, col: 1 });
expect(tile1.isAdjacent(tile2)).toBe(true);
expect(tile2.isAdjacent(tile1)).toBe(true);
});
it('should return true for tiles adjacent vertically', () => {
const tile1 = new Tile('tile-1', 0, { row: 0, col: 0 });
const tile2 = new Tile('tile-2', 0, { row: 1, col: 0 });
expect(tile1.isAdjacent(tile2)).toBe(true);
expect(tile2.isAdjacent(tile1)).toBe(true);
});
it('should return false for diagonal tiles', () => {
const tile1 = new Tile('tile-1', 0, { row: 0, col: 0 });
const tile2 = new Tile('tile-2', 0, { row: 1, col: 1 });
expect(tile1.isAdjacent(tile2)).toBe(false);
expect(tile2.isAdjacent(tile1)).toBe(false);
});
it('should return false for tiles with gap of 2', () => {
const tile1 = new Tile('tile-1', 0, { row: 0, col: 0 });
const tile2 = new Tile('tile-2', 0, { row: 0, col: 2 });
expect(tile1.isAdjacent(tile2)).toBe(false);
});
it('should return false for the same tile', () => {
const tile = new Tile('tile-1', 0, { row: 0, col: 0 });
expect(tile.isAdjacent(tile)).toBe(false);
});
it('should return false for tiles far apart', () => {
const tile1 = new Tile('tile-1', 0, { row: 0, col: 0 });
const tile2 = new Tile('tile-2', 0, { row: 5, col: 3 });
expect(tile1.isAdjacent(tile2)).toBe(false);
});
it('should handle edge positions correctly', () => {
// Bottom right corner
const cornerTile = new Tile('tile-1', 0, { row: 9, col: 15 });
const adjacentLeft = new Tile('tile-2', 0, { row: 9, col: 14 });
const adjacentTop = new Tile('tile-3', 0, { row: 8, col: 15 });
expect(cornerTile.isAdjacent(adjacentLeft)).toBe(true);
expect(cornerTile.isAdjacent(adjacentTop)).toBe(true);
});
});
describe('readonly properties', () => {
it('should have readonly id', () => {
const tile = new Tile('tile-1', 0, { row: 0, col: 0 });
// TypeScript enforces readonly at compile time
// This test confirms the property exists and has correct value
expect(tile.id).toBe('tile-1');
});
it('should have readonly type', () => {
const tile = new Tile('tile-1', 5, { row: 0, col: 0 });
expect(tile.type).toBe(5);
});
it('should have readonly position', () => {
const tile = new Tile('tile-1', 0, { row: 3, col: 7 });
expect(tile.position.row).toBe(3);
expect(tile.position.col).toBe(7);
});
});
});
-87
View File
@@ -1,87 +0,0 @@
// Tests for src/config.ts
// Test 1: CONFIG.grid.rows equals 10
// Test 2: CONFIG.grid.cols equals 16
// Test 3: CONFIG.emojis has exactly 16 emojis
// Test 4: CONFIG.tile.size and gap are positive numbers
import { describe, it, expect } from 'vitest';
import { CONFIG } from '../config';
describe('CONFIG', () => {
describe('grid', () => {
it('should have rows equal to 10', () => {
expect(CONFIG.grid.rows).toBe(10);
});
it('should have cols equal to 16', () => {
expect(CONFIG.grid.cols).toBe(16);
});
it('should have totalTiles equal to rows * cols', () => {
expect(CONFIG.grid.totalTiles).toBe(CONFIG.grid.rows * CONFIG.grid.cols);
expect(CONFIG.grid.totalTiles).toBe(160);
});
it('should have pairsPerType equal to 10', () => {
expect(CONFIG.grid.pairsPerType).toBe(10);
});
});
describe('tile', () => {
it('should have positive size', () => {
expect(CONFIG.tile.size).toBeGreaterThan(0);
});
it('should have positive gap', () => {
expect(CONFIG.tile.gap).toBeGreaterThan(0);
});
it('should have positive cornerRadius', () => {
expect(CONFIG.tile.cornerRadius).toBeGreaterThanOrEqual(0);
});
});
describe('emojis', () => {
it('should have exactly 16 emojis', () => {
expect(CONFIG.emojis).toHaveLength(16);
});
it('should contain the correct emoji set', () => {
const expectedEmojis = [
'🌟', '⭐', '💫', '✨', '🌙', '☀️', '🔥', '💧',
'🌿', '⚡', '🧊', '🪨', '🌸', '🍃', '🌊', '🍄'
];
expect(CONFIG.emojis).toEqual(expectedEmojis);
});
});
describe('colors', () => {
it('should have valid hex background color', () => {
expect(CONFIG.colors.background).toMatch(/^#[0-9a-fA-F]{6}$/);
});
it('should have valid hex tile color', () => {
expect(CONFIG.colors.tile).toMatch(/^#[0-9a-fA-F]{6}$/);
});
it('should have valid hex tileHover color', () => {
expect(CONFIG.colors.tileHover).toMatch(/^#[0-9a-fA-F]{6}$/);
});
it('should have valid hex selection color', () => {
expect(CONFIG.colors.selection).toMatch(/^#[0-9a-fA-F]{6}$/);
});
it('should have valid hex text color', () => {
expect(CONFIG.colors.text).toMatch(/^#[0-9a-fA-F]{6}$/);
});
it('should have the correct color values', () => {
expect(CONFIG.colors.background).toBe('#1a1a2e');
expect(CONFIG.colors.tile).toBe('#16213e');
expect(CONFIG.colors.tileHover).toBe('#0f3460');
expect(CONFIG.colors.selection).toBe('#e94560');
expect(CONFIG.colors.text).toBe('#eaeaea');
});
});
});
-155
View File
@@ -1,155 +0,0 @@
// Tests for src/main.ts
// TDD tests for application entry point
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Game } from '../game/Game';
describe('main.ts entry point', () => {
let originalRaf: typeof globalThis.requestAnimationFrame;
let originalCancelRaf: typeof globalThis.cancelAnimationFrame;
let originalPerformance: typeof globalThis.performance;
beforeEach(() => {
// Save originals
originalRaf = globalThis.requestAnimationFrame;
originalCancelRaf = globalThis.cancelAnimationFrame;
originalPerformance = globalThis.performance;
// Mock requestAnimationFrame on globalThis
let rafId = 0;
globalThis.requestAnimationFrame = vi.fn((_cb: FrameRequestCallback): number => {
rafId++;
return rafId;
});
globalThis.cancelAnimationFrame = vi.fn((_id: number) => {});
// Mock performance.now()
let mockTime = 0;
globalThis.performance = {
...globalThis.performance,
now: vi.fn(() => {
mockTime += 16.67;
return mockTime;
}),
} as typeof globalThis.performance;
// Mock console methods
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCancelRaf;
globalThis.performance = originalPerformance;
vi.restoreAllMocks();
});
describe('Game instantiation', () => {
it('should be able to import and instantiate Game', async () => {
// Mock DOM
const mockCanvas = {
width: 0,
height: 0,
style: { width: '', height: '' },
getContext: vi.fn(() => ({
scale: vi.fn(),
fillStyle: '',
fillRect: vi.fn(),
})),
};
vi.stubGlobal('document', {
getElementById: vi.fn((id: string) => {
if (id === 'game') return mockCanvas;
return null;
}),
});
vi.stubGlobal('window', { devicePixelRatio: 1 });
// Should be able to create a Game instance
const game = new Game();
expect(game).toBeInstanceOf(Game);
expect(game.canvas).toBe(mockCanvas);
game.stop();
});
it('should be able to start the game', async () => {
const mockCanvas = {
width: 0,
height: 0,
style: { width: '', height: '' },
getContext: vi.fn(() => ({
scale: vi.fn(),
fillStyle: '',
fillRect: vi.fn(),
})),
};
vi.stubGlobal('document', {
getElementById: vi.fn((id: string) => {
if (id === 'game') return mockCanvas;
return null;
}),
});
vi.stubGlobal('window', { devicePixelRatio: 1 });
const game = new Game();
const emitSpy = vi.spyOn(game.events, 'emit');
game.start();
expect(emitSpy).toHaveBeenCalledWith('game:start', undefined as never);
game.stop();
});
it('should handle error events', async () => {
const mockCanvas = {
width: 0,
height: 0,
style: { width: '', height: '' },
getContext: vi.fn(() => ({
scale: vi.fn(),
fillStyle: '',
fillRect: vi.fn(),
})),
};
vi.stubGlobal('document', {
getElementById: vi.fn((id: string) => {
if (id === 'game') return mockCanvas;
return null;
}),
});
vi.stubGlobal('window', { devicePixelRatio: 1 });
const game = new Game();
// Register error handler
const errorHandler = vi.fn();
game.events.on('error', errorHandler);
// Emit an error
const testError = new Error('Test error');
game.events.emit('error', testError);
expect(errorHandler).toHaveBeenCalledWith(testError);
game.stop();
});
});
describe('DOMContentLoaded behavior', () => {
it('should have DOMContentLoaded event type available', () => {
// This test verifies the event type is available
// main.ts should use this event to initialize the game
const eventType = 'DOMContentLoaded';
expect(eventType).toBe('DOMContentLoaded');
});
});
});
-30
View File
@@ -1,30 +0,0 @@
// Test 1: npm run dev starts without errors
// Test 2: Browser displays Canvas element with colored background
// Test 3: npm run test runs vitest successfully
import { describe, it, expect } from 'vitest';
describe('Project Setup', () => {
it('should have vite as a dev dependency', () => {
// Read package.json to verify vite is installed
const pkg = require('../../package.json');
expect(pkg.devDependencies).toHaveProperty('vite');
});
it('should have typescript as a dev dependency', () => {
const pkg = require('../../package.json');
expect(pkg.devDependencies).toHaveProperty('typescript');
});
it('should have vitest as a dev dependency', () => {
const pkg = require('../../package.json');
expect(pkg.devDependencies).toHaveProperty('vitest');
});
it('should have npm scripts for dev, test, and build', () => {
const pkg = require('../../package.json');
expect(pkg.scripts).toHaveProperty('dev');
expect(pkg.scripts).toHaveProperty('test');
expect(pkg.scripts).toHaveProperty('build');
});
});
-178
View File
@@ -1,178 +0,0 @@
// Tests for src/types/index.ts
// Test 1: TilePosition type has row and col as numbers
// Test 2: Tile interface has id, type, position, cleared
// Test 3: GameEvents type defines event names and payloads
import { describe, it, expect } from 'vitest';
import type { TilePosition, Tile, GameEvents, StateChangeEvent } from '../types';
import { GameState } from '../types';
describe('Type Definitions', () => {
describe('TilePosition', () => {
it('should accept object with row and col as numbers', () => {
const position: TilePosition = { row: 5, col: 10 };
expect(position.row).toBe(5);
expect(position.col).toBe(10);
});
it('should allow row and col to be 0', () => {
const position: TilePosition = { row: 0, col: 0 };
expect(position.row).toBe(0);
expect(position.col).toBe(0);
});
});
describe('Tile', () => {
it('should have id as string', () => {
const tile: Tile = {
id: 'tile-0-0',
type: 0,
position: { row: 0, col: 0 },
cleared: false,
};
expect(typeof tile.id).toBe('string');
});
it('should have type as number (0-15 for emoji index)', () => {
const tile: Tile = {
id: 'tile-5-5',
type: 7,
position: { row: 5, col: 5 },
cleared: false,
};
expect(typeof tile.type).toBe('number');
expect(tile.type).toBeGreaterThanOrEqual(0);
expect(tile.type).toBeLessThanOrEqual(15);
});
it('should have position as TilePosition', () => {
const tile: Tile = {
id: 'tile-1-2',
type: 3,
position: { row: 1, col: 2 },
cleared: false,
};
expect(tile.position).toHaveProperty('row');
expect(tile.position).toHaveProperty('col');
});
it('should have cleared as boolean', () => {
const tile: Tile = {
id: 'tile-2-3',
type: 5,
position: { row: 2, col: 3 },
cleared: true,
};
expect(typeof tile.cleared).toBe('boolean');
expect(tile.cleared).toBe(true);
});
});
describe('GameEvents', () => {
it('should define game:start event with void payload', () => {
// Type check - if this compiles, the type is correct
type StartPayload = GameEvents['game:start'];
const payload: StartPayload = undefined;
expect(payload).toBeUndefined();
});
it('should define game:tick event with deltaTime', () => {
type TickPayload = GameEvents['game:tick'];
const payload: TickPayload = { deltaTime: 16.67 };
expect(payload.deltaTime).toBe(16.67);
});
it('should define tile:selected event with tile, row, col', () => {
type SelectedPayload = GameEvents['tile:selected'];
const tile: Tile = {
id: 'test',
type: 0,
position: { row: 0, col: 0 },
cleared: false,
};
const payload: SelectedPayload = { tile, row: 0, col: 0 };
expect(payload.tile).toBe(tile);
expect(payload.row).toBe(0);
expect(payload.col).toBe(0);
});
it('should define tile:cleared event with tile', () => {
type ClearedPayload = GameEvents['tile:cleared'];
const tile: Tile = {
id: 'test',
type: 1,
position: { row: 1, col: 1 },
cleared: true,
};
const payload: ClearedPayload = { tile };
expect(payload.tile).toBe(tile);
});
it('should define game:score event with points', () => {
type ScorePayload = GameEvents['game:score'];
const payload: ScorePayload = { points: 100 };
expect(payload.points).toBe(100);
});
it('should define game:over event with won boolean', () => {
type OverPayload = GameEvents['game:over'];
const payload: OverPayload = { won: true };
expect(payload.won).toBe(true);
});
it('should define error event with Error', () => {
type ErrorPayload = GameEvents['error'];
const payload: ErrorPayload = new Error('Test error');
expect(payload).toBeInstanceOf(Error);
});
it('should define game:stateChange event with StateChangeEvent', () => {
type StateChangePayload = GameEvents['game:stateChange'];
const payload: StateChangePayload = {
from: GameState.IDLE,
to: GameState.SELECTING
};
expect(payload.from).toBe(GameState.IDLE);
expect(payload.to).toBe(GameState.SELECTING);
});
it('should define board:shuffling event with tilesRemaining', () => {
type ShufflingPayload = GameEvents['board:shuffling'];
const payload: ShufflingPayload = { tilesRemaining: 50 };
expect(payload.tilesRemaining).toBe(50);
});
it('should define board:shuffled event with tilesRemaining', () => {
type ShuffledPayload = GameEvents['board:shuffled'];
const payload: ShuffledPayload = { tilesRemaining: 50 };
expect(payload.tilesRemaining).toBe(50);
});
});
describe('GameState', () => {
it('should have 4 string values', () => {
expect(GameState.IDLE).toBe('IDLE');
expect(GameState.SELECTING).toBe('SELECTING');
expect(GameState.MATCHING).toBe('MATCHING');
expect(GameState.GAME_OVER).toBe('GAME_OVER');
});
it('should have string enum values for debugging', () => {
expect(typeof GameState.IDLE).toBe('string');
expect(typeof GameState.SELECTING).toBe('string');
expect(typeof GameState.MATCHING).toBe('string');
expect(typeof GameState.GAME_OVER).toBe('string');
});
});
describe('StateChangeEvent', () => {
it('should have from and to properties of type GameState', () => {
const event: StateChangeEvent = {
from: GameState.IDLE,
to: GameState.SELECTING
};
expect(event.from).toBe(GameState.IDLE);
expect(event.to).toBe(GameState.SELECTING);
});
});
});
-30
View File
@@ -1,30 +0,0 @@
// src/config.ts - Game configuration constants
// All game constants in one place, easily tunable
export const CONFIG = {
grid: {
rows: 10,
cols: 16,
totalTiles: 160,
pairsPerType: 10,
},
tile: {
size: 48,
gap: 4,
cornerRadius: 8,
},
emojis: [
'🌟', '⭐', '💫', '✨', '🌙', '☀️', '🔥', '💧',
'🌿', '⚡', '🧊', '🪨', '🌸', '🍃', '🌊', '🍄'
],
colors: {
background: '#1a1a2e',
tile: '#16213e',
tileHover: '#0f3460',
selection: '#e94560',
text: '#eaeaea',
},
animation: {
matchDuration: 250, // ms - scale+fade duration per CONTEXT.md
},
} as const;
-86
View File
@@ -1,86 +0,0 @@
// src/detection/NoMovesDetector.ts - Type-optimized no-moves detection algorithm
import type { Tile } from '../types';
import { PathFinder } from '../matching/PathFinder';
/**
* NoMovesDetector determines if any valid moves remain on the board
*
* Algorithm overview (type-optimized):
* 1. Group all uncleared tiles by type into Map<number, Tile[]>
* 2. For each type group with 2+ tiles:
* - Check all pairs within that type group
* - For each pair, call PathFinder.findPath(pos1, pos2, grid, 2)
* - If path found, return true immediately (early exit)
* 3. If no valid pairs found after checking all types, return false
*
* Performance optimization:
* - Only check pairs within same type (94% reduction in PathFinder calls)
* - Early exit on first valid move found
* - Skip cleared tiles when building type groups
*/
export class NoMovesDetector {
/**
* Check if any valid moves exist on the board
* @param grid - 2D array of tiles
* @returns true if at least one valid pair can be matched, false otherwise
*/
static hasValidMoves(grid: Tile[][]): boolean {
// Handle empty board
if (!grid || grid.length === 0) {
return false;
}
// Step 1: Group all uncleared tiles by type
const tilesByType = new Map<number, Tile[]>();
for (let row = 0; row < grid.length; row++) {
for (let col = 0; col < grid[row].length; col++) {
const tile = grid[row][col];
// Skip cleared tiles
if (tile.cleared) {
continue;
}
// Add tile to its type group
if (!tilesByType.has(tile.type)) {
tilesByType.set(tile.type, []);
}
tilesByType.get(tile.type)!.push(tile);
}
}
// Step 2: For each type group with 2+ tiles, check all pairs
for (const [type, tiles] of tilesByType.entries()) {
// Need at least 2 tiles of the same type to form a pair
if (tiles.length < 2) {
continue;
}
// Check all pairs within this type group
// Use nested loops: for (i=0; i<tiles.length; i++) for (j=i+1; j<tiles.length; j++)
for (let i = 0; i < tiles.length; i++) {
for (let j = i + 1; j < tiles.length; j++) {
const tile1 = tiles[i];
const tile2 = tiles[j];
// Check if these two tiles can connect with a valid path
const path = PathFinder.findPath(
tile1.position,
tile2.position,
grid,
2 // maxTurns
);
// If path found, we have a valid move - early exit
if (path !== null) {
return true;
}
}
}
}
// Step 3: No valid pairs found
return false;
}
}
-76
View File
@@ -1,76 +0,0 @@
// src/game/EventEmitter.ts - Typed event emitter (browser-compatible)
/**
* TypedEventEmitter provides a type-safe event system that works in browsers.
* Custom implementation - no Node.js dependencies.
*/
export class TypedEventEmitter<T extends object> {
private listeners = new Map<keyof T, Set<(data: T[keyof T]) => void>>();
/**
* Register a listener for an event
* @param event - The event name (type-safe)
* @param listener - The callback function (payload is type-safe)
* @returns this for chaining
*/
on<K extends keyof T>(event: K, listener: (data: T[K]) => void): this {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(listener as (data: T[keyof T]) => void);
return this;
}
/**
* Register a one-time listener for an event
* @param event - The event name (type-safe)
* @param listener - The callback function (payload is type-safe)
* @returns this for chaining
*/
once<K extends keyof T>(event: K, listener: (data: T[K]) => void): this {
const onceWrapper = (data: T[K]) => {
this.off(event, onceWrapper);
listener(data);
};
return this.on(event, onceWrapper);
}
/**
* Emit an event with payload
* @param event - The event name (type-safe)
* @param data - The payload (type-safe)
* @returns true if listeners were called, false otherwise
*/
emit<K extends keyof T>(event: K, data: T[K]): boolean {
const eventListeners = this.listeners.get(event);
if (!eventListeners || eventListeners.size === 0) {
return false;
}
eventListeners.forEach(listener => listener(data as T[keyof T]));
return true;
}
/**
* Remove a specific listener for an event
* @param event - The event name (type-safe)
* @param listener - The callback function to remove
* @returns this for chaining
*/
off<K extends keyof T>(event: K, listener: (data: T[K]) => void): this {
const eventListeners = this.listeners.get(event);
if (eventListeners) {
eventListeners.delete(listener as (data: T[keyof T]) => void);
}
return this;
}
/**
* Remove all listeners for an event
* @param event - The event name (type-safe)
* @returns this for chaining
*/
removeAllListeners<K extends keyof T>(event: K): this {
this.listeners.delete(event);
return this;
}
}
-465
View File
@@ -1,465 +0,0 @@
// src/game/Game.ts - Main game orchestrator class
/**
* Game is the main orchestrator that coordinates all game components.
* It manages the canvas, game loop, event system, grid, and rendering.
*/
import { GameLoop } from './GameLoop';
import { TypedEventEmitter } from './EventEmitter';
import { GameEvents } from '../types';
import { CONFIG } from '../config';
import { GridManager } from '../managers/GridManager';
import { Renderer } from '../rendering/Renderer';
import { MatchEngine } from '../matching/MatchEngine';
import { GameStateManager, GameState } from '../state/GameStateManager';
import { NoMovesDetector } from '../detection/NoMovesDetector';
export class Game {
readonly canvas: HTMLCanvasElement;
readonly ctx: CanvasRenderingContext2D;
readonly loop: GameLoop;
readonly events: TypedEventEmitter<GameEvents>;
readonly gridManager: GridManager;
readonly renderer: Renderer;
readonly matchEngine: MatchEngine;
readonly gameStateManager: GameStateManager;
private resizeTimeout: number | undefined;
private score = 0;
private previousScore = 0;
private readonly MAX_SHUFFLE_ATTEMPTS = 3;
private shuffleAttempts = 0;
constructor() {
// Get canvas element
this.canvas = document.getElementById('game') as HTMLCanvasElement;
if (!this.canvas) {
throw new Error('Canvas element with id "game" not found');
}
// Get 2D rendering context
this.ctx = this.canvas.getContext('2d')!;
if (!this.ctx) {
throw new Error('Could not get 2D context from canvas');
}
// Initialize event emitter
this.events = new TypedEventEmitter<GameEvents>();
// Initialize grid manager and create grid
this.gridManager = new GridManager(this.events);
this.gridManager.initializeGrid();
// Initialize match engine
this.matchEngine = new MatchEngine(this.gridManager, this.events);
// Initialize game state manager
this.gameStateManager = new GameStateManager(this.events);
// Setup canvas size and scale
this.setupCanvas();
// Initialize renderer with canvas reference
this.renderer = new Renderer(this.ctx, this.gridManager);
this.renderer.setCanvas(this.canvas);
// Create game loop with update callback
this.loop = new GameLoop(this.update.bind(this));
// Listen for tilesSelected event from GridManager and validate matches
this.gridManager.events.on('tilesSelected', ({ tile1, tile2 }) => {
const result = this.matchEngine.validateMatch(tile1, tile2);
if (result.valid) {
// Successful match - draw path first
this.renderer.drawPath(result.path!);
// Start match animation (concurrent with path display)
this.renderer.animateMatch([tile1, tile2]);
// Emit tilesMatched event
this.events.emit('tilesMatched', {
tile1,
tile2,
path: result.path!,
turns: result.turns!,
score: result.score!
});
// Clear tiles from board after path animation completes
setTimeout(() => {
this.gridManager.clearTiles([tile1, tile2]);
// Check for no-moves condition after tiles cleared
const grid = this.gridManager.getAllTiles();
const hasValidMoves = NoMovesDetector.hasValidMoves(grid);
if (!hasValidMoves && this.gameStateManager.getState() !== GameState.GAME_OVER) {
// No moves left - trigger auto-shuffle
this.handleNoMoves();
}
}, 300); // Wait for path animation (300ms)
// Update score immediately
this.score += result.score!;
this.updateScoreDisplay();
// Emit score update event
this.events.emit('game:score', { points: this.score });
} else {
// Failed match - emit matchFailed event
this.events.emit('matchFailed', {
tile1,
tile2,
reason: result.reason || 'unknown'
});
// Trigger shake animation for visual feedback
this.renderer.animateShake([tile1, tile2], result.reason || 'unknown');
// Deselect after shake animation completes
setTimeout(() => {
this.gridManager.deselectAll();
}, 200); // Wait for shake animation (200ms)
}
});
// Setup input listeners
this.setupInputListeners();
// Listen for tile:cleared events to check win condition
this.events.on('tile:cleared', () => {
this.checkWinCondition();
});
// Setup restart button handler
const restartButton = document.getElementById('restart-button');
if (restartButton) {
restartButton.addEventListener('click', () => {
this.restart();
});
}
}
/**
* Update score display in HTML overlay
*/
private updateScoreDisplay(): void {
const scoreDisplay = document.getElementById('score-display');
if (scoreDisplay) {
scoreDisplay.textContent = `Score: ${this.score}`;
}
}
/**
* 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);
}
// 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);
}
/**
* Starts the game
*/
start(): void {
// Emit game:start event
this.events.emit('game:start', undefined as never);
// Start the game loop
this.loop.start();
}
/**
* Stops the game
*/
stop(): void {
this.loop.stop();
}
/**
* Update callback - called by GameLoop on each tick
* @param deltaTime - Time since last update in milliseconds
*/
private update(deltaTime: number): void {
// Emit game:tick event
this.events.emit('game:tick', { deltaTime });
// Render the current frame
this.render();
}
/**
* Renders the game state to the canvas
*/
private render(): void {
this.renderer.render();
}
/**
* Setup mouse and touch event listeners for tile selection
*/
public setupInputListeners(): void {
this.canvas.addEventListener('click', this.handleClick);
this.canvas.addEventListener('touchstart', this.handleTouch, { passive: true });
window.addEventListener('resize', this.handleResize);
}
/**
* Handle click events on the canvas
*/
private handleClick = (event: MouseEvent): void => {
this.handleInput(event);
};
/**
* Handle touch events on the canvas
*/
private handleTouch = (event: TouchEvent): void => {
this.handleInput(event);
};
/**
* Handle input from mouse or touch events
* Converts screen coordinates to canvas coordinates and selects the tile at that position
* @param event - MouseEvent or TouchEvent
*/
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);
// Find tile at coordinates
const { size, gap } = CONFIG.tile;
const col = Math.floor((x - gap) / (size + gap));
const row = Math.floor((y - gap) / (size + gap));
// Get tile and select it
const tile = this.gridManager.getTileAt(row, col);
if (tile) {
this.gridManager.selectTile(tile);
}
}
/**
* Handle window resize events with debouncing
* Recalculates canvas size and re-renders after debounce delay
*/
private handleResize = (): void => {
clearTimeout(this.resizeTimeout);
this.resizeTimeout = window.setTimeout(() => {
this.setupCanvas();
this.renderer.render();
}, 150); // 150ms debounce per RESEARCH.md
};
/**
* Check win condition - triggers game over when all tiles are cleared
*/
private checkWinCondition(): void {
// Get all tiles from grid
const tiles = this.gridManager.getAllTiles();
// Count uncleared tiles
const unclearedTiles = tiles.flat().filter(tile => !tile.cleared);
// If no tiles remain, player wins!
if (unclearedTiles.length === 0) {
this.handleGameOver(true);
}
}
/**
* Handle game over - transition state and emit event
* @param won - Whether the player won (true) or lost (false)
*/
private handleGameOver(won: boolean): void {
// Transition to GAME_OVER state
this.gameStateManager.transitionTo(GameState.GAME_OVER);
// Emit game:over event
this.events.emit('game:over', { won });
// Show game over overlay
this.showGameOverOverlay(won);
}
/**
* Show game over overlay with win/lose message
* @param won - Whether the player won (true) or lost (false)
*/
private showGameOverOverlay(won: boolean): void {
const overlay = document.getElementById('game-over-overlay');
const message = document.getElementById('game-over-message');
if (overlay && message) {
message.textContent = won ? 'You Win!' : 'No moves left!';
overlay.style.display = 'flex';
}
}
/**
* Hide game over overlay
*/
private hideGameOverOverlay(): void {
const overlay = document.getElementById('game-over-overlay');
if (overlay) {
overlay.style.display = 'none';
}
}
/**
* Show shuffle overlay with "Shuffling..." message
*/
private showShuffleOverlay(): void {
const overlay = document.getElementById('shuffle-overlay');
if (overlay) {
overlay.style.display = 'flex';
}
}
/**
* Hide shuffle overlay
*/
private hideShuffleOverlay(): void {
const overlay = document.getElementById('shuffle-overlay');
if (overlay) {
overlay.style.display = 'none';
}
}
/**
* Handle no-moves condition with automatic shuffle
* Shuffles up to MAX_SHUFFLE_ATTEMPTS times before game over
*/
private handleNoMoves(): void {
// Check if we've exceeded max shuffle attempts
if (this.shuffleAttempts >= this.MAX_SHUFFLE_ATTEMPTS) {
// Truly stuck - game over
this.handleGameOver(false);
return;
}
// Increment shuffle attempts
this.shuffleAttempts++;
// Show shuffle overlay
this.showShuffleOverlay();
// Wait for animation (300-500ms per CONTEXT.md)
setTimeout(() => {
// Perform shuffle
this.gridManager.shuffleTiles();
// Hide overlay after brief display
setTimeout(() => {
this.hideShuffleOverlay();
// Check if shuffle produced valid moves
const grid = this.gridManager.getAllTiles();
const hasValidMoves = NoMovesDetector.hasValidMoves(grid);
if (!hasValidMoves) {
// Still no moves - try again (recursive)
this.handleNoMoves();
}
// If valid moves exist, game continues naturally
}, 300); // Shuffle animation duration (300ms minimum)
}, 50); // Brief delay before shuffle starts
}
/**
* Update previous score display in HTML overlay
*/
private updatePreviousScoreDisplay(): void {
const previousScoreDisplay = document.getElementById('previous-score-display');
if (previousScoreDisplay) {
previousScoreDisplay.textContent = `Previous: ${this.previousScore}`;
// Show the element if there's a previous score, hide if 0
previousScoreDisplay.style.display = this.previousScore > 0 ? 'block' : 'none';
}
}
/**
* Restart the game - reset grid, score, state, and UI
*/
restart(): void {
// Store current score as previous score BEFORE reset
this.previousScore = this.score;
// Reset grid to initial state
this.gridManager.initializeGrid();
// Reset score to 0 for new game
this.score = 0;
// Reset state machine to IDLE
this.gameStateManager.reset();
// Reset shuffle attempts for new game
this.shuffleAttempts = 0;
// Hide all overlays
this.hideGameOverOverlay();
this.hideShuffleOverlay();
// Update score displays (current = 0, previous = preserved)
this.updateScoreDisplay();
this.updatePreviousScoreDisplay();
// Emit restart event for extensibility (future listeners can subscribe)
this.events.emit('game:restart', undefined as never);
}
}
-92
View File
@@ -1,92 +0,0 @@
// src/game/GameLoop.ts - requestAnimationFrame-based game loop
/**
* GameLoop provides a fixed-timestep game loop using requestAnimationFrame.
* It accumulates delta time and calls the update callback at 60fps.
*/
export class GameLoop {
private readonly tickLength: number = 1000 / 60; // 60 FPS target
private lastTick: number = 0;
private rafId: number = 0;
private running: boolean = false;
/**
* Creates a new GameLoop
* @param update - Callback function called with delta time in milliseconds
*/
constructor(private update: (deltaTime: number) => void) {}
/**
* Starts the game loop
*/
start(): void {
if (this.running) return;
this.running = true;
this.lastTick = performance.now();
this.rafId = requestAnimationFrame(this.main);
}
/**
* Stops the game loop
*/
stop(): void {
if (!this.running) return;
this.running = false;
cancelAnimationFrame(this.rafId);
this.rafId = 0;
}
/**
* Checks if the loop is currently running
*/
isRunning(): boolean {
return this.running;
}
/**
* Gets the current requestAnimationFrame ID
*/
getRafId(): number {
return this.rafId;
}
/**
* Gets the tick length in milliseconds
*/
getTickLength(): number {
return this.tickLength;
}
/**
* Main loop callback - called by requestAnimationFrame
*/
private main = (timestamp: number): void => {
if (!this.running) return;
this.rafId = requestAnimationFrame(this.main);
// Calculate how many ticks have passed
const nextTick = this.lastTick + this.tickLength;
let numTicks = 0;
if (timestamp > nextTick) {
const timeSinceTick = timestamp - this.lastTick;
numTicks = Math.floor(timeSinceTick / this.tickLength);
}
// Process accumulated ticks
this.queueUpdates(numTicks);
};
/**
* Calls update for each accumulated tick
*/
private queueUpdates(numTicks: number): void {
for (let i = 0; i < numTicks; i++) {
this.lastTick += this.tickLength;
this.update(this.tickLength);
}
}
}
-27
View File
@@ -1,27 +0,0 @@
// Main entry point for the Pikachu Match game
// This file initializes the Game class and starts the game loop
import { Game } from './game/Game';
let game: Game | null = null;
document.addEventListener('DOMContentLoaded', () => {
try {
game = new Game();
// Handle errors
game.events.on('error', (err) => {
console.error('Game error:', err);
});
// Log tick events in development (comment out in production)
game.events.on('game:tick', (_data) => {
// Uncomment for debugging: console.log('Tick:', _data.deltaTime.toFixed(2), 'ms');
});
game.start();
console.log('Game initialized - Canvas should show with background color');
} catch (err) {
console.error('Failed to initialize game:', err);
}
});
-214
View File
@@ -1,214 +0,0 @@
// src/managers/GridManager.ts - Manages 2D tile array and selection state
/**
* GridManager handles the tile grid and selection state.
* It provides methods to access tiles, manage selection, and emit events.
*/
import { Tile } from '../models/Tile';
import { TilePosition, GameEvents } from '../types';
import { TypedEventEmitter } from '../game/EventEmitter';
import { CONFIG } from '../config';
import { NoMovesDetector } from '../detection/NoMovesDetector';
export class GridManager {
private tiles: Tile[][] = [];
private selectedTiles: Tile[] = [];
public readonly events: TypedEventEmitter<GameEvents>;
constructor(events?: TypedEventEmitter<GameEvents>) {
// Allow optional events parameter for testing
this.events = events || new TypedEventEmitter<GameEvents>();
}
/**
* Initialize the grid with randomized tiles and verify solvability
* Retries up to 100 times to find a solvable board
*/
initializeGrid(): void {
const maxAttempts = 100;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// Generate random board
this.generateRandomGrid();
// Verify solvability using existing NoMovesDetector
if (NoMovesDetector.hasValidMoves(this.tiles)) {
// Solvable board found
this.events.emit('board:generated', { solvable: true, attempts: attempt });
return;
}
}
// Fallback: accept last generated board (rely on auto-shuffle to recover)
console.warn('Board generation: max attempts reached, accepting board');
this.events.emit('board:generated', { solvable: false, attempts: maxAttempts });
}
/**
* Generate a randomized grid using Fisher-Yates shuffle
* Creates 16 types x 10 pairs = 160 tiles with random arrangement
*/
private generateRandomGrid(): void {
// 1. Create flat array of tile types (16 types x 10 pairs = 160 tiles)
const types: number[] = [];
for (let type = 0; type < 16; type++) {
for (let pair = 0; pair < CONFIG.grid.pairsPerType; pair++) {
types.push(type);
}
}
// 2. Shuffle using Fisher-Yates algorithm
for (let i = types.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[types[i], types[j]] = [types[j], types[i]];
}
// 3. Place shuffled types in grid
this.tiles = [];
let typeIndex = 0;
for (let row = 0; row < CONFIG.grid.rows; row++) {
const rowTiles: Tile[] = [];
for (let col = 0; col < CONFIG.grid.cols; col++) {
const id = `tile-${row}-${col}`;
const type = types[typeIndex++];
const position: TilePosition = { row, col };
rowTiles.push(new Tile(id, type, position));
}
this.tiles.push(rowTiles);
}
}
/**
* Get tile at specific grid coordinates
* @param row - Row index (0-9)
* @param col - Column index (0-15)
* @returns Tile or null if out of bounds
*/
getTileAt(row: number, col: number): Tile | null {
if (row < 0 || row >= CONFIG.grid.rows || col < 0 || col >= CONFIG.grid.cols) {
return null;
}
return this.tiles[row][col];
}
/**
* Select or deselect a tile
* Implements toggle behavior: clicking selected tile deselects it
* Ignores cleared tiles
* Emits tilesSelected event when 2 tiles are selected
* @param tile - Tile to select/deselect
*/
selectTile(tile: Tile): void {
// Ignore cleared tiles
if (tile.cleared) {
return;
}
// Check if tile is already selected
const selectedIndex = this.selectedTiles.findIndex(t => t.id === tile.id);
if (selectedIndex !== -1) {
// Toggle deselect: remove from selection
this.selectedTiles.splice(selectedIndex, 1);
} else if (this.selectedTiles.length < 2) {
// Add to selection if less than 2 selected
this.selectedTiles.push(tile);
// Emit event when 2 tiles selected
if (this.selectedTiles.length === 2) {
this.events.emit('tilesSelected', {
tile1: this.selectedTiles[0],
tile2: this.selectedTiles[1]
});
}
}
// If 2 tiles already selected, ignore (input blocked)
}
/**
* Deselect all tiles
*/
deselectAll(): void {
this.selectedTiles = [];
}
/**
* Get currently selected tiles
* @returns Copy of selected tiles array
*/
get selectedTilesList(): Tile[] {
return [...this.selectedTiles];
}
/**
* Get all tiles in the grid
* @returns 2D array of tiles
*/
getAllTiles(): Tile[][] {
return this.tiles;
}
/**
* Clear tiles (mark as cleared and emit events)
* @param tiles - Array of tiles to clear
*/
clearTiles(tiles: Tile[]): void {
tiles.forEach(tile => {
tile.cleared = true;
this.events.emit('tile:cleared', { tile });
});
// Clear selection after clearing tiles
this.deselectAll();
}
/**
* Get the event emitter for external subscription
*/
getEvents(): TypedEventEmitter<GameEvents> {
return this.events;
}
/**
* Shuffle remaining tiles by redistributing types while preserving positions
* Clears selection and emits events for UI feedback
*/
shuffleTiles(): void {
// 1. Collect uncleared tiles and their positions
const unclearedTiles: Tile[] = [];
for (let row = 0; row < CONFIG.grid.rows; row++) {
for (let col = 0; col < CONFIG.grid.cols; col++) {
const tile = this.tiles[row][col];
if (!tile.cleared) {
unclearedTiles.push(tile);
}
}
}
const tilesRemaining = unclearedTiles.length;
// 2. Emit shuffling event before modification
this.events.emit('board:shuffling', { tilesRemaining });
// 3. Extract types from uncleared tiles
const types = unclearedTiles.map(t => t.type);
// 4. Shuffle types using Fisher-Yates
for (let i = types.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[types[i], types[j]] = [types[j], types[i]];
}
// 5. Reassign shuffled types back to tiles (positions preserved)
for (let i = 0; i < unclearedTiles.length; i++) {
unclearedTiles[i].type = types[i];
}
// 6. Clear selection to prevent stale references
this.deselectAll();
// 7. Emit shuffled event after completion
this.events.emit('board:shuffled', { tilesRemaining });
}
}
-72
View File
@@ -1,72 +0,0 @@
// src/matching/MatchEngine.ts - Match validation pipeline
/**
* MatchEngine validates tile matches using a multi-stage pipeline
* Stage 1: Type check (fast fail)
* Stage 2: Position check (same tile)
* Stage 3: Pathfinding (expensive - only if types match)
*/
import { Tile, MatchResult, GameEvents } from '../types';
import { GridManager } from '../managers/GridManager';
import { TypedEventEmitter } from '../game/EventEmitter';
import { PathFinder } from './PathFinder';
import { Scoring } from './Scoring';
export class MatchEngine {
constructor(
private gridManager: GridManager,
private events: TypedEventEmitter<GameEvents>
) {}
/**
* Validate if two tiles can be matched
* Uses fail-fast pipeline: type check → position check → pathfinding
* @param tile1 - First tile
* @param tile2 - Second tile
* @returns MatchResult with validity, reason, path, turns, and score
*/
validateMatch(tile1: Tile, tile2: Tile): MatchResult {
// Stage 1: Type check (cheap - fail fast)
if (tile1.type !== tile2.type) {
return { valid: false, reason: 'different-type' };
}
// Stage 2: Position check (prevent matching same tile)
if (tile1.id === tile2.id) {
return { valid: false, reason: 'same-tile' };
}
// Stage 3: Pathfinding (expensive - only if types match)
const grid = this.gridManager.getAllTiles();
const pathResult = PathFinder.findPath(
tile1.position,
tile2.position,
grid,
2 // max 2 turns
);
// Stage 3a: No path found
if (!pathResult) {
return { valid: false, reason: 'no-path' };
}
// Stage 3b: Path has too many turns
if (pathResult.turns > 2) {
return {
valid: false,
reason: 'too-many-turns',
turns: pathResult.turns
};
}
// Stage 4: Success - calculate score
const score = Scoring.calculate(pathResult.turns);
return {
valid: true,
path: pathResult.path,
turns: pathResult.turns,
score
};
}
}
-115
View File
@@ -1,115 +0,0 @@
// src/matching/PathFinder.ts - BFS pathfinding algorithm with turn counting
import { TilePosition, Tile, PathNode } from '../types';
import { CONFIG } from '../config';
/**
* PathFinder implements BFS pathfinding with turn constraints
* Finds paths between two tiles with maximum 2 turns (3 straight lines)
*/
export class PathFinder {
/**
* Direction encoding: 0=up, 1=right, 2=down, 3=left
* Using these deltas: row changes by -1/+1, col changes by -1/+1
*/
private static readonly DIRECTIONS = [
{ row: -1, col: 0 }, // 0: up
{ row: 0, col: 1 }, // 1: right
{ row: 1, col: 0 }, // 2: down
{ row: 0, col: -1 } // 3: left
];
/**
* Finds a valid path between two tiles with maximum turns constraint
* @param start - Starting tile position
* @param end - Ending tile position
* @param grid - 2D array of tiles
* @param maxTurns - Maximum allowed turns (default: 2)
* @returns PathNode if valid path found, null otherwise
*/
static findPath(
start: TilePosition,
end: TilePosition,
grid: Tile[][],
maxTurns: number = 2
): PathNode | null {
// Initialize BFS queue with start node
// Start with direction=-1 (no direction yet), 0 turns, path containing start position
const queue: PathNode[] = [{
row: start.row,
col: start.col,
direction: -1, // No direction yet (first move doesn't count as turn)
turns: 0,
path: [{ row: start.row, col: start.col }]
}];
// Track visited states to avoid cycles
// State key: "row,col,direction" - same position with different direction is different state
const visited = new Set<string>();
while (queue.length > 0) {
const currentNode = queue.shift()!;
const { row, col, direction, turns, path } = currentNode;
// Check if we reached the destination
if (row === end.row && col === end.col) {
return currentNode;
}
// Skip if already exceeded max turns
if (turns > maxTurns) {
continue;
}
// Try all 4 directions
for (let newDirection = 0; newDirection < 4; newDirection++) {
const newRow = row + PathFinder.DIRECTIONS[newDirection].row;
const newCol = col + PathFinder.DIRECTIONS[newDirection].col;
// Check bounds
if (newRow < 0 || newRow >= CONFIG.grid.rows ||
newCol < 0 || newCol >= CONFIG.grid.cols) {
continue;
}
// Check if passable (tile must be cleared)
const tile = grid[newRow][newCol];
if (!tile.cleared) {
continue;
}
// Calculate turn increment
// First move (direction=-1) doesn't count as turn
// Changing direction counts as turn, continuing straight doesn't
let turnIncrement = 0;
if (direction !== -1 && newDirection !== direction) {
turnIncrement = 1;
}
const newTurns = turns + turnIncrement;
// Create state key for visited tracking
const stateKey = `${newRow},${newCol},${newDirection}`;
// Skip if this state already visited
if (visited.has(stateKey)) {
continue;
}
// Mark as visited
visited.add(stateKey);
// Add to queue with extended path
queue.push({
row: newRow,
col: newCol,
direction: newDirection,
turns: newTurns,
path: [...path, { row: newRow, col: newCol }]
});
}
}
// No valid path found
return null;
}
}
-37
View File
@@ -1,37 +0,0 @@
// src/matching/Scoring.ts - Score calculation with complexity bonus
/**
* Scoring system that rewards players with complexity bonuses
* Fewer turns = higher score (harder to spot paths)
*/
export class Scoring {
/**
* Base score for any successful match
*/
private static readonly BASE_SCORE = 100;
/**
* Bonus multipliers based on path complexity (fewer turns = higher bonus)
* - 0 turns: 1.5x (50% bonus) - same row/col direct path
* - 1 turn: 1.25x (25% bonus) - one corner
* - 2 turns: 1.0x (base) - two corners (max allowed)
*/
private static readonly BONUS_MULTIPLIERS = {
0: 1.5, // 50% bonus for 0-turn (same row/col)
1: 1.25, // 25% bonus for 1-turn
2: 1.0 // No bonus for 2-turn
} as const;
/**
* Calculate score based on path complexity (number of turns)
* @param turns - Number of turns in the path (0, 1, or 2)
* @returns Integer score with complexity bonus applied
*/
static calculate(turns: number): number {
// Get multiplier for this turn count, default to base (1.0) if invalid
const multiplier = this.BONUS_MULTIPLIERS[turns as keyof typeof Scoring.BONUS_MULTIPLIERS] || 1.0;
// Calculate score and ensure integer result
return Math.floor(this.BASE_SCORE * multiplier);
}
}
-47
View File
@@ -1,47 +0,0 @@
// src/models/Tile.ts - Tile data model
/**
* Tile represents a single tile in the game grid.
* It contains position, type, and state information.
*/
import { TilePosition, Tile as TileInterface } from '../types';
import { CONFIG } from '../config';
export class Tile implements TileInterface {
/**
* Whether this tile has been cleared from the board
*/
public cleared: boolean = false;
/**
* Creates a new Tile
* @param id - Unique identifier for this tile
* @param type - Tile type (0-15 for emoji index)
* @param position - Grid position (row, col)
*/
constructor(
public readonly id: string,
public type: number, // Not readonly - needed for shuffle operation
public readonly position: TilePosition
) {}
/**
* Gets the emoji for this tile's type
*/
get emoji(): string {
return CONFIG.emojis[this.type];
}
/**
* Checks if this tile is orthogonally adjacent to another tile
* @param other - The other tile to check
* @returns true if tiles are adjacent (not diagonal)
*/
isAdjacent(other: Tile): boolean {
const rowDiff = Math.abs(this.position.row - other.position.row);
const colDiff = Math.abs(this.position.col - other.position.col);
// Orthogonally adjacent means exactly one row OR one column difference, but not both
return (rowDiff === 1 && colDiff === 0) || (rowDiff === 0 && colDiff === 1);
}
}
-572
View File
@@ -1,572 +0,0 @@
// src/rendering/Renderer.ts - Canvas rendering logic for tiles and selection highlights
/**
* Renderer handles all canvas drawing operations for the game grid.
* It draws tiles, emojis, selection highlights, and fade-in animations.
*/
import { GridManager } from '../managers/GridManager';
import { Tile } from '../models/Tile';
import { TilePosition } from '../types';
import { CONFIG } from '../config';
/**
* ShakeAnimation class for animating tile shake effects
* Used for visual feedback on failed matches
*/
class ShakeAnimation {
private startTime: number;
private readonly duration: number;
private readonly intensity: number;
private readonly pattern: 'horizontal' | 'circular';
constructor(pattern: 'horizontal' | 'circular', duration: number = 200, intensity: number = 5) {
this.startTime = 0;
this.duration = duration;
this.intensity = intensity;
this.pattern = pattern;
}
/**
* Start the shake animation
*/
start(): void {
this.startTime = performance.now();
}
/**
* Get current shake offset based on elapsed time
* @returns { x, y } offset in pixels
*/
getOffset(): { x: number; y: number } {
const elapsed = performance.now() - this.startTime;
// Animation complete - return zero offset
if (elapsed > this.duration) {
return { x: 0, y: 0 };
}
// Calculate decay (1 to 0 over duration)
const decay = 1 - (elapsed / this.duration);
// Calculate oscillation angle
const angle = elapsed * 0.05;
// Calculate offset based on pattern
if (this.pattern === 'horizontal') {
return {
x: Math.sin(angle) * this.intensity * decay,
y: 0
};
} else {
// Circular pattern
return {
x: Math.cos(angle) * this.intensity * decay,
y: Math.sin(angle) * this.intensity * decay
};
}
}
/**
* Check if animation is complete
*/
isComplete(): boolean {
return performance.now() - this.startTime > this.duration;
}
}
/**
* 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;
}
}
/**
* 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;
private canvas: HTMLCanvasElement;
private fadeAnimationStartTimes: Map<string, number> = new Map();
private readonly FADE_DURATION = 100; // ms per CONTEXT.md
private shakeAnimations: Map<string, ShakeAnimation> = new Map();
private matchAnimations: Map<string, MatchAnimation> = new Map();
private rippleAnimations: RippleAnimation[] = [];
private pathAnimation: { path: TilePosition[], startTime: number } | null = null;
private readonly PATH_DISPLAY_DURATION = 300; // ms per CONTEXT.md
private readonly MATCH_ANIMATION_DURATION = CONFIG.animation.matchDuration;
constructor(ctx: CanvasRenderingContext2D, gridManager: GridManager) {
this.ctx = ctx;
this.gridManager = gridManager;
// Create a mock canvas for size calculations
// In real usage, this would be passed from Game.ts
this.canvas = {
width: 800,
height: 600
} as HTMLCanvasElement;
}
/**
* Main render loop - draws all tiles and selection highlights
*/
render(): void {
// Clear canvas with background color
this.ctx.fillStyle = CONFIG.colors.background;
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Calculate grid dimensions
const gridWidth = CONFIG.grid.cols * (CONFIG.tile.size + CONFIG.tile.gap) + CONFIG.tile.gap;
const gridHeight = CONFIG.grid.rows * (CONFIG.tile.size + CONFIG.tile.gap) + CONFIG.tile.gap;
// Center the grid within canvas
const offsetX = (this.canvas.width - gridWidth) / 2;
const offsetY = (this.canvas.height - gridHeight) / 2;
// Draw path animation if active
this.renderPathAnimation();
// Draw ripple animations
this.rippleAnimations = this.rippleAnimations.filter(ripple =>
ripple.render(this.ctx)
);
// Get selected tiles for highlight rendering
const selectedTiles = this.gridManager.selectedTilesList;
const selectedTileIds = new Set(selectedTiles.map(t => t.id));
// Iterate all tiles and render them
for (let row = 0; row < CONFIG.grid.rows; row++) {
for (let col = 0; col < CONFIG.grid.cols; col++) {
const tile = this.gridManager.getTileAt(row, col);
if (!tile) continue;
// Skip cleared tiles
if (tile.cleared) {
continue;
}
// Draw the tile
this.renderTile(this.ctx, tile, offsetX, offsetY);
// Draw selection highlight if tile is selected
if (selectedTileIds.has(tile.id)) {
this.renderSelection(this.ctx, tile, offsetX, offsetY);
}
}
}
}
/**
* Draw a single tile with background and emoji
* @param ctx - Canvas rendering context
* @param tile - Tile to render
* @param offsetX - Grid offset X (for centering)
* @param offsetY - Grid offset Y (for centering)
*/
private renderTile(
ctx: CanvasRenderingContext2D,
tile: Tile,
offsetX: number,
offsetY: number
): void {
// Get shake offset if animation is active
const shakeOffset = this.getShakeOffset(tile);
// Calculate tile position
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;
// Calculate tile center for match animation scaling
const centerX = x + CONFIG.tile.size / 2;
const centerY = y + CONFIG.tile.size / 2;
// Check for match animation
const matchAnimation = this.matchAnimations.get(tile.id);
// Save context before applying transforms
ctx.save();
// Apply shake offset
ctx.translate(shakeOffset.x, shakeOffset.y);
// Apply match animation transforms if active
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);
}
}
// Draw rounded rectangle background
this.drawRoundedRect(ctx, x, y, CONFIG.tile.size, CONFIG.tile.size, CONFIG.tile.cornerRadius);
ctx.fillStyle = CONFIG.colors.tile;
ctx.fill();
// Draw emoji centered in tile
ctx.font = '32px sans-serif';
ctx.fillStyle = CONFIG.colors.text;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(tile.emoji, x + CONFIG.tile.size / 2, y + CONFIG.tile.size / 2);
// Restore context
ctx.restore();
}
/**
* Draw selection highlight with fade-in animation
* @param ctx - Canvas rendering context
* @param tile - Tile to highlight
* @param offsetX - Grid offset X (for centering)
* @param offsetY - Grid offset Y (for centering)
*/
private renderSelection(
ctx: CanvasRenderingContext2D,
tile: Tile,
offsetX: number,
offsetY: number
): void {
// Calculate tile position
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;
// Get or create fade animation start time
let startTime = this.fadeAnimationStartTimes.get(tile.id);
if (!startTime) {
startTime = performance.now();
this.fadeAnimationStartTimes.set(tile.id, startTime);
}
// Calculate fade progress
const elapsed = performance.now() - startTime;
const progress = Math.min(elapsed / this.FADE_DURATION, 1);
const alpha = 0.3 * progress; // 30% max opacity per CONTEXT.md
// Draw selection border
ctx.strokeStyle = CONFIG.colors.selection;
ctx.lineWidth = 3;
ctx.strokeRect(x, y, CONFIG.tile.size, CONFIG.tile.size);
// Draw background tint with fade-in
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = CONFIG.colors.selection;
ctx.fillRect(x, y, CONFIG.tile.size, CONFIG.tile.size);
ctx.restore();
}
/**
* Draw a rounded rectangle
* @param ctx - Canvas rendering context
* @param x - X position
* @param y - Y position
* @param width - Rectangle width
* @param height - Rectangle height
* @param radius - Corner radius
*/
private drawRoundedRect(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number
): void {
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
ctx.lineTo(x + width, y + height - radius);
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
ctx.lineTo(x + radius, y + height);
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
}
/**
* Get the current alpha value for selection fade-in animation
* Exposed for testing purposes
* @param tile - Tile to get alpha for
* @param elapsedMs - Elapsed time since selection started
* @returns Alpha value (0-0.3)
*/
getSelectionAlpha(tile: Tile, elapsedMs: number): number {
const progress = Math.min(elapsedMs / this.FADE_DURATION, 1);
return 0.3 * progress;
}
/**
* Reset fade animation for a tile (when deselected)
* @param tileId - ID of tile to reset animation for
*/
resetFadeAnimation(tileId: string): void {
this.fadeAnimationStartTimes.delete(tileId);
}
/**
* Update the canvas reference (for resize handling)
* @param canvas - New canvas element
*/
setCanvas(canvas: HTMLCanvasElement): void {
this.canvas = canvas;
}
/**
* Start shake animation for specified tiles
* @param tiles - Tiles to shake
* @param reason - Failure reason ('too-many-turns' → circular, else → horizontal)
*/
animateShake(tiles: Tile[], reason: string): void {
const pattern = reason === 'too-many-turns' ? 'circular' : 'horizontal';
for (const tile of tiles) {
const animation = new ShakeAnimation(pattern);
animation.start();
this.shakeAnimations.set(tile.id, animation);
}
}
/**
* 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);
}
}
/**
* 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));
}
/**
* Get shake offset for a tile if it has an active animation
* @param tile - Tile to get offset for
* @returns { x, y } offset in pixels (0, 0 if no animation)
*/
private getShakeOffset(tile: Tile): { x: number; y: number } {
const animation = this.shakeAnimations.get(tile.id);
if (!animation) {
return { x: 0, y: 0 };
}
const offset = animation.getOffset();
// Clean up completed animations
if (animation.isComplete()) {
this.shakeAnimations.delete(tile.id);
}
return offset;
}
/**
* Start path drawing animation
* @param path - Array of tile positions to draw connection line through
*/
drawPath(path: TilePosition[]): void {
this.pathAnimation = {
path,
startTime: performance.now()
};
}
/**
* Render path animation if active
*/
private renderPathAnimation(): void {
if (!this.pathAnimation) {
return;
}
const elapsed = performance.now() - this.pathAnimation.startTime;
// Check if animation is complete
if (elapsed >= this.PATH_DISPLAY_DURATION) {
this.pathAnimation = null;
return;
}
// Draw the path
this.drawPathLine(this.pathAnimation.path);
}
/**
* Draw connection line through tile centers
* @param path - Array of tile positions to connect
*/
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;
// 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;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
// Begin path
this.ctx.beginPath();
// Move to first point center
const firstPoint = path[0];
const firstX = offsetX + firstPoint.col * (size + gap) + gap + size / 2;
const firstY = offsetY + firstPoint.row * (size + gap) + gap + size / 2;
this.ctx.moveTo(firstX, firstY);
// Line to each subsequent point center
for (let i = 1; i < path.length; i++) {
const point = path[i];
const x = offsetX + point.col * (size + gap) + gap + size / 2;
const y = offsetY + point.row * (size + gap) + gap + size / 2;
this.ctx.lineTo(x, y);
}
// Stroke path
this.ctx.stroke();
// Restore context state after drawing
this.ctx.restore();
}
}
-98
View File
@@ -1,98 +0,0 @@
// src/state/GameStateManager.ts - Game state machine with transition validation
import { TypedEventEmitter } from '../game/EventEmitter';
import type { GameEvents } from '../types';
/**
* Represents a game state in the state machine
* String enum for better debugging and logging
*/
export enum GameState {
/** Waiting for player input */
IDLE = 'IDLE',
/** One tile selected, waiting for second tile */
SELECTING = 'SELECTING',
/** Processing match, input blocked */
MATCHING = 'MATCHING',
/** Game ended (win or no moves) */
GAME_OVER = 'GAME_OVER',
}
/**
* Manages game state transitions with validation and event emission
* Enforces valid state transitions and provides helper methods for state-based logic
*/
export class GameStateManager {
private currentState: GameState;
private readonly events: TypedEventEmitter<GameEvents>;
/**
* Valid state transitions
* Maps each state to the list of states it can transition to
*/
private readonly validTransitions: Record<GameState, GameState[]> = {
[GameState.IDLE]: [GameState.SELECTING],
[GameState.SELECTING]: [GameState.IDLE, GameState.MATCHING],
[GameState.MATCHING]: [GameState.IDLE, GameState.GAME_OVER],
[GameState.GAME_OVER]: [GameState.IDLE], // restart only
};
constructor(events: TypedEventEmitter<GameEvents>) {
this.events = events;
this.currentState = GameState.IDLE;
}
/**
* Transition to a new state if valid
* @param newState - The state to transition to
* @returns true if transition succeeded, false if invalid
*/
transitionTo(newState: GameState): boolean {
// Validate transition
const allowedStates = this.validTransitions[this.currentState];
if (!allowedStates.includes(newState)) {
return false;
}
// Perform transition
const previousState = this.currentState;
this.currentState = newState;
// Emit event for other components to react
this.events.emit('game:stateChange', {
from: previousState,
to: newState
});
return true;
}
/**
* Get the current game state
* @returns The current GameState
*/
getState(): GameState {
return this.currentState;
}
/**
* Check if tiles can be selected in the current state
* @returns true if selection is allowed, false otherwise
*/
canSelectTile(): boolean {
return this.currentState === GameState.IDLE || this.currentState === GameState.SELECTING;
}
/**
* Reset the state machine to IDLE (for restart functionality)
* Emits a state change event from current state to IDLE
*/
reset(): void {
const previousState = this.currentState;
this.currentState = GameState.IDLE;
this.events.emit('game:stateChange', {
from: previousState,
to: GameState.IDLE
});
}
}
-100
View File
@@ -1,100 +0,0 @@
// src/types/index.ts - Shared type definitions
// This file contains all TypeScript interfaces and types used throughout the game.
/**
* Represents a position in the grid
*/
export interface TilePosition {
row: number;
col: number;
}
/**
* Represents a single tile in the game grid
*/
export interface Tile {
id: string;
type: number; // 0-15 for emoji index
position: TilePosition;
cleared: boolean;
}
/**
* Represents a node in the BFS pathfinding algorithm
* Tracks position, direction, turn count, and path history
*/
export interface PathNode {
/** Current row in grid */
row: number;
/** Current column in grid */
col: number;
/** Direction of movement (-1=none/start, 0=up, 1=right, 2=down, 3=left) */
direction: number;
/** Number of direction changes (turns) taken to reach this node */
turns: number;
/** Path taken to reach this node (including start and current position) */
path: TilePosition[];
}
/**
* Represents the result of a match validation
* Provides feedback on whether tiles can be matched and why/why not
*/
export interface MatchResult {
/** Whether the match is valid */
valid: boolean;
/** Reason for invalidity (e.g., "wrong type", "path too long", "no path") */
reason?: string;
/** Path connecting the tiles (if valid) */
path?: TilePosition[];
/** Number of turns in the path (if valid) */
turns?: number;
}
/**
* Represents a game state in the state machine
* String enum for better debugging and logging
*/
export enum GameState {
/** Waiting for player input */
IDLE = 'IDLE',
/** One tile selected, waiting for second tile */
SELECTING = 'SELECTING',
/** Processing match, input blocked */
MATCHING = 'MATCHING',
/** Game ended (win or no moves) */
GAME_OVER = 'GAME_OVER',
}
/**
* Represents a state transition event
* Emitted when game state changes
*/
export interface StateChangeEvent {
/** Previous state */
from: GameState;
/** New state */
to: GameState;
}
/**
* Maps event names to their payload types
* Used for type-safe event emission and handling
*/
export interface GameEvents {
'game:start': void;
'game:restart': void;
'game:tick': { deltaTime: number };
'game:stateChange': StateChangeEvent;
'tilesSelected': { tile1: Tile; tile2: Tile };
'tile:selected': { tile: Tile; row: number; col: number };
'tile:cleared': { tile: Tile };
'game:score': { points: number };
'game:over': { won: boolean };
'error': Error;
'tilesMatched': { tile1: Tile; tile2: Tile; path: TilePosition[]; turns: number; score: number };
'matchFailed': { tile1: Tile; tile2: Tile; reason: string };
'board:generated': { solvable: boolean; attempts: number };
'board:shuffling': { tilesRemaining: number };
'board:shuffled': { tilesRemaining: number };
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />