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

106
src/world/resources.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* Resource Spawner — creates ECS entities for harvestable world objects
*
* Scans the generated tile grid for mineral veins and geysers,
* creates an entity at each with a randomly assigned element.
*/
import { addEntity, addComponent } from 'bitecs';
import type { World } from '../ecs/world';
import { Position, Resource, SpriteRef } from '../ecs/components';
import type { TileGrid, BiomeData } from './types';
import {
pickResourceElement,
MINERAL_ELEMENTS,
GEYSER_ELEMENTS,
type ResourceInfo,
} from '../player/interaction';
/** Resource spawn configuration per tile type */
interface ResourceTileConfig {
tileId: number;
elements: readonly string[];
minQuantity: number;
maxQuantity: number;
interactRange: number;
spriteColor: number;
spriteRadius: number;
}
/**
* Spawn resource entities for all resource tiles in the grid.
* @returns Map of entity ID → ResourceInfo for string data
*/
export function spawnResources(
world: World,
grid: TileGrid,
biome: BiomeData,
seed: number,
): Map<number, ResourceInfo> {
const resourceData = new Map<number, ResourceInfo>();
// Find tile IDs for resource types
const mineralTile = biome.tiles.find(t => t.name === 'mineral-vein');
const geyserTile = biome.tiles.find(t => t.name === 'geyser');
const configs: ResourceTileConfig[] = [];
if (mineralTile) {
configs.push({
tileId: mineralTile.id,
elements: MINERAL_ELEMENTS,
minQuantity: 3,
maxQuantity: 5,
interactRange: 40,
spriteColor: 0xffd700, // gold
spriteRadius: 4,
});
}
if (geyserTile) {
configs.push({
tileId: geyserTile.id,
elements: GEYSER_ELEMENTS,
minQuantity: 2,
maxQuantity: 4,
interactRange: 48,
spriteColor: 0xff6600, // orange
spriteRadius: 5,
});
}
const tileSize = biome.tileSize;
for (let y = 0; y < grid.length; y++) {
for (let x = 0; x < grid[y].length; x++) {
const tileId = grid[y][x];
const config = configs.find(c => c.tileId === tileId);
if (!config) continue;
// Pick element deterministically
const itemId = pickResourceElement(x, y, seed, config.elements);
// Quantity from deterministic hash
const qHash = ((x * 48611) ^ (y * 29423) ^ (seed * 61379)) >>> 0;
const range = config.maxQuantity - config.minQuantity + 1;
const quantity = config.minQuantity + (qHash % range);
// Create entity at tile center
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Resource);
addComponent(world, eid, SpriteRef);
Position.x[eid] = x * tileSize + tileSize / 2;
Position.y[eid] = y * tileSize + tileSize / 2;
Resource.quantity[eid] = quantity;
Resource.interactRange[eid] = config.interactRange;
SpriteRef.color[eid] = config.spriteColor;
SpriteRef.radius[eid] = config.spriteRadius;
resourceData.set(eid, { itemId, tileX: x, tileY: y });
}
}
return resourceData;
}