feat: resource collection with E-key interaction (Phase 4.3)

Mineral veins yield metals (Fe, Cu, Zn, Au, Sn), geysers yield S/H.
Resources spawn as ECS entities with gold/orange dot sprites on tiles.
E-key collects nearest resource in range into inventory. Resources
deplete after collection. Visual feedback text. 12 new tests (174 total).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Денис Шкабатур
2026-02-12 13:25:35 +03:00
parent cf36c0adce
commit e77b9df6e4
5 changed files with 501 additions and 11 deletions

114
src/player/interaction.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* Resource Interaction System — Phase 4.3
*
* Handles player collecting elements from world resources
* (mineral veins, geysers). Press E near a resource to collect.
*/
import { query } from 'bitecs';
import type { World } from '../ecs/world';
import { Position, Resource, PlayerTag } from '../ecs/components';
import { removeGameEntity } from '../ecs/factory';
import type { Inventory } from './inventory';
/** Metadata for a resource entity (string data that can't go in bitECS arrays) */
export interface ResourceInfo {
itemId: string; // element symbol or compound id
tileX: number;
tileY: number;
}
/** Result of an interaction attempt */
export interface InteractionResult {
type: 'collected' | 'depleted' | 'inventory_full' | 'nothing_nearby';
itemId?: string;
remaining?: number;
}
/** Elements that mineral veins can yield */
export const MINERAL_ELEMENTS = ['Fe', 'Cu', 'Zn', 'Au', 'Sn'] as const;
/** Elements that geysers can yield */
export const GEYSER_ELEMENTS = ['S', 'H'] as const;
/**
* Deterministic element picker based on tile position and seed.
* Same (x, y, seed) always gives the same element.
*/
export function pickResourceElement(
tileX: number,
tileY: number,
seed: number,
options: readonly string[],
): string {
// Multiplicative hash for spatial distribution
const hash = ((tileX * 73856093) ^ (tileY * 19349663) ^ (seed * 83492791)) >>> 0;
return options[hash % options.length];
}
/**
* Interaction system — handles E-key resource collection.
*
* Finds closest resource in range, adds to inventory, decrements quantity.
* Returns null if E not pressed, or InteractionResult describing what happened.
*/
export function interactionSystem(
world: World,
justPressedInteract: boolean,
inventory: Inventory,
resourceData: Map<number, ResourceInfo>,
): InteractionResult | null {
if (!justPressedInteract) return null;
// Find player position
const players = query(world, [Position, PlayerTag]);
if (players.length === 0) return null;
const playerEid = players[0];
const px = Position.x[playerEid];
const py = Position.y[playerEid];
// Find closest resource in range
let closestEid: number | null = null;
let closestDist = Infinity;
for (const eid of query(world, [Position, Resource])) {
const dx = Position.x[eid] - px;
const dy = Position.y[eid] - py;
const dist = Math.sqrt(dx * dx + dy * dy);
const range = Resource.interactRange[eid];
if (dist <= range && dist < closestDist) {
closestEid = eid;
closestDist = dist;
}
}
if (closestEid === null) {
return { type: 'nothing_nearby' };
}
const info = resourceData.get(closestEid);
if (!info) return { type: 'nothing_nearby' };
// Try to add to inventory
const added = inventory.addItem(info.itemId, 1);
if (added === 0) {
return { type: 'inventory_full', itemId: info.itemId };
}
// Decrement resource quantity
Resource.quantity[closestEid] -= 1;
if (Resource.quantity[closestEid] <= 0) {
// Resource depleted — remove entity
removeGameEntity(world, closestEid);
resourceData.delete(closestEid);
return { type: 'depleted', itemId: info.itemId };
}
return {
type: 'collected',
itemId: info.itemId,
remaining: Resource.quantity[closestEid],
};
}