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>
410 lines
13 KiB
TypeScript
410 lines
13 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|