Files
synthesis/tests/projectile.test.ts
Денис Шкабатур 0396170303 feat: projectile system for throwing elements (Phase 4.5)
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>
2026-02-12 13:29:38 +03:00

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