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 <cursoragent@cursor.com>
This commit is contained in:
Денис Шкабатур
2026-02-12 13:09:01 +03:00
parent c4993e9eee
commit 0c0635c93b
11 changed files with 820 additions and 14 deletions

View File

@@ -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;

View File

@@ -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[],
};

128
src/player/collision.ts Normal file
View File

@@ -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<number>,
): 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<number>,
): 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<number>,
): { 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<number> {
const set = new Set<number>();
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<number>,
): 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;
}
}

31
src/player/factory.ts Normal file
View File

@@ -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;
}

48
src/player/input.ts Normal file
View File

@@ -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;
}
}

51
src/player/spawn.ts Normal file
View File

@@ -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<number>,
): { 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;
}

33
src/player/types.ts Normal file
View File

@@ -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;

View File

@@ -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',

View File

@@ -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<number>;
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`,
);
}
}

View File

@@ -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);
});
}

409
tests/player.test.ts Normal file
View File

@@ -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);
});
});