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

View File

@@ -39,3 +39,9 @@ export const ChemicalComposition = {
export const PlayerTag = {
_tag: [] as number[],
};
/** Harvestable resource — mineral veins, geysers, etc. */
export const Resource = {
quantity: [] as number[], // remaining items to collect
interactRange: [] as number[], // max interaction distance in pixels
};

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],
};
}

View File

@@ -15,6 +15,9 @@ import { playerInputSystem } from '../player/input';
import { tileCollisionSystem, buildWalkableSet } from '../player/collision';
import { findSpawnPosition } from '../player/spawn';
import { createPlayerEntity } from '../player/factory';
import { Inventory } from '../player/inventory';
import { interactionSystem, type ResourceInfo } from '../player/interaction';
import { spawnResources } from '../world/resources';
import type { InputState } from '../player/types';
export class GameScene extends Phaser.Scene {
@@ -26,9 +29,11 @@ export class GameScene extends Phaser.Scene {
// Player state
private playerEid!: number;
private inventory!: Inventory;
private walkableSet!: Set<number>;
private worldGrid!: number[][];
private tileSize!: number;
private resourceData!: Map<number, ResourceInfo>;
private keys!: {
W: Phaser.Input.Keyboard.Key;
A: Phaser.Input.Keyboard.Key;
@@ -37,6 +42,11 @@ export class GameScene extends Phaser.Scene {
E: Phaser.Input.Keyboard.Key;
};
// Interaction feedback
private interactionText!: Phaser.GameObjects.Text;
private interactionTimer = 0;
private wasEDown = false;
constructor() {
super({ key: 'GameScene' });
}
@@ -59,18 +69,24 @@ export class GameScene extends Phaser.Scene {
this.worldGrid = worldData.grid;
this.tileSize = biome.tileSize;
// 5. Create player at spawn position
// 5. Spawn resource entities (mineral veins, geysers)
this.resourceData = spawnResources(
this.gameWorld.world, worldData.grid, biome, this.worldSeed,
);
// 6. Create player at spawn position + inventory
const spawn = findSpawnPosition(worldData.grid, biome.tileSize, this.walkableSet);
const spawnX = spawn?.x ?? (biome.mapWidth * biome.tileSize) / 2;
const spawnY = spawn?.y ?? (biome.mapHeight * biome.tileSize) / 2;
this.playerEid = createPlayerEntity(this.gameWorld.world, spawnX, spawnY);
this.inventory = new Inventory(500, 20);
// 6. Camera — follow player, zoom via scroll wheel
// 7. Camera — follow player, zoom via scroll wheel
const worldPixelW = biome.mapWidth * biome.tileSize;
const worldPixelH = biome.mapHeight * biome.tileSize;
setupPlayerCamera(this, worldPixelW, worldPixelH);
// Sync bridge to create player sprite, then attach camera follow
// Sync bridge to create sprites, then attach camera follow to player
this.bridge.sync(this.gameWorld.world);
const playerSprite = this.bridge.getSprite(this.playerEid);
if (playerSprite) {
@@ -78,7 +94,7 @@ export class GameScene extends Phaser.Scene {
this.cameras.main.startFollow(playerSprite, true, 0.1, 0.1);
}
// 7. Keyboard input
// 8. Keyboard input
const keyboard = this.input.keyboard;
if (!keyboard) throw new Error('Keyboard plugin not available');
this.keys = {
@@ -89,10 +105,10 @@ export class GameScene extends Phaser.Scene {
E: keyboard.addKey('E'),
};
// 8. Minimap
// 9. Minimap
this.minimap = new Minimap(this, worldData);
// 9. UI stats overlay
// 10. UI overlay
this.statsText = this.add.text(10, 10, '', {
fontSize: '12px',
color: '#00ff88',
@@ -102,6 +118,21 @@ export class GameScene extends Phaser.Scene {
});
this.statsText.setScrollFactor(0);
this.statsText.setDepth(100);
// Interaction feedback text (center-bottom of screen)
this.interactionText = this.add.text(
this.cameras.main.width / 2, this.cameras.main.height - 40, '', {
fontSize: '14px',
color: '#ffdd44',
fontFamily: 'monospace',
backgroundColor: '#000000cc',
padding: { x: 6, y: 3 },
},
);
this.interactionText.setScrollFactor(0);
this.interactionText.setOrigin(0.5);
this.interactionText.setDepth(100);
this.interactionText.setAlpha(0);
}
update(_time: number, delta: number): void {
@@ -130,24 +161,66 @@ export class GameScene extends Phaser.Scene {
this.walkableSet,
);
// 6. Health / death
// 6. Resource interaction (E key, debounced — manual edge detection)
const isEDown = this.keys.E.isDown;
const justPressedE = isEDown && !this.wasEDown;
this.wasEDown = isEDown;
const interaction = interactionSystem(
this.gameWorld.world, justPressedE, this.inventory, this.resourceData,
);
if (interaction) {
this.showInteractionFeedback(interaction.type, interaction.itemId);
}
// 7. Health / death
const dead = healthSystem(this.gameWorld.world);
for (const eid of dead) {
removeGameEntity(this.gameWorld.world, eid);
}
// 7. Render sync
// 8. Render sync
this.bridge.sync(this.gameWorld.world);
// 8. Minimap viewport
// 9. Minimap viewport
this.minimap.update(this.cameras.main);
// 9. Stats
// 10. Fade interaction text
if (this.interactionTimer > 0) {
this.interactionTimer -= delta;
if (this.interactionTimer <= 0) {
this.interactionText.setAlpha(0);
}
}
// 11. 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} | WASD move, E interact, scroll zoom`,
`seed: ${this.worldSeed} | ${fps} fps | pos: ${px},${py} | inv: ${invSlots} items, ${invWeight} AMU | WASD/E/scroll`,
);
}
private showInteractionFeedback(type: string, itemId?: string): void {
let msg = '';
switch (type) {
case 'collected':
msg = `+1 ${itemId ?? ''}`;
break;
case 'depleted':
msg = `+1 ${itemId ?? ''} (depleted)`;
break;
case 'inventory_full':
msg = `Inventory full! Can't pick up ${itemId ?? ''}`;
break;
case 'nothing_nearby':
msg = 'Nothing to interact with nearby';
break;
}
this.interactionText.setText(msg);
this.interactionText.setAlpha(1);
this.interactionTimer = 1500; // fade after 1.5s
}
}

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

191
tests/interaction.test.ts Normal file
View File

@@ -0,0 +1,191 @@
/**
* Resource Interaction Tests — Phase 4.3
*
* Tests: resource element picking, proximity detection,
* collection into inventory, resource depletion.
*/
import { describe, it, expect } from 'vitest';
import { createWorld, addEntity, addComponent, query } from 'bitecs';
import { Position, Resource, PlayerTag } from '../src/ecs/components';
import { Inventory } from '../src/player/inventory';
import {
pickResourceElement,
interactionSystem,
type ResourceInfo,
type InteractionResult,
MINERAL_ELEMENTS,
GEYSER_ELEMENTS,
} from '../src/player/interaction';
// === Helpers ===
function createTestWorld() {
const world = createWorld();
const resourceData = new Map<number, ResourceInfo>();
const inventory = new Inventory();
return { world, resourceData, inventory };
}
function addPlayer(world: ReturnType<typeof createWorld>, x: number, y: number): number {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, PlayerTag);
Position.x[eid] = x;
Position.y[eid] = y;
return eid;
}
function addResource(
world: ReturnType<typeof createWorld>,
resourceData: Map<number, ResourceInfo>,
x: number,
y: number,
itemId: string,
quantity: number,
range = 40,
): number {
const eid = addEntity(world);
addComponent(world, eid, Position);
addComponent(world, eid, Resource);
Position.x[eid] = x;
Position.y[eid] = y;
Resource.quantity[eid] = quantity;
Resource.interactRange[eid] = range;
resourceData.set(eid, { itemId, tileX: Math.floor(x / 32), tileY: Math.floor(y / 32) });
return eid;
}
// === pickResourceElement ===
describe('pickResourceElement', () => {
it('always returns element from provided list', () => {
for (let x = 0; x < 20; x++) {
for (let y = 0; y < 20; y++) {
const el = pickResourceElement(x, y, 12345, MINERAL_ELEMENTS);
expect(MINERAL_ELEMENTS).toContain(el);
}
}
});
it('is deterministic (same inputs → same output)', () => {
const a = pickResourceElement(5, 10, 42, MINERAL_ELEMENTS);
const b = pickResourceElement(5, 10, 42, MINERAL_ELEMENTS);
expect(a).toBe(b);
});
it('varies with different positions', () => {
const results = new Set<string>();
for (let i = 0; i < 100; i++) {
results.add(pickResourceElement(i, i * 7, 42, MINERAL_ELEMENTS));
}
// Should use more than one element (statistical certainty)
expect(results.size).toBeGreaterThan(1);
});
it('works with geyser elements', () => {
const el = pickResourceElement(3, 7, 42, GEYSER_ELEMENTS);
expect(GEYSER_ELEMENTS).toContain(el);
});
});
// === Interaction System ===
describe('interactionSystem — collection', () => {
it('collects element when in range and pressing E', () => {
const { world, resourceData, inventory } = createTestWorld();
addPlayer(world, 100, 100);
addResource(world, resourceData, 120, 100, 'Fe', 5);
const result = interactionSystem(world, true, inventory, resourceData);
expect(result).not.toBeNull();
expect(result!.type).toBe('collected');
expect(result!.itemId).toBe('Fe');
expect(inventory.getCount('Fe')).toBe(1);
});
it('does nothing when E is not pressed', () => {
const { world, resourceData, inventory } = createTestWorld();
addPlayer(world, 100, 100);
addResource(world, resourceData, 120, 100, 'Fe', 5);
const result = interactionSystem(world, false, inventory, resourceData);
expect(result).toBeNull();
expect(inventory.getCount('Fe')).toBe(0);
});
it('does nothing when no resources in range', () => {
const { world, resourceData, inventory } = createTestWorld();
addPlayer(world, 100, 100);
addResource(world, resourceData, 500, 500, 'Fe', 5); // far away
const result = interactionSystem(world, true, inventory, resourceData);
expect(result).not.toBeNull();
expect(result!.type).toBe('nothing_nearby');
});
it('picks closest resource when multiple in range', () => {
const { world, resourceData, inventory } = createTestWorld();
addPlayer(world, 100, 100);
addResource(world, resourceData, 130, 100, 'Cu', 3); // 30px away
addResource(world, resourceData, 115, 100, 'Fe', 5); // 15px away (closer)
const result = interactionSystem(world, true, inventory, resourceData);
expect(result!.itemId).toBe('Fe'); // picked closer one
});
it('decrements resource quantity on collection', () => {
const { world, resourceData, inventory } = createTestWorld();
addPlayer(world, 100, 100);
const resEid = addResource(world, resourceData, 120, 100, 'Fe', 3);
interactionSystem(world, true, inventory, resourceData);
expect(Resource.quantity[resEid]).toBe(2);
});
});
describe('interactionSystem — depletion', () => {
it('depletes resource when quantity reaches 0', () => {
const { world, resourceData, inventory } = createTestWorld();
addPlayer(world, 100, 100);
addResource(world, resourceData, 120, 100, 'Fe', 1);
const result = interactionSystem(world, true, inventory, resourceData);
expect(result!.type).toBe('depleted');
expect(result!.itemId).toBe('Fe');
expect(resourceData.size).toBe(0);
});
it('removes entity from world on depletion', () => {
const { world, resourceData, inventory } = createTestWorld();
addPlayer(world, 100, 100);
addResource(world, resourceData, 120, 100, 'Fe', 1);
interactionSystem(world, true, inventory, resourceData);
const remaining = query(world, [Resource]);
expect(remaining.length).toBe(0);
});
});
describe('interactionSystem — inventory full', () => {
it('reports inventory_full when cannot add', () => {
const { world, resourceData } = createTestWorld();
const inventory = new Inventory(1, 1); // very small
inventory.addItem('H', 1); // fills it up
addPlayer(world, 100, 100);
addResource(world, resourceData, 120, 100, 'Fe', 5);
const result = interactionSystem(world, true, inventory, resourceData);
expect(result!.type).toBe('inventory_full');
expect(Resource.quantity[query(world, [Resource])[0]]).toBe(5); // not consumed
});
});