diff --git a/src/ecs/components.ts b/src/ecs/components.ts index 37b09ad..ccd6a43 100644 --- a/src/ecs/components.ts +++ b/src/ecs/components.ts @@ -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 +}; diff --git a/src/player/interaction.ts b/src/player/interaction.ts new file mode 100644 index 0000000..021c605 --- /dev/null +++ b/src/player/interaction.ts @@ -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, +): 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], + }; +} diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 5d1dc95..1158333 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -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; private worldGrid!: number[][]; private tileSize!: number; + private resourceData!: Map; 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 + } } diff --git a/src/world/resources.ts b/src/world/resources.ts new file mode 100644 index 0000000..e46c7d0 --- /dev/null +++ b/src/world/resources.ts @@ -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 { + const resourceData = new Map(); + + // 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; +} diff --git a/tests/interaction.test.ts b/tests/interaction.test.ts new file mode 100644 index 0000000..c2af2de --- /dev/null +++ b/tests/interaction.test.ts @@ -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(); + const inventory = new Inventory(); + return { world, resourceData, inventory }; +} + +function addPlayer(world: ReturnType, 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, + resourceData: Map, + 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(); + 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 + }); +});