F key launches first inventory item toward mouse cursor as projectile. Projectiles travel at 350px/s, expire after 2s, destroyed on hitting non-walkable tiles. Color matches element's periodic table color. 13 new tests (198 total). Co-authored-by: Cursor <cursoragent@cursor.com>
189 lines
5.5 KiB
TypeScript
189 lines
5.5 KiB
TypeScript
/**
|
|
* Projectile System Tests — Phase 4.5
|
|
*
|
|
* Tests: projectile creation, direction calculation,
|
|
* lifetime expiry, tile collision removal.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { createWorld, addEntity, addComponent, query } from 'bitecs';
|
|
import { Position, Velocity, Projectile, SpriteRef } from '../src/ecs/components';
|
|
import {
|
|
launchProjectile,
|
|
projectileSystem,
|
|
calculateDirection,
|
|
type ProjectileData,
|
|
PROJECTILE_SPEED,
|
|
PROJECTILE_LIFETIME,
|
|
} from '../src/player/projectile';
|
|
|
|
// === Direction Calculation ===
|
|
|
|
describe('calculateDirection', () => {
|
|
it('normalizes direction vector', () => {
|
|
const d = calculateDirection(0, 0, 100, 0);
|
|
expect(d.dx).toBeCloseTo(1);
|
|
expect(d.dy).toBeCloseTo(0);
|
|
});
|
|
|
|
it('handles diagonal direction', () => {
|
|
const d = calculateDirection(0, 0, 100, 100);
|
|
const mag = Math.sqrt(d.dx * d.dx + d.dy * d.dy);
|
|
expect(mag).toBeCloseTo(1);
|
|
});
|
|
|
|
it('handles negative direction', () => {
|
|
const d = calculateDirection(100, 100, 0, 0);
|
|
expect(d.dx).toBeCloseTo(-1 / Math.SQRT2);
|
|
expect(d.dy).toBeCloseTo(-1 / Math.SQRT2);
|
|
});
|
|
|
|
it('returns zero for same position', () => {
|
|
const d = calculateDirection(50, 50, 50, 50);
|
|
expect(d.dx).toBe(1); // default to right
|
|
expect(d.dy).toBe(0);
|
|
});
|
|
});
|
|
|
|
// === Launch Projectile ===
|
|
|
|
describe('launchProjectile', () => {
|
|
it('creates entity at source position', () => {
|
|
const world = createWorld();
|
|
const projData = new Map<number, ProjectileData>();
|
|
|
|
const eid = launchProjectile(world, projData, 100, 200, 200, 200, 'Na');
|
|
|
|
expect(Position.x[eid]).toBe(100);
|
|
expect(Position.y[eid]).toBe(200);
|
|
});
|
|
|
|
it('sets velocity toward target', () => {
|
|
const world = createWorld();
|
|
const projData = new Map<number, ProjectileData>();
|
|
|
|
const eid = launchProjectile(world, projData, 0, 0, 100, 0, 'Na');
|
|
|
|
expect(Velocity.vx[eid]).toBeCloseTo(PROJECTILE_SPEED);
|
|
expect(Velocity.vy[eid]).toBeCloseTo(0);
|
|
});
|
|
|
|
it('stores chemical data', () => {
|
|
const world = createWorld();
|
|
const projData = new Map<number, ProjectileData>();
|
|
|
|
const eid = launchProjectile(world, projData, 0, 0, 100, 0, 'Na');
|
|
|
|
expect(projData.get(eid)).toBeDefined();
|
|
expect(projData.get(eid)!.itemId).toBe('Na');
|
|
});
|
|
|
|
it('sets lifetime', () => {
|
|
const world = createWorld();
|
|
const projData = new Map<number, ProjectileData>();
|
|
|
|
const eid = launchProjectile(world, projData, 0, 0, 100, 0, 'Na');
|
|
|
|
expect(Projectile.lifetime[eid]).toBe(PROJECTILE_LIFETIME);
|
|
});
|
|
|
|
it('sets sprite with element color', () => {
|
|
const world = createWorld();
|
|
const projData = new Map<number, ProjectileData>();
|
|
|
|
const eid = launchProjectile(world, projData, 0, 0, 100, 0, 'Na');
|
|
|
|
expect(SpriteRef.radius[eid]).toBeGreaterThan(0);
|
|
expect(SpriteRef.color[eid]).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// === Projectile System ===
|
|
|
|
describe('projectileSystem', () => {
|
|
it('decrements lifetime', () => {
|
|
const world = createWorld();
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Position);
|
|
addComponent(world, eid, Velocity);
|
|
addComponent(world, eid, Projectile);
|
|
Position.x[eid] = 48;
|
|
Position.y[eid] = 48;
|
|
Velocity.vx[eid] = 100;
|
|
Velocity.vy[eid] = 0;
|
|
Projectile.lifetime[eid] = 1000;
|
|
|
|
const grid = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
|
|
const walkable = new Set([0]);
|
|
const projData = new Map<number, ProjectileData>();
|
|
projData.set(eid, { itemId: 'Na' });
|
|
|
|
projectileSystem(world, 100, grid, 32, walkable, projData);
|
|
|
|
expect(Projectile.lifetime[eid]).toBe(900);
|
|
});
|
|
|
|
it('removes expired projectiles', () => {
|
|
const world = createWorld();
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Position);
|
|
addComponent(world, eid, Velocity);
|
|
addComponent(world, eid, Projectile);
|
|
Position.x[eid] = 48;
|
|
Position.y[eid] = 48;
|
|
Projectile.lifetime[eid] = 50;
|
|
|
|
const grid = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
|
|
const walkable = new Set([0]);
|
|
const projData = new Map<number, ProjectileData>();
|
|
projData.set(eid, { itemId: 'Na' });
|
|
|
|
projectileSystem(world, 100, grid, 32, walkable, projData);
|
|
|
|
expect(query(world, [Projectile]).length).toBe(0);
|
|
expect(projData.size).toBe(0);
|
|
});
|
|
|
|
it('removes projectiles on non-walkable tile', () => {
|
|
const world = createWorld();
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Position);
|
|
addComponent(world, eid, Velocity);
|
|
addComponent(world, eid, Projectile);
|
|
// Position on non-walkable tile (1,1) = tile 3
|
|
Position.x[eid] = 48;
|
|
Position.y[eid] = 48;
|
|
Projectile.lifetime[eid] = 5000;
|
|
|
|
const grid = [[0, 0, 0], [0, 3, 0], [0, 0, 0]];
|
|
const walkable = new Set([0]);
|
|
const projData = new Map<number, ProjectileData>();
|
|
projData.set(eid, { itemId: 'Na' });
|
|
|
|
projectileSystem(world, 16, grid, 32, walkable, projData);
|
|
|
|
expect(query(world, [Projectile]).length).toBe(0);
|
|
});
|
|
|
|
it('keeps valid projectiles alive', () => {
|
|
const world = createWorld();
|
|
const eid = addEntity(world);
|
|
addComponent(world, eid, Position);
|
|
addComponent(world, eid, Velocity);
|
|
addComponent(world, eid, Projectile);
|
|
Position.x[eid] = 48;
|
|
Position.y[eid] = 48;
|
|
Projectile.lifetime[eid] = 5000;
|
|
|
|
const grid = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
|
|
const walkable = new Set([0]);
|
|
const projData = new Map<number, ProjectileData>();
|
|
projData.set(eid, { itemId: 'Na' });
|
|
|
|
projectileSystem(world, 16, grid, 32, walkable, projData);
|
|
|
|
expect(query(world, [Projectile]).length).toBe(1);
|
|
expect(projData.size).toBe(1);
|
|
});
|
|
});
|