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:
Денис Шкабатур
2026-02-12 13:29:38 +03:00
parent b097ce738f
commit 0396170303
4 changed files with 404 additions and 11 deletions

View File

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

141
src/player/projectile.ts Normal file
View 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);
}
}

View File

@@ -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<number, ResourceInfo>;
private projectileData!: Map<number, ProjectileData>;
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;
}
}