import { describe, expect, it } from 'vitest'; import { PLAYER_ACCEL, PLAYER_FRICTION, PLAYER_MAX_SPEED, PLAYER_RADIUS, freshPlayer, stepPlayer, } from '../../src/scenes/floor/player'; import type { PlayerInput, PlayerState } from '../../src/scenes/floor/player'; import { TILE, VENUE, isWalkableWorld, tileToWorld } from '../../src/scenes/floor/venueMap'; const NONE: PlayerInput = { up: false, down: false, left: false, right: false }; const input = (over: Partial): PlayerInput => ({ ...NONE, ...over }); // Open dance floor, and a spot one tile north of the south border wall. const OPEN = tileToWorld(38, 23); const NEAR_SOUTH_WALL = tileToWorld(40, 43); const SOUTH_WALL_Y = 44 * TILE; function run(p: PlayerState, i: PlayerInput, steps: number, ms = 16): PlayerState { let s = p; for (let n = 0; n < steps; n++) s = stepPlayer(s, i, ms, VENUE); return s; } const speedOf = (s: PlayerState): number => Math.hypot(s.vx, s.vy); /** The collision test the mover itself uses. */ function standing(s: PlayerState): boolean { return isWalkableWorld(VENUE, s.x + PLAYER_RADIUS, s.y) && isWalkableWorld(VENUE, s.x - PLAYER_RADIUS, s.y) && isWalkableWorld(VENUE, s.x, s.y + PLAYER_RADIUS) && isWalkableWorld(VENUE, s.x, s.y - PLAYER_RADIUS); } describe('freshPlayer', () => { it('starts at rest where it was put', () => { const p = freshPlayer(100, 200); expect(p).toEqual({ x: 100, y: 200, vx: 0, vy: 0, facing: 0 }); }); it('exposes a heavy tuning — accel far outstrips top speed', () => { expect(PLAYER_ACCEL).toBeGreaterThan(PLAYER_MAX_SPEED); expect(PLAYER_FRICTION).toBeGreaterThan(0); }); }); describe('acceleration', () => { it('accelerates from rest', () => { const p = stepPlayer(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 16, VENUE); expect(p.vx).toBeGreaterThan(0); expect(p.x).toBeGreaterThan(OPEN.x); expect(p.vy).toBe(0); }); it('clamps to PLAYER_MAX_SPEED however long you hold it', () => { const p = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 40); expect(speedOf(p)).toBeCloseTo(PLAYER_MAX_SPEED, 6); expect(speedOf(p)).toBeLessThanOrEqual(PLAYER_MAX_SPEED + 1e-9); }); it('caps diagonals at the same top speed, not 1.41x', () => { const p = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true, down: true }), 40); expect(speedOf(p)).toBeLessThanOrEqual(PLAYER_MAX_SPEED + 1e-9); expect(p.vx).toBeCloseTo(p.vy, 6); }); }); describe('friction', () => { it('decelerates with no input, without reversing', () => { const moving = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 40); const a = run(moving, NONE, 5); const b = run(a, NONE, 5); expect(speedOf(a)).toBeLessThan(speedOf(moving)); expect(speedOf(b)).toBeLessThan(speedOf(a)); expect(b.vx).toBeGreaterThan(0); expect(run(b, NONE, 400).vx).toBeCloseTo(0, 3); }); }); describe('facing', () => { it('follows the direction of travel', () => { const right = run(freshPlayer(OPEN.x, OPEN.y), input({ right: true }), 20); expect(right.facing).toBeCloseTo(0, 4); const up = run(freshPlayer(OPEN.x, OPEN.y), input({ up: true }), 20); expect(up.facing).toBeCloseTo(-Math.PI / 2, 4); }); it('persists when input stops and the body coasts to a halt', () => { const up = run(freshPlayer(OPEN.x, OPEN.y), input({ up: true }), 20); const stopped = run(up, NONE, 600); expect(speedOf(stopped)).toBeLessThan(1); expect(stopped.facing).toBeCloseTo(up.facing, 6); }); }); describe('collision', () => { it('cannot walk through a wall', () => { const p = run(freshPlayer(NEAR_SOUTH_WALL.x, NEAR_SOUTH_WALL.y), input({ down: true }), 200); expect(standing(p)).toBe(true); expect(p.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y); expect(p.vy).toBe(0); }); it('slides along a wall when moving diagonally into it', () => { const start = freshPlayer(NEAR_SOUTH_WALL.x, NEAR_SOUTH_WALL.y); const p = run(start, input({ down: true, right: true }), 120); expect(standing(p)).toBe(true); expect(p.x).toBeGreaterThan(start.x + 50); expect(p.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y); }); it('does not tunnel on a monstrous delta', () => { const one = stepPlayer(freshPlayer(NEAR_SOUTH_WALL.x, NEAR_SOUTH_WALL.y), input({ down: true }), 60000, VENUE); expect(standing(one)).toBe(true); expect(one.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y); // ...and repeated giant deltas are still just clamped steps. const many = run(one, input({ down: true }), 30, 9999); expect(standing(many)).toBe(true); expect(many.y + PLAYER_RADIUS).toBeLessThan(SOUTH_WALL_Y); }); it('keeps a full patrol of the open floor on walkable ground', () => { let p = freshPlayer(OPEN.x, OPEN.y); const legs: PlayerInput[] = [ input({ right: true }), input({ down: true }), input({ left: true }), input({ up: true }), input({ up: true, left: true }), input({ down: true, right: true }), ]; for (const leg of legs) { for (let n = 0; n < 300; n++) { p = stepPlayer(p, leg, 16, VENUE); expect(standing(p)).toBe(true); } } }); });