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:
141
src/player/projectile.ts
Normal file
141
src/player/projectile.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Projectile System — Throw Elements & Compounds
|
||||
*
|
||||
* Player launches projectiles toward mouse cursor.
|
||||
* Projectiles travel in a straight line, expire after a lifetime,
|
||||
* and are destroyed on hitting non-walkable tiles.
|
||||
*/
|
||||
|
||||
import { addEntity, addComponent, query } from 'bitecs';
|
||||
import type { World } from '../ecs/world';
|
||||
import { Position, Velocity, SpriteRef, Projectile } from '../ecs/components';
|
||||
import { removeGameEntity } from '../ecs/factory';
|
||||
import { isTileWalkable } from './collision';
|
||||
import { ElementRegistry } from '../chemistry/elements';
|
||||
import { CompoundRegistry } from '../chemistry/compounds';
|
||||
import type { TileGrid } from '../world/types';
|
||||
|
||||
/** Extra data for a projectile entity (string data) */
|
||||
export interface ProjectileData {
|
||||
itemId: string; // element symbol or compound id
|
||||
}
|
||||
|
||||
// === Constants ===
|
||||
|
||||
/** Projectile flight speed in pixels per second */
|
||||
export const PROJECTILE_SPEED = 350;
|
||||
|
||||
/** Projectile lifetime in milliseconds */
|
||||
export const PROJECTILE_LIFETIME = 2000;
|
||||
|
||||
/** Projectile visual radius */
|
||||
export const PROJECTILE_RADIUS = 5;
|
||||
|
||||
// === Direction ===
|
||||
|
||||
/**
|
||||
* Calculate normalized direction from source to target.
|
||||
* Returns (1, 0) if source == target (default: right).
|
||||
*/
|
||||
export function calculateDirection(
|
||||
fromX: number, fromY: number,
|
||||
toX: number, toY: number,
|
||||
): { dx: number; dy: number } {
|
||||
const dx = toX - fromX;
|
||||
const dy = toY - fromY;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (len < 0.001) {
|
||||
return { dx: 1, dy: 0 }; // default direction
|
||||
}
|
||||
|
||||
return { dx: dx / len, dy: dy / len };
|
||||
}
|
||||
|
||||
// === Launch ===
|
||||
|
||||
/**
|
||||
* Get the display color for a chemical item.
|
||||
* Uses element color from registry, or compound color, or default white.
|
||||
*/
|
||||
function getItemColor(itemId: string): number {
|
||||
const el = ElementRegistry.getBySymbol(itemId);
|
||||
if (el) return parseInt(el.color.replace('#', ''), 16);
|
||||
|
||||
const comp = CompoundRegistry.getById(itemId);
|
||||
if (comp) return parseInt(comp.color.replace('#', ''), 16);
|
||||
|
||||
return 0xffffff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a projectile entity flying from (fromX, fromY) toward (toX, toY).
|
||||
* @returns entity ID
|
||||
*/
|
||||
export function launchProjectile(
|
||||
world: World,
|
||||
projData: Map<number, ProjectileData>,
|
||||
fromX: number,
|
||||
fromY: number,
|
||||
toX: number,
|
||||
toY: number,
|
||||
itemId: string,
|
||||
): number {
|
||||
const dir = calculateDirection(fromX, fromY, toX, toY);
|
||||
|
||||
const eid = addEntity(world);
|
||||
addComponent(world, eid, Position);
|
||||
addComponent(world, eid, Velocity);
|
||||
addComponent(world, eid, SpriteRef);
|
||||
addComponent(world, eid, Projectile);
|
||||
|
||||
Position.x[eid] = fromX;
|
||||
Position.y[eid] = fromY;
|
||||
Velocity.vx[eid] = dir.dx * PROJECTILE_SPEED;
|
||||
Velocity.vy[eid] = dir.dy * PROJECTILE_SPEED;
|
||||
SpriteRef.color[eid] = getItemColor(itemId);
|
||||
SpriteRef.radius[eid] = PROJECTILE_RADIUS;
|
||||
Projectile.lifetime[eid] = PROJECTILE_LIFETIME;
|
||||
|
||||
projData.set(eid, { itemId });
|
||||
|
||||
return eid;
|
||||
}
|
||||
|
||||
// === System ===
|
||||
|
||||
/**
|
||||
* Update projectiles: decrement lifetime, remove expired/collided.
|
||||
* Runs AFTER movementSystem (projectiles moved by generic movement).
|
||||
*/
|
||||
export function projectileSystem(
|
||||
world: World,
|
||||
deltaMs: number,
|
||||
grid: TileGrid,
|
||||
tileSize: number,
|
||||
walkable: Set<number>,
|
||||
projData: Map<number, ProjectileData>,
|
||||
): void {
|
||||
const toRemove: number[] = [];
|
||||
|
||||
for (const eid of query(world, [Position, Projectile])) {
|
||||
// Decrement lifetime
|
||||
Projectile.lifetime[eid] -= deltaMs;
|
||||
|
||||
// Check expiry
|
||||
if (Projectile.lifetime[eid] <= 0) {
|
||||
toRemove.push(eid);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check tile collision (point check at projectile center)
|
||||
if (!isTileWalkable(Position.x[eid], Position.y[eid], grid, tileSize, walkable)) {
|
||||
toRemove.push(eid);
|
||||
}
|
||||
}
|
||||
|
||||
for (const eid of toRemove) {
|
||||
removeGameEntity(world, eid);
|
||||
projData.delete(eid);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user