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>
This commit is contained in:
188
tests/projectile.test.ts
Normal file
188
tests/projectile.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user