From 0c0635c93b49ca82ff8b40b4654c466676ffeee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B5=D0=BD=D0=B8=D1=81=20=D0=A8=D0=BA=D0=B0=D0=B1?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=80?= Date: Thu, 12 Feb 2026 13:09:01 +0300 Subject: [PATCH] feat: player entity with WASD movement, tile collision, camera follow (Phase 4.1) Player spawns at walkable tile near map center. WASD controls movement (150px/s, normalized diagonal). Tile collision with wall-sliding prevents walking through acid pools, crystals, geysers. Camera follows player with smooth lerp. 39 new tests (134 total). Co-authored-by: Cursor --- src/ecs/bridge.ts | 5 + src/ecs/components.ts | 5 + src/player/collision.ts | 128 +++++++++++++ src/player/factory.ts | 31 +++ src/player/input.ts | 48 +++++ src/player/spawn.ts | 51 +++++ src/player/types.ts | 33 ++++ src/scenes/BootScene.ts | 2 +- src/scenes/GameScene.ts | 96 ++++++++-- src/world/camera.ts | 26 +++ tests/player.test.ts | 409 ++++++++++++++++++++++++++++++++++++++++ 11 files changed, 820 insertions(+), 14 deletions(-) create mode 100644 src/player/collision.ts create mode 100644 src/player/factory.ts create mode 100644 src/player/input.ts create mode 100644 src/player/spawn.ts create mode 100644 src/player/types.ts create mode 100644 tests/player.test.ts diff --git a/src/ecs/bridge.ts b/src/ecs/bridge.ts index d4318ef..b8bd506 100644 --- a/src/ecs/bridge.ts +++ b/src/ecs/bridge.ts @@ -65,6 +65,11 @@ export class PhaserBridge { } } + /** Get the Phaser sprite for an entity (undefined if not yet rendered) */ + getSprite(eid: number): Phaser.GameObjects.Arc | undefined { + return this.spriteMap.get(eid); + } + /** Current number of rendered entities */ get entityCount(): number { return this.spriteMap.size; diff --git a/src/ecs/components.ts b/src/ecs/components.ts index f88d390..37b09ad 100644 --- a/src/ecs/components.ts +++ b/src/ecs/components.ts @@ -34,3 +34,8 @@ export const Health = { export const ChemicalComposition = { primaryElement: [] as number[], // atomic number (e.g. 11 for Na) }; + +/** Tag component — marks entity as the player (no data, identity only) */ +export const PlayerTag = { + _tag: [] as number[], +}; diff --git a/src/player/collision.ts b/src/player/collision.ts new file mode 100644 index 0000000..62b6238 --- /dev/null +++ b/src/player/collision.ts @@ -0,0 +1,128 @@ +/** + * Tile Collision System + * + * Checks player position against tile grid after movement. + * Supports wall-sliding: if full movement is blocked, tries + * X-only or Y-only to let player slide along walls. + */ + +import { query } from 'bitecs'; +import type { World } from '../ecs/world'; +import { Position, Velocity, PlayerTag } from '../ecs/components'; +import type { TileGrid } from '../world/types'; +import { PLAYER_COLLISION_RADIUS } from './types'; + +/** + * Check if a pixel position falls on a walkable tile. + * Returns false for out-of-bounds positions. + */ +export function isTileWalkable( + px: number, + py: number, + grid: TileGrid, + tileSize: number, + walkable: Set, +): boolean { + const tx = Math.floor(px / tileSize); + const ty = Math.floor(py / tileSize); + if (ty < 0 || ty >= grid.length || tx < 0 || tx >= (grid[0]?.length ?? 0)) { + return false; + } + return walkable.has(grid[ty][tx]); +} + +/** + * Check if a circular area (4 corners of bounding box) is on walkable tiles. + * Uses AABB corners, not true circle — good enough for small radii. + */ +export function isAreaWalkable( + x: number, + y: number, + radius: number, + grid: TileGrid, + tileSize: number, + walkable: Set, +): boolean { + return ( + isTileWalkable(x - radius, y - radius, grid, tileSize, walkable) && + isTileWalkable(x + radius, y - radius, grid, tileSize, walkable) && + isTileWalkable(x - radius, y + radius, grid, tileSize, walkable) && + isTileWalkable(x + radius, y + radius, grid, tileSize, walkable) + ); +} + +/** + * Resolve position after movement with wall-sliding. + * + * Priority: full move → X-slide → Y-slide → stay put. + * This gives natural wall-sliding behavior. + */ +export function resolveCollision( + newX: number, + newY: number, + prevX: number, + prevY: number, + radius: number, + grid: TileGrid, + tileSize: number, + walkable: Set, +): { x: number; y: number } { + // Full movement valid + if (isAreaWalkable(newX, newY, radius, grid, tileSize, walkable)) { + return { x: newX, y: newY }; + } + // X-only: slide along Y wall + if (isAreaWalkable(newX, prevY, radius, grid, tileSize, walkable)) { + return { x: newX, y: prevY }; + } + // Y-only: slide along X wall + if (isAreaWalkable(prevX, newY, radius, grid, tileSize, walkable)) { + return { x: prevX, y: newY }; + } + // Can't move at all + return { x: prevX, y: prevY }; +} + +/** + * Build a Set of walkable tile IDs from tile data. + * Used to quickly check if a tile allows movement. + */ +export function buildWalkableSet( + tiles: ReadonlyArray<{ id: number; walkable: boolean }>, +): Set { + const set = new Set(); + for (const tile of tiles) { + if (tile.walkable) set.add(tile.id); + } + return set; +} + +/** + * ECS system: resolve tile collisions for player entities. + * + * Runs AFTER movementSystem. Reconstructs pre-movement position + * from current position and velocity, then resolves collision. + */ +export function tileCollisionSystem( + world: World, + deltaMs: number, + grid: TileGrid, + tileSize: number, + walkable: Set, +): void { + const dt = deltaMs / 1000; + for (const eid of query(world, [Position, Velocity, PlayerTag])) { + const curX = Position.x[eid]; + const curY = Position.y[eid]; + // Reconstruct pre-movement position (movementSystem did: pos += vel * dt) + const prevX = curX - Velocity.vx[eid] * dt; + const prevY = curY - Velocity.vy[eid] * dt; + + const resolved = resolveCollision( + curX, curY, prevX, prevY, + PLAYER_COLLISION_RADIUS, grid, tileSize, walkable, + ); + Position.x[eid] = resolved.x; + Position.y[eid] = resolved.y; + } +} diff --git a/src/player/factory.ts b/src/player/factory.ts new file mode 100644 index 0000000..2c511dd --- /dev/null +++ b/src/player/factory.ts @@ -0,0 +1,31 @@ +/** + * Player Entity Factory + * + * Creates the player entity with all required components: + * Position, Velocity, SpriteRef, Health, PlayerTag. + */ + +import { addComponent } from 'bitecs'; +import type { World } from '../ecs/world'; +import { PlayerTag } from '../ecs/components'; +import { createGameEntity } from '../ecs/factory'; +import { PLAYER_COLOR, PLAYER_RADIUS, PLAYER_HEALTH } from './types'; + +/** + * Create the player entity at the given position. + * @returns entity ID (eid) + */ +export function createPlayerEntity( + world: World, + x: number, + y: number, +): number { + const eid = createGameEntity(world, { + position: { x, y }, + velocity: { vx: 0, vy: 0 }, + health: { current: PLAYER_HEALTH, max: PLAYER_HEALTH }, + sprite: { color: PLAYER_COLOR, radius: PLAYER_RADIUS }, + }); + addComponent(world, eid, PlayerTag); + return eid; +} diff --git a/src/player/input.ts b/src/player/input.ts new file mode 100644 index 0000000..d7aec22 --- /dev/null +++ b/src/player/input.ts @@ -0,0 +1,48 @@ +/** + * Player Input System + * + * Reads InputState → sets player entity Velocity. + * Diagonal movement is normalized to prevent faster diagonal speed. + */ + +import { query } from 'bitecs'; +import type { World } from '../ecs/world'; +import { Velocity, PlayerTag } from '../ecs/components'; +import type { InputState } from './types'; +import { PLAYER_SPEED } from './types'; + +/** + * Calculate velocity vector from input state (pure function). + * Diagonal movement is normalized so magnitude = speed. + */ +export function calculatePlayerVelocity( + input: InputState, + speed: number, +): { vx: number; vy: number } { + const { moveX, moveY } = input; + + if (moveX === 0 && moveY === 0) { + return { vx: 0, vy: 0 }; + } + + // Normalize diagonal so total speed stays constant + const isDiagonal = moveX !== 0 && moveY !== 0; + const factor = isDiagonal ? 1 / Math.SQRT2 : 1; + + return { + vx: moveX * speed * factor, + vy: moveY * speed * factor, + }; +} + +/** + * ECS system: set player entity velocity from input. + * Only affects entities with PlayerTag + Velocity. + */ +export function playerInputSystem(world: World, input: InputState): void { + const vel = calculatePlayerVelocity(input, PLAYER_SPEED); + for (const eid of query(world, [Velocity, PlayerTag])) { + Velocity.vx[eid] = vel.vx; + Velocity.vy[eid] = vel.vy; + } +} diff --git a/src/player/spawn.ts b/src/player/spawn.ts new file mode 100644 index 0000000..b4b21a2 --- /dev/null +++ b/src/player/spawn.ts @@ -0,0 +1,51 @@ +/** + * Player Spawn Position + * + * Finds a walkable tile near the center of the map. + * Spirals outward from center to find the nearest valid spawn. + */ + +import type { TileGrid } from '../world/types'; + +/** + * Find a walkable spawn position, starting from map center. + * Returns pixel coordinates (center of tile) or null if no walkable tile exists. + */ +export function findSpawnPosition( + grid: TileGrid, + tileSize: number, + walkable: Set, +): { x: number; y: number } | null { + const height = grid.length; + if (height === 0) return null; + const width = grid[0].length; + if (width === 0) return null; + + const centerY = Math.floor(height / 2); + const centerX = Math.floor(width / 2); + + // Spiral outward from center + const maxRadius = Math.max(width, height); + for (let r = 0; r <= maxRadius; r++) { + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + // Only check perimeter of ring (interior was checked in smaller radius) + if (r > 0 && Math.abs(dx) < r && Math.abs(dy) < r) continue; + + const ty = centerY + dy; + const tx = centerX + dx; + + if (ty >= 0 && ty < height && tx >= 0 && tx < width) { + if (walkable.has(grid[ty][tx])) { + return { + x: tx * tileSize + tileSize / 2, + y: ty * tileSize + tileSize / 2, + }; + } + } + } + } + } + + return null; +} diff --git a/src/player/types.ts b/src/player/types.ts new file mode 100644 index 0000000..49f21f9 --- /dev/null +++ b/src/player/types.ts @@ -0,0 +1,33 @@ +/** + * Player Types & Constants + * + * InputState represents the current frame's player input. + * Constants define player attributes (speed, size, health, color). + */ + +/** Keyboard/gamepad state for current frame */ +export interface InputState { + /** Horizontal axis: -1 (left), 0 (none), +1 (right) */ + moveX: number; + /** Vertical axis: -1 (up), 0 (none), +1 (down) */ + moveY: number; + /** Interact key (E) pressed */ + interact: boolean; +} + +// === Player Constants === + +/** Movement speed in pixels per second */ +export const PLAYER_SPEED = 150; + +/** Collision radius in pixels (smaller than visual for forgiving feel) */ +export const PLAYER_COLLISION_RADIUS = 6; + +/** Starting and maximum health */ +export const PLAYER_HEALTH = 100; + +/** Visual color (bright cyan — stands out on dark biome) */ +export const PLAYER_COLOR = 0x00e5ff; + +/** Visual radius in pixels */ +export const PLAYER_RADIUS = 10; diff --git a/src/scenes/BootScene.ts b/src/scenes/BootScene.ts index 35336d9..3e59f18 100644 --- a/src/scenes/BootScene.ts +++ b/src/scenes/BootScene.ts @@ -30,7 +30,7 @@ export class BootScene extends Phaser.Scene { // Version this.add - .text(cx, cy + 80, 'v0.2.0 — Phase 2: ECS Foundation', { + .text(cx, cy + 80, 'v0.4.0 — Phase 4: Player Systems', { fontSize: '12px', color: '#333333', fontFamily: 'monospace', diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 6ffaf6a..5d1dc95 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -1,5 +1,6 @@ import Phaser from 'phaser'; import { createGameWorld, updateTime, type GameWorld } from '../ecs/world'; +import { Position } from '../ecs/components'; import { movementSystem } from '../ecs/systems/movement'; import { healthSystem } from '../ecs/systems/health'; import { removeGameEntity } from '../ecs/factory'; @@ -8,23 +9,40 @@ import biomeDataArray from '../data/biomes.json'; import type { BiomeData } from '../world/types'; import { generateWorld } from '../world/generator'; import { createWorldTilemap } from '../world/tilemap'; -import { setupCamera, updateCamera, type CameraKeys } from '../world/camera'; +import { setupPlayerCamera } from '../world/camera'; import { Minimap } from '../world/minimap'; +import { playerInputSystem } from '../player/input'; +import { tileCollisionSystem, buildWalkableSet } from '../player/collision'; +import { findSpawnPosition } from '../player/spawn'; +import { createPlayerEntity } from '../player/factory'; +import type { InputState } from '../player/types'; export class GameScene extends Phaser.Scene { private gameWorld!: GameWorld; private bridge!: PhaserBridge; - private cameraKeys!: CameraKeys; private minimap!: Minimap; private statsText!: Phaser.GameObjects.Text; private worldSeed!: number; + // Player state + private playerEid!: number; + private walkableSet!: Set; + private worldGrid!: number[][]; + private tileSize!: number; + private keys!: { + W: Phaser.Input.Keyboard.Key; + A: Phaser.Input.Keyboard.Key; + S: Phaser.Input.Keyboard.Key; + D: Phaser.Input.Keyboard.Key; + E: Phaser.Input.Keyboard.Key; + }; + constructor() { super({ key: 'GameScene' }); } create(): void { - // 1. Initialize ECS (needed for future entity systems) + // 1. Initialize ECS this.gameWorld = createGameWorld(); this.bridge = new PhaserBridge(this); @@ -36,15 +54,45 @@ export class GameScene extends Phaser.Scene { // 3. Create tilemap createWorldTilemap(this, worldData); - // 4. Camera with bounds and WASD controls + // 4. Build walkable set + store world data for collision + this.walkableSet = buildWalkableSet(biome.tiles); + this.worldGrid = worldData.grid; + this.tileSize = biome.tileSize; + + // 5. Create player at spawn position + const spawn = findSpawnPosition(worldData.grid, biome.tileSize, this.walkableSet); + const spawnX = spawn?.x ?? (biome.mapWidth * biome.tileSize) / 2; + const spawnY = spawn?.y ?? (biome.mapHeight * biome.tileSize) / 2; + this.playerEid = createPlayerEntity(this.gameWorld.world, spawnX, spawnY); + + // 6. Camera — follow player, zoom via scroll wheel const worldPixelW = biome.mapWidth * biome.tileSize; const worldPixelH = biome.mapHeight * biome.tileSize; - this.cameraKeys = setupCamera(this, worldPixelW, worldPixelH); + setupPlayerCamera(this, worldPixelW, worldPixelH); - // 5. Minimap + // Sync bridge to create player sprite, then attach camera follow + this.bridge.sync(this.gameWorld.world); + const playerSprite = this.bridge.getSprite(this.playerEid); + if (playerSprite) { + playerSprite.setDepth(10); + this.cameras.main.startFollow(playerSprite, true, 0.1, 0.1); + } + + // 7. Keyboard input + const keyboard = this.input.keyboard; + if (!keyboard) throw new Error('Keyboard plugin not available'); + this.keys = { + W: keyboard.addKey('W'), + A: keyboard.addKey('A'), + S: keyboard.addKey('S'), + D: keyboard.addKey('D'), + E: keyboard.addKey('E'), + }; + + // 8. Minimap this.minimap = new Minimap(this, worldData); - // 6. UI overlay + // 9. UI stats overlay this.statsText = this.add.text(10, 10, '', { fontSize: '12px', color: '#00ff88', @@ -60,24 +108,46 @@ export class GameScene extends Phaser.Scene { // 1. Update world time updateTime(this.gameWorld, delta); - // 2. Camera movement - updateCamera(this, this.cameraKeys, delta); + // 2. Read keyboard → InputState + const input: InputState = { + moveX: (this.keys.D.isDown ? 1 : 0) - (this.keys.A.isDown ? 1 : 0), + moveY: (this.keys.S.isDown ? 1 : 0) - (this.keys.W.isDown ? 1 : 0), + interact: this.keys.E.isDown, + }; - // 3. ECS systems (no entities yet — future phases will add player, creatures) + // 3. Player input → velocity + playerInputSystem(this.gameWorld.world, input); + + // 4. Movement (all entities) movementSystem(this.gameWorld.world, delta); + + // 5. Tile collision (player only) + tileCollisionSystem( + this.gameWorld.world, + delta, + this.worldGrid, + this.tileSize, + this.walkableSet, + ); + + // 6. Health / death const dead = healthSystem(this.gameWorld.world); for (const eid of dead) { removeGameEntity(this.gameWorld.world, eid); } + + // 7. Render sync this.bridge.sync(this.gameWorld.world); - // 4. Minimap viewport + // 8. Minimap viewport this.minimap.update(this.cameras.main); - // 5. Stats + // 9. Stats const fps = delta > 0 ? Math.round(1000 / delta) : 0; + const px = Math.round(Position.x[this.playerEid]); + const py = Math.round(Position.y[this.playerEid]); this.statsText.setText( - `seed: ${this.worldSeed} | ${fps} fps | WASD move, scroll zoom`, + `seed: ${this.worldSeed} | ${fps} fps | pos: ${px},${py} | WASD move, E interact, scroll zoom`, ); } } diff --git a/src/world/camera.ts b/src/world/camera.ts index 0c0baa4..79ee578 100644 --- a/src/world/camera.ts +++ b/src/world/camera.ts @@ -67,3 +67,29 @@ export function updateCamera( if (keys.up.isDown) camera.scrollY -= speed * dt; if (keys.down.isDown) camera.scrollY += speed * dt; } + +/** + * Set up camera for player-follow mode. + * No WASD movement — camera follows the player entity. + * Zoom via mouse wheel, bounds clamped to world size. + */ +export function setupPlayerCamera( + scene: Phaser.Scene, + worldPixelWidth: number, + worldPixelHeight: number, +): void { + const camera = scene.cameras.main; + camera.setBounds(0, 0, worldPixelWidth, worldPixelHeight); + camera.setZoom(2); // closer view for gameplay + + // Mouse wheel zoom (0.5x – 3x) + scene.input.on('wheel', ( + _pointer: unknown, + _gameObjects: unknown, + _deltaX: number, + deltaY: number, + ) => { + const newZoom = Phaser.Math.Clamp(camera.zoom - deltaY * 0.001, 0.5, 3); + camera.setZoom(newZoom); + }); +} diff --git a/tests/player.test.ts b/tests/player.test.ts new file mode 100644 index 0000000..590d8eb --- /dev/null +++ b/tests/player.test.ts @@ -0,0 +1,409 @@ +/** + * Player Systems Tests — Phase 4.1 + * + * Tests pure logic functions: input → velocity, tile collision, + * spawn position finding, and player entity creation. + */ + +import { describe, it, expect } from 'vitest'; +import { createWorld, addEntity, addComponent, query } from 'bitecs'; +import { Position, Velocity, PlayerTag, Health, SpriteRef } from '../src/ecs/components'; +import { calculatePlayerVelocity, playerInputSystem } from '../src/player/input'; +import { PLAYER_SPEED, PLAYER_COLLISION_RADIUS } from '../src/player/types'; +import { + isTileWalkable, + isAreaWalkable, + resolveCollision, + buildWalkableSet, + tileCollisionSystem, +} from '../src/player/collision'; +import { findSpawnPosition } from '../src/player/spawn'; +import { createPlayerEntity } from '../src/player/factory'; +import type { InputState } from '../src/player/types'; + +// === Helpers === + +function makeInput(moveX = 0, moveY = 0, interact = false): InputState { + return { moveX, moveY, interact }; +} + +// === Input: calculatePlayerVelocity === + +describe('calculatePlayerVelocity', () => { + it('moves right when moveX = 1', () => { + const v = calculatePlayerVelocity(makeInput(1, 0), PLAYER_SPEED); + expect(v.vx).toBe(PLAYER_SPEED); + expect(v.vy).toBe(0); + }); + + it('moves left when moveX = -1', () => { + const v = calculatePlayerVelocity(makeInput(-1, 0), PLAYER_SPEED); + expect(v.vx).toBe(-PLAYER_SPEED); + expect(v.vy).toBe(0); + }); + + it('moves down when moveY = 1', () => { + const v = calculatePlayerVelocity(makeInput(0, 1), PLAYER_SPEED); + expect(v.vx).toBe(0); + expect(v.vy).toBe(PLAYER_SPEED); + }); + + it('moves up when moveY = -1', () => { + const v = calculatePlayerVelocity(makeInput(0, -1), PLAYER_SPEED); + expect(v.vx).toBe(0); + expect(v.vy).toBe(-PLAYER_SPEED); + }); + + it('stops when no input', () => { + const v = calculatePlayerVelocity(makeInput(0, 0), PLAYER_SPEED); + expect(v.vx).toBe(0); + expect(v.vy).toBe(0); + }); + + it('normalizes diagonal movement to same speed', () => { + const v = calculatePlayerVelocity(makeInput(1, 1), PLAYER_SPEED); + const mag = Math.sqrt(v.vx * v.vx + v.vy * v.vy); + expect(mag).toBeCloseTo(PLAYER_SPEED); + }); + + it('normalizes all four diagonal directions', () => { + for (const [mx, my] of [[-1, -1], [-1, 1], [1, -1], [1, 1]]) { + const v = calculatePlayerVelocity(makeInput(mx, my), PLAYER_SPEED); + const mag = Math.sqrt(v.vx * v.vx + v.vy * v.vy); + expect(mag).toBeCloseTo(PLAYER_SPEED); + } + }); + + it('diagonal components are equal in magnitude', () => { + const v = calculatePlayerVelocity(makeInput(1, -1), PLAYER_SPEED); + expect(Math.abs(v.vx)).toBeCloseTo(Math.abs(v.vy)); + }); +}); + +// === Input: playerInputSystem === + +describe('playerInputSystem', () => { + it('sets player velocity from input', () => { + const world = createWorld(); + const eid = addEntity(world); + addComponent(world, eid, Position); + addComponent(world, eid, Velocity); + addComponent(world, eid, PlayerTag); + + playerInputSystem(world, makeInput(1, 0)); + + expect(Velocity.vx[eid]).toBe(PLAYER_SPEED); + expect(Velocity.vy[eid]).toBe(0); + }); + + it('does not affect non-player entities', () => { + const world = createWorld(); + + // Player + const player = addEntity(world); + addComponent(world, player, Position); + addComponent(world, player, Velocity); + addComponent(world, player, PlayerTag); + + // Non-player with existing velocity + const other = addEntity(world); + addComponent(world, other, Position); + addComponent(world, other, Velocity); + Velocity.vx[other] = 50; + Velocity.vy[other] = -30; + + playerInputSystem(world, makeInput(0, 1)); + + expect(Velocity.vx[player]).toBe(0); + expect(Velocity.vy[player]).toBe(PLAYER_SPEED); + // Non-player unchanged + expect(Velocity.vx[other]).toBe(50); + expect(Velocity.vy[other]).toBe(-30); + }); + + it('stops player when input is zero', () => { + const world = createWorld(); + const eid = addEntity(world); + addComponent(world, eid, Position); + addComponent(world, eid, Velocity); + addComponent(world, eid, PlayerTag); + Velocity.vx[eid] = 999; + Velocity.vy[eid] = 999; + + playerInputSystem(world, makeInput(0, 0)); + + expect(Velocity.vx[eid]).toBe(0); + expect(Velocity.vy[eid]).toBe(0); + }); +}); + +// === Collision: isTileWalkable === + +describe('isTileWalkable', () => { + const grid = [[0, 0, 0], [0, 3, 0], [0, 0, 0]]; + const walkable = new Set([0]); + const ts = 32; + + it('returns true for walkable tile', () => { + expect(isTileWalkable(16, 16, grid, ts, walkable)).toBe(true); + }); + + it('returns false for non-walkable tile', () => { + expect(isTileWalkable(48, 48, grid, ts, walkable)).toBe(false); + }); + + it('returns false for negative coordinates', () => { + expect(isTileWalkable(-1, 16, grid, ts, walkable)).toBe(false); + expect(isTileWalkable(16, -1, grid, ts, walkable)).toBe(false); + }); + + it('returns false for out-of-bounds coordinates', () => { + expect(isTileWalkable(96, 16, grid, ts, walkable)).toBe(false); + expect(isTileWalkable(16, 96, grid, ts, walkable)).toBe(false); + }); + + it('handles tile edge boundaries', () => { + expect(isTileWalkable(31.9, 16, grid, ts, walkable)).toBe(true); // still tile 0 + expect(isTileWalkable(32, 16, grid, ts, walkable)).toBe(true); // tile 1 column, row 0 = walkable + }); +}); + +// === Collision: isAreaWalkable === + +describe('isAreaWalkable', () => { + const grid = [[0, 0, 0], [0, 3, 0], [0, 0, 0]]; + const walkable = new Set([0]); + const ts = 32; + const r = 6; + + it('returns true when area is fully on walkable tiles', () => { + expect(isAreaWalkable(16, 16, r, grid, ts, walkable)).toBe(true); + }); + + it('returns false when area overlaps non-walkable tile', () => { + // (26, 26) with r=6 → corner at (32, 32) enters tile (1,1) which is non-walkable + expect(isAreaWalkable(26, 26, r, grid, ts, walkable)).toBe(false); + }); + + it('returns true when near wall but not overlapping', () => { + // (24, 16) with r=6 → rightmost point at 30, still in tile 0 column + expect(isAreaWalkable(24, 16, r, grid, ts, walkable)).toBe(true); + }); +}); + +// === Collision: resolveCollision === + +describe('resolveCollision', () => { + const ts = 32; + const r = 6; + const walkable = new Set([0]); + + it('allows movement to valid position', () => { + const grid = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]; + const result = resolveCollision(48, 48, 16, 16, r, grid, ts, walkable); + expect(result).toEqual({ x: 48, y: 48 }); + }); + + it('blocks movement into non-walkable tile (slides X when Y row is clear)', () => { + // Center tile non-walkable but row 0 at X=48 is walkable → slides X + const grid = [[0, 0, 0], [0, 3, 0], [0, 0, 0]]; + const result = resolveCollision(48, 48, 16, 16, r, grid, ts, walkable); + expect(result).toEqual({ x: 48, y: 16 }); // slides along X + }); + + it('fully blocks when both axes lead to non-walkable', () => { + // "+" shaped wall: column 1 and row 1 are non-walkable + const grid = [[0, 3, 0], [3, 3, 3], [0, 3, 0]]; + const result = resolveCollision(48, 48, 16, 16, r, grid, ts, walkable); + expect(result).toEqual({ x: 16, y: 16 }); // can't move at all + }); + + it('wall-slides along X when Y is blocked', () => { + // Top row is wall, rows 1-2 walkable + const grid = [[3, 3, 3], [0, 0, 0], [0, 0, 0]]; + // From (48, 48) try to go to (60, 22) — Y into wall, X along wall + // Full: blocked (y-r=16 hits row 0) + // X-only: (60, 48) → all in row 1 → valid + const result = resolveCollision(60, 22, 48, 48, r, grid, ts, walkable); + expect(result).toEqual({ x: 60, y: 48 }); + }); + + it('wall-slides along Y when X is blocked', () => { + // Left column is wall, columns 1-2 walkable + const grid = [[3, 0, 0], [3, 0, 0], [3, 0, 0]]; + // From (48, 48) try to go to (22, 60) — X into wall, Y along wall + // Full: blocked (x-r=16 hits col 0) + // X-only: (22, 48) → blocked + // Y-only: (48, 60) → all in col 1 → valid + const result = resolveCollision(22, 60, 48, 48, r, grid, ts, walkable); + expect(result).toEqual({ x: 48, y: 60 }); + }); + + it('reverts completely when both axes blocked', () => { + // Only center tile walkable, rest walls + const grid = [[3, 3, 3], [3, 0, 3], [3, 3, 3]]; + // From (48, 48) try to go to (80, 80) — off walkable area + const result = resolveCollision(80, 80, 48, 48, r, grid, ts, walkable); + expect(result).toEqual({ x: 48, y: 48 }); + }); + + it('blocks out-of-bounds movement', () => { + const grid = [[0, 0], [0, 0]]; + const result = resolveCollision(-5, 16, 16, 16, r, grid, ts, walkable); + expect(result).toEqual({ x: 16, y: 16 }); + }); +}); + +// === Collision: tileCollisionSystem === + +describe('tileCollisionSystem', () => { + it('corrects player position after invalid movement', () => { + const world = createWorld(); + const eid = addEntity(world); + addComponent(world, eid, Position); + addComponent(world, eid, Velocity); + addComponent(world, eid, PlayerTag); + + // Player at (48, 48) with velocity that would move into wall + // After movementSystem: Position was updated. We simulate the post-movement state. + Velocity.vx[eid] = 150; // moving right + Velocity.vy[eid] = 0; + // After 0.2s movement: moved 30px right to x=78 which is in tile (2,1) + Position.x[eid] = 78; + Position.y[eid] = 48; + + // Grid where tile (2,1) is non-walkable + const grid = [[0, 0, 3], [0, 0, 3], [0, 0, 3]]; + const walkable = new Set([0]); + + tileCollisionSystem(world, 200, grid, 32, walkable); + + // Should revert X (back to 48) + expect(Position.x[eid]).toBe(48); + expect(Position.y[eid]).toBe(48); + }); +}); + +// === Collision: buildWalkableSet === + +describe('buildWalkableSet', () => { + it('includes walkable tiles', () => { + const tiles = [ + { id: 0, walkable: true }, + { id: 1, walkable: true }, + { id: 3, walkable: false }, + ]; + const set = buildWalkableSet(tiles); + expect(set.has(0)).toBe(true); + expect(set.has(1)).toBe(true); + expect(set.has(3)).toBe(false); + }); + + it('returns empty set when no walkable tiles', () => { + const tiles = [{ id: 0, walkable: false }]; + const set = buildWalkableSet(tiles); + expect(set.size).toBe(0); + }); +}); + +// === Spawn Position === + +describe('findSpawnPosition', () => { + const walkable = new Set([0]); + const ts = 32; + + it('finds walkable spawn position', () => { + const grid = [[3, 3, 3], [3, 0, 3], [3, 3, 3]]; + const pos = findSpawnPosition(grid, ts, walkable); + expect(pos).not.toBeNull(); + const tx = Math.floor(pos!.x / ts); + const ty = Math.floor(pos!.y / ts); + expect(walkable.has(grid[ty][tx])).toBe(true); + }); + + it('prefers center of map', () => { + const grid = [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + ]; + const pos = findSpawnPosition(grid, ts, walkable); + expect(pos).not.toBeNull(); + const tx = Math.floor(pos!.x / ts); + const ty = Math.floor(pos!.y / ts); + expect(tx).toBe(2); + expect(ty).toBe(2); + }); + + it('returns null for fully non-walkable grid', () => { + const grid = [[3, 3], [3, 3]]; + const pos = findSpawnPosition(grid, ts, walkable); + expect(pos).toBeNull(); + }); + + it('spirals outward to find walkable tile', () => { + // Center (2,2) is non-walkable, but neighbors are + const grid = [ + [0, 0, 0, 0, 0], + [0, 3, 3, 3, 0], + [0, 3, 3, 3, 0], + [0, 3, 3, 3, 0], + [0, 0, 0, 0, 0], + ]; + const pos = findSpawnPosition(grid, ts, walkable); + expect(pos).not.toBeNull(); + // Should find one of the ring-1 tiles (first walkable on spiral) + const tx = Math.floor(pos!.x / ts); + const ty = Math.floor(pos!.y / ts); + expect(walkable.has(grid[ty][tx])).toBe(true); + }); + + it('returns center of tile in pixels', () => { + const grid = [[0]]; + const pos = findSpawnPosition(grid, ts, walkable); + expect(pos).toEqual({ x: 16, y: 16 }); // center of tile (0,0) = (16, 16) + }); +}); + +// === Player Entity Factory === + +describe('createPlayerEntity', () => { + it('creates entity with correct position', () => { + const world = createWorld(); + const eid = createPlayerEntity(world, 100, 200); + expect(Position.x[eid]).toBe(100); + expect(Position.y[eid]).toBe(200); + }); + + it('creates entity with health', () => { + const world = createWorld(); + const eid = createPlayerEntity(world, 0, 0); + expect(Health.current[eid]).toBeGreaterThan(0); + expect(Health.max[eid]).toBeGreaterThan(0); + expect(Health.current[eid]).toBe(Health.max[eid]); + }); + + it('creates entity with sprite', () => { + const world = createWorld(); + const eid = createPlayerEntity(world, 0, 0); + expect(SpriteRef.color[eid]).toBeDefined(); + expect(SpriteRef.radius[eid]).toBeGreaterThan(0); + }); + + it('creates entity queryable with PlayerTag', () => { + const world = createWorld(); + const eid = createPlayerEntity(world, 50, 50); + const players = query(world, [PlayerTag]); + expect(players).toContain(eid); + expect(players.length).toBe(1); + }); + + it('creates entity with velocity component', () => { + const world = createWorld(); + const eid = createPlayerEntity(world, 0, 0); + expect(Velocity.vx[eid]).toBe(0); + expect(Velocity.vy[eid]).toBe(0); + }); +});