diff --git a/src/ecs/components.ts b/src/ecs/components.ts index ccd6a43..9d66ec1 100644 --- a/src/ecs/components.ts +++ b/src/ecs/components.ts @@ -45,3 +45,8 @@ export const Resource = { quantity: [] as number[], // remaining items to collect interactRange: [] as number[], // max interaction distance in pixels }; + +/** Thrown element/compound projectile */ +export const Projectile = { + lifetime: [] as number[], // remaining lifetime in ms (removed at 0) +}; diff --git a/src/player/projectile.ts b/src/player/projectile.ts new file mode 100644 index 0000000..91d6fde --- /dev/null +++ b/src/player/projectile.ts @@ -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, + 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, + projData: Map, +): 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); + } +} diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 1158333..3835a4f 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -18,6 +18,11 @@ import { createPlayerEntity } from '../player/factory'; import { Inventory } from '../player/inventory'; import { interactionSystem, type ResourceInfo } from '../player/interaction'; import { spawnResources } from '../world/resources'; +import { + launchProjectile, + projectileSystem, + type ProjectileData, +} from '../player/projectile'; import type { InputState } from '../player/types'; export class GameScene extends Phaser.Scene { @@ -34,18 +39,21 @@ export class GameScene extends Phaser.Scene { private worldGrid!: number[][]; private tileSize!: number; private resourceData!: Map; + private projectileData!: Map; private keys!: { W: Phaser.Input.Keyboard.Key; A: Phaser.Input.Keyboard.Key; S: Phaser.Input.Keyboard.Key; D: Phaser.Input.Keyboard.Key; E: Phaser.Input.Keyboard.Key; + F: Phaser.Input.Keyboard.Key; }; // Interaction feedback private interactionText!: Phaser.GameObjects.Text; private interactionTimer = 0; private wasEDown = false; + private wasFDown = false; constructor() { super({ key: 'GameScene' }); @@ -55,6 +63,7 @@ export class GameScene extends Phaser.Scene { // 1. Initialize ECS this.gameWorld = createGameWorld(); this.bridge = new PhaserBridge(this); + this.projectileData = new Map(); // 2. Generate world const biome = biomeDataArray[0] as BiomeData; @@ -103,6 +112,7 @@ export class GameScene extends Phaser.Scene { S: keyboard.addKey('S'), D: keyboard.addKey('D'), E: keyboard.addKey('E'), + F: keyboard.addKey('F'), }; // 9. Minimap @@ -149,7 +159,7 @@ export class GameScene extends Phaser.Scene { // 3. Player input → velocity playerInputSystem(this.gameWorld.world, input); - // 4. Movement (all entities) + // 4. Movement (all entities including projectiles) movementSystem(this.gameWorld.world, delta); // 5. Tile collision (player only) @@ -161,7 +171,17 @@ export class GameScene extends Phaser.Scene { this.walkableSet, ); - // 6. Resource interaction (E key, debounced — manual edge detection) + // 6. Projectile system (lifetime + tile collision) + projectileSystem( + this.gameWorld.world, + delta, + this.worldGrid, + this.tileSize, + this.walkableSet, + this.projectileData, + ); + + // 7. Resource interaction (E key, debounced) const isEDown = this.keys.E.isDown; const justPressedE = isEDown && !this.wasEDown; this.wasEDown = isEDown; @@ -172,19 +192,27 @@ export class GameScene extends Phaser.Scene { this.showInteractionFeedback(interaction.type, interaction.itemId); } - // 7. Health / death + // 8. Throw projectile (F key, debounced) + const isFDown = this.keys.F.isDown; + const justPressedF = isFDown && !this.wasFDown; + this.wasFDown = isFDown; + if (justPressedF) { + this.tryLaunchProjectile(); + } + + // 9. Health / death const dead = healthSystem(this.gameWorld.world); for (const eid of dead) { removeGameEntity(this.gameWorld.world, eid); } - // 8. Render sync + // 10. Render sync this.bridge.sync(this.gameWorld.world); - // 9. Minimap viewport + // 11. Minimap viewport this.minimap.update(this.cameras.main); - // 10. Fade interaction text + // 12. Fade interaction text if (this.interactionTimer > 0) { this.interactionTimer -= delta; if (this.interactionTimer <= 0) { @@ -192,22 +220,53 @@ export class GameScene extends Phaser.Scene { } } - // 11. Stats + // 13. Stats const fps = delta > 0 ? Math.round(1000 / delta) : 0; const px = Math.round(Position.x[this.playerEid]); const py = Math.round(Position.y[this.playerEid]); const invWeight = Math.round(this.inventory.getTotalWeight()); const invSlots = this.inventory.slotCount; this.statsText.setText( - `seed: ${this.worldSeed} | ${fps} fps | pos: ${px},${py} | inv: ${invSlots} items, ${invWeight} AMU | WASD/E/scroll`, + `seed: ${this.worldSeed} | ${fps} fps | pos: ${px},${py} | inv: ${invSlots} items, ${invWeight} AMU | WASD/E/F/scroll`, ); } + /** Try to launch a projectile from the first inventory item toward mouse */ + private tryLaunchProjectile(): void { + const items = this.inventory.getItems(); + if (items.length === 0) { + this.showInteractionFeedback('nothing_nearby'); + return; + } + + // Use first item in inventory (quick slots will replace this in 4.6) + const item = items[0]; + const removed = this.inventory.removeItem(item.id, 1); + if (removed === 0) return; + + // Get mouse world position for direction + const pointer = this.input.activePointer; + const worldPoint = this.cameras.main.getWorldPoint(pointer.x, pointer.y); + + const px = Position.x[this.playerEid]; + const py = Position.y[this.playerEid]; + + launchProjectile( + this.gameWorld.world, + this.projectileData, + px, py, + worldPoint.x, worldPoint.y, + item.id, + ); + + this.showInteractionFeedback('collected', `Threw ${item.id}`); + } + private showInteractionFeedback(type: string, itemId?: string): void { let msg = ''; switch (type) { case 'collected': - msg = `+1 ${itemId ?? ''}`; + msg = itemId?.startsWith('Threw') ? itemId : `+1 ${itemId ?? ''}`; break; case 'depleted': msg = `+1 ${itemId ?? ''} (depleted)`; @@ -216,11 +275,11 @@ export class GameScene extends Phaser.Scene { msg = `Inventory full! Can't pick up ${itemId ?? ''}`; break; case 'nothing_nearby': - msg = 'Nothing to interact with nearby'; + msg = 'Nothing to throw / interact with'; break; } this.interactionText.setText(msg); this.interactionText.setAlpha(1); - this.interactionTimer = 1500; // fade after 1.5s + this.interactionTimer = 1500; } } diff --git a/tests/projectile.test.ts b/tests/projectile.test.ts new file mode 100644 index 0000000..704ff43 --- /dev/null +++ b/tests/projectile.test.ts @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + 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(); + 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(); + 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(); + projData.set(eid, { itemId: 'Na' }); + + projectileSystem(world, 16, grid, 32, walkable, projData); + + expect(query(world, [Projectile]).length).toBe(1); + expect(projData.size).toBe(1); + }); +});