mirror of
https://github.com/tiennm99/gsd-framework.git
synced 2026-08-08 22:25:02 +00:00
chore: reset
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user