From 9521b7951cefa5814b86bc0be02ea16c1614c599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B5=D0=BD=D0=B8=D1=81=20=D0=A8=D0=BA=D0=B0=D0=B1?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=80?= Date: Thu, 12 Feb 2026 14:15:58 +0300 Subject: [PATCH] feat(creatures): add interaction system and GameScene integration - Projectile-creature collision with armor-based damage reduction - Creature observation system (health/energy/stage when near player) - Creature-to-player melee attacks with cooldown - Full GameScene integration: AI, metabolism, lifecycle, reproduction, interaction - Debug overlay shows creature count - 16 new interaction tests (288 total) Co-authored-by: Cursor --- src/creatures/index.ts | 7 + src/creatures/interaction.ts | 214 ++++++++++++++++++++ src/scenes/GameScene.ts | 114 ++++++++++- tests/creature-interaction.test.ts | 306 +++++++++++++++++++++++++++++ 4 files changed, 635 insertions(+), 6 deletions(-) create mode 100644 src/creatures/interaction.ts create mode 100644 tests/creature-interaction.test.ts diff --git a/src/creatures/index.ts b/src/creatures/index.ts index acc4fb5..b372e68 100644 --- a/src/creatures/index.ts +++ b/src/creatures/index.ts @@ -12,3 +12,10 @@ export { metabolismSystem, clearMetabolismTracking, resetMetabolismTracking } fr export { lifeCycleSystem } from './lifecycle'; export type { LifeCycleEvent } from './lifecycle'; export { countPopulations, spawnInitialCreatures, reproduce } from './population'; + +export { + creatureProjectileSystem, + getObservableCreatures, + creatureAttackPlayerSystem, +} from './interaction'; +export type { CreatureObservation, ProjectileHit } from './interaction'; diff --git a/src/creatures/interaction.ts b/src/creatures/interaction.ts new file mode 100644 index 0000000..07426e1 --- /dev/null +++ b/src/creatures/interaction.ts @@ -0,0 +1,214 @@ +/** + * Creature Interaction — projectile damage, player observation, basic taming + * + * Handles projectile-creature collision and player proximity detection. + */ + +import { query } from 'bitecs'; +import type { World } from '../ecs/world'; +import { + Position, + Health, + Creature, + AI, + LifeCycle, + Projectile, + PlayerTag, + Metabolism, +} from '../ecs/components'; +import { AIState, LifeStage } from './types'; +import type { SpeciesData } from './types'; +import type { ProjectileData } from '../player/projectile'; +import { removeGameEntity } from '../ecs/factory'; + +/** Projectile hit radius squared (projectile + creature overlap) */ +const HIT_RADIUS_SQ = 20 * 20; + +/** Observation range — how close player must be to observe a creature */ +const OBSERVE_RANGE_SQ = 60 * 60; + +/** Base projectile damage */ +const PROJECTILE_BASE_DAMAGE = 15; + +/** Result of a creature observation */ +export interface CreatureObservation { + eid: number; + speciesId: number; + healthPercent: number; + energyPercent: number; + stage: LifeStage; + aiState: AIState; +} + +/** Result of projectile-creature collision */ +export interface ProjectileHit { + creatureEid: number; + projectileEid: number; + damage: number; + killed: boolean; + speciesId: number; +} + +/** + * Check projectile-creature collisions. + * Removes hitting projectiles and deals damage to creatures. + * + * @returns Array of hits that occurred + */ +export function creatureProjectileSystem( + world: World, + projData: Map, + speciesLookup: Map, +): ProjectileHit[] { + const hits: ProjectileHit[] = []; + const projectiles = query(world, [Position, Projectile]); + const creatures = query(world, [Creature, Position, Health, LifeCycle]); + + const projectilesToRemove: number[] = []; + + for (const projEid of projectiles) { + const px = Position.x[projEid]; + const py = Position.y[projEid]; + + for (const cEid of creatures) { + const dx = px - Position.x[cEid]; + const dy = py - Position.y[cEid]; + const dSq = dx * dx + dy * dy; + + if (dSq <= HIT_RADIUS_SQ) { + // Hit! Calculate damage (with armor reduction) + const species = speciesLookup.get(Creature.speciesId[cEid]); + const armor = species?.armor ?? 0; + const damage = Math.round(PROJECTILE_BASE_DAMAGE * (1 - armor)); + + Health.current[cEid] -= damage; + const killed = Health.current[cEid] <= 0; + + hits.push({ + creatureEid: cEid, + projectileEid: projEid, + damage, + killed, + speciesId: Creature.speciesId[cEid], + }); + + // Creature becomes aggressive or flees + if (!killed && species) { + if (species.aggressionRadius > 0) { + // Territorial/aggressive → attack the player + AI.state[cEid] = AIState.Attack; + AI.stateTimer[cEid] = 5000; + // Find nearest player to target + const players = query(world, [PlayerTag, Position]); + if (players.length > 0) { + AI.targetEid[cEid] = players[0]; + } + } else { + // Passive → flee + AI.state[cEid] = AIState.Flee; + AI.stateTimer[cEid] = 3000; + } + } + + projectilesToRemove.push(projEid); + break; // one hit per projectile + } + } + } + + // Remove hit projectiles + for (const eid of projectilesToRemove) { + removeGameEntity(world, eid); + projData.delete(eid); + } + + return hits; +} + +/** + * Get observable creatures near the player. + * Returns info about creatures within observation range. + */ +export function getObservableCreatures( + world: World, +): CreatureObservation[] { + const observations: CreatureObservation[] = []; + const players = query(world, [PlayerTag, Position]); + if (players.length === 0) return observations; + + const playerX = Position.x[players[0]]; + const playerY = Position.y[players[0]]; + + const creatures = query(world, [Creature, Position, Health, LifeCycle, Metabolism]); + + for (const eid of creatures) { + const dx = playerX - Position.x[eid]; + const dy = playerY - Position.y[eid]; + const dSq = dx * dx + dy * dy; + + if (dSq <= OBSERVE_RANGE_SQ) { + observations.push({ + eid, + speciesId: Creature.speciesId[eid], + healthPercent: Health.max[eid] > 0 + ? Math.round((Health.current[eid] / Health.max[eid]) * 100) + : 0, + energyPercent: Metabolism.energyMax[eid] > 0 + ? Math.round((Metabolism.energy[eid] / Metabolism.energyMax[eid]) * 100) + : 0, + stage: LifeCycle.stage[eid] as LifeStage, + aiState: AI.state[eid] as AIState, + }); + } + } + + return observations; +} + +/** + * Check if player is near any creature for attack damage (creature → player). + * Returns total damage dealt to player this frame. + */ +export function creatureAttackPlayerSystem( + world: World, + speciesLookup: Map, +): number { + let totalDamage = 0; + + const players = query(world, [PlayerTag, Position, Health]); + if (players.length === 0) return 0; + + const playerEid = players[0]; + const playerX = Position.x[playerEid]; + const playerY = Position.y[playerEid]; + + const creatures = query(world, [Creature, AI, Position, LifeCycle]); + + for (const eid of creatures) { + if (AI.state[eid] !== AIState.Attack) continue; + if (AI.targetEid[eid] !== playerEid) continue; + if (LifeCycle.stage[eid] === LifeStage.Egg) continue; + + const species = speciesLookup.get(Creature.speciesId[eid]); + if (!species) continue; + + const dx = playerX - Position.x[eid]; + const dy = playerY - Position.y[eid]; + const dSq = dx * dx + dy * dy; + const rangeSq = species.attackRange * species.attackRange; + + if (dSq <= rangeSq && AI.attackCooldown[eid] <= 0) { + const dmg = LifeCycle.stage[eid] === LifeStage.Youth + ? species.damage * 0.5 + : species.damage; + totalDamage += dmg; + AI.attackCooldown[eid] = species.attackCooldown; + } + } + + if (totalDamage > 0) { + Health.current[playerEid] -= totalDamage; + } + + return totalDamage; +} diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 2658d2a..9bf5222 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -1,11 +1,12 @@ import Phaser from 'phaser'; import { createGameWorld, updateTime, type GameWorld } from '../ecs/world'; -import { Health, Position } from '../ecs/components'; +import { Health, Position, Creature, LifeCycle } from '../ecs/components'; import { movementSystem } from '../ecs/systems/movement'; import { healthSystem } from '../ecs/systems/health'; import { removeGameEntity } from '../ecs/factory'; import { PhaserBridge } from '../ecs/bridge'; import biomeDataArray from '../data/biomes.json'; +import speciesDataArray from '../data/creatures.json'; import type { BiomeData } from '../world/types'; import { generateWorld } from '../world/generator'; import { createWorldTilemap } from '../world/tilemap'; @@ -25,6 +26,22 @@ import { } from '../player/projectile'; import { QuickSlots } from '../player/quickslots'; import type { InputState } from '../player/types'; +import type { SpeciesData, CreatureInfo } from '../creatures/types'; +import { SpeciesRegistry } from '../creatures/types'; +import { aiSystem } from '../creatures/ai'; +import { metabolismSystem, clearMetabolismTracking } from '../creatures/metabolism'; +import { lifeCycleSystem } from '../creatures/lifecycle'; +import { + countPopulations, + spawnInitialCreatures, + reproduce, +} from '../creatures/population'; +import { + creatureProjectileSystem, + getObservableCreatures, + creatureAttackPlayerSystem, +} from '../creatures/interaction'; +import { query } from 'bitecs'; export class GameScene extends Phaser.Scene { private gameWorld!: GameWorld; @@ -55,6 +72,11 @@ export class GameScene extends Phaser.Scene { FOUR: Phaser.Input.Keyboard.Key; }; + // Creature state + private speciesRegistry!: SpeciesRegistry; + private speciesLookup!: Map; + private creatureData!: Map; + // Interaction feedback private interactionText!: Phaser.GameObjects.Text; private interactionTimer = 0; @@ -89,7 +111,20 @@ export class GameScene extends Phaser.Scene { this.gameWorld.world, worldData.grid, biome, this.worldSeed, ); - // 6. Create player at spawn position + inventory + // 6. Initialize creature systems + const allSpecies = speciesDataArray as SpeciesData[]; + this.speciesRegistry = new SpeciesRegistry(allSpecies); + this.speciesLookup = new Map(); + for (const s of allSpecies) { + this.speciesLookup.set(s.speciesId, s); + } + + // 7. Spawn creatures across the map + this.creatureData = spawnInitialCreatures( + this.gameWorld.world, worldData.grid, biome, this.worldSeed, allSpecies, + ); + + // 8. 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; @@ -97,7 +132,7 @@ export class GameScene extends Phaser.Scene { this.inventory = new Inventory(500, 20); this.quickSlots = new QuickSlots(); - // 7. Camera — follow player, zoom via scroll wheel + // 9. Camera — follow player, zoom via scroll wheel const worldPixelW = biome.mapWidth * biome.tileSize; const worldPixelH = biome.mapHeight * biome.tileSize; setupPlayerCamera(this, worldPixelW, worldPixelH); @@ -226,9 +261,54 @@ export class GameScene extends Phaser.Scene { this.tryLaunchProjectile(); } - // 9. Health / death + // 9a. Creature AI + aiSystem( + this.gameWorld.world, delta, + this.speciesLookup, this.gameWorld.time.tick, + ); + + // 9b. Creature metabolism (feeding, energy drain) + metabolismSystem( + this.gameWorld.world, delta, + this.resourceData, this.gameWorld.time.elapsed, + ); + + // 9c. Creature life cycle (aging, stage transitions) + const lcEvents = lifeCycleSystem( + this.gameWorld.world, delta, this.speciesLookup, + ); + + // 9d. Handle reproduction events + const populations = countPopulations(this.gameWorld.world); + for (const event of lcEvents) { + if (event.type === 'ready_to_reproduce') { + const species = this.speciesLookup.get(event.speciesId); + if (species) { + const currentPop = populations.get(event.speciesId) ?? 0; + reproduce( + this.gameWorld.world, event.eid, species, + currentPop, this.creatureData, + ); + } + } + } + + // 9e. Projectile-creature collision + creatureProjectileSystem( + this.gameWorld.world, this.projectileData, this.speciesLookup, + ); + + // 9f. Creature attacks on player + creatureAttackPlayerSystem(this.gameWorld.world, this.speciesLookup); + + // 10. Health / death const dead = healthSystem(this.gameWorld.world); for (const eid of dead) { + // Clean up creature tracking + if (this.creatureData.has(eid)) { + clearMetabolismTracking(eid); + this.creatureData.delete(eid); + } removeGameEntity(this.gameWorld.world, eid); } @@ -262,11 +342,33 @@ export class GameScene extends Phaser.Scene { } this.registry.set('invCounts', counts); - // 14. Debug stats overlay + // 15. Creature observation for UIScene + const nearbyCreatures = getObservableCreatures(this.gameWorld.world); + if (nearbyCreatures.length > 0) { + const closest = nearbyCreatures[0]; + const species = this.speciesLookup.get(closest.speciesId); + this.registry.set('observedCreature', { + name: species?.name ?? 'Unknown', + healthPercent: closest.healthPercent, + energyPercent: closest.energyPercent, + stage: closest.stage, + }); + } else { + this.registry.set('observedCreature', null); + } + + // 16. Population counts for debug + const popCounts = countPopulations(this.gameWorld.world); + this.registry.set('creaturePopulations', popCounts); + + // 17. Debug stats overlay 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]); - this.statsText.setText(`seed: ${this.worldSeed} | ${fps} fps | pos: ${px},${py}`); + const creatureCount = [...popCounts.values()].reduce((a, b) => a + b, 0); + this.statsText.setText( + `seed: ${this.worldSeed} | ${fps} fps | pos: ${px},${py} | creatures: ${creatureCount}`, + ); } /** Try to launch a projectile from active quick slot toward mouse */ diff --git a/tests/creature-interaction.test.ts b/tests/creature-interaction.test.ts new file mode 100644 index 0000000..5f43b5c --- /dev/null +++ b/tests/creature-interaction.test.ts @@ -0,0 +1,306 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createWorld, addEntity, addComponent, query } from 'bitecs'; +import type { World } from '../src/ecs/world'; +import { + Position, + Health, + Creature, + AI, + Metabolism, + LifeCycle, + Projectile, + PlayerTag, + SpriteRef, + Velocity, +} from '../src/ecs/components'; +import { SpeciesId, AIState, LifeStage } from '../src/creatures/types'; +import type { SpeciesData, CreatureInfo } from '../src/creatures/types'; +import { createCreatureEntity } from '../src/creatures/factory'; +import { + creatureProjectileSystem, + getObservableCreatures, + creatureAttackPlayerSystem, +} from '../src/creatures/interaction'; +import type { ProjectileData } from '../src/player/projectile'; +import speciesDataArray from '../src/data/creatures.json'; + +// ─── Helpers ───────────────────────────────────────────────────── + +const allSpecies = speciesDataArray as SpeciesData[]; + +function getSpecies(id: string): SpeciesData { + const s = allSpecies.find(s => s.id === id); + if (!s) throw new Error(`Species not found: ${id}`); + return s; +} + +function buildSpeciesLookup(): Map { + const map = new Map(); + for (const s of allSpecies) map.set(s.speciesId, s); + return map; +} + +function createProjectile(world: World, x: number, y: number, projData: Map): number { + const eid = addEntity(world); + addComponent(world, eid, Position); + addComponent(world, eid, Projectile); + addComponent(world, eid, SpriteRef); + addComponent(world, eid, Velocity); + Position.x[eid] = x; + Position.y[eid] = y; + Projectile.lifetime[eid] = 2000; + SpriteRef.color[eid] = 0xffffff; + SpriteRef.radius[eid] = 5; + projData.set(eid, { itemId: 'Na' }); + return eid; +} + +function createPlayer(world: World, x: number, y: number): number { + const eid = addEntity(world); + addComponent(world, eid, Position); + addComponent(world, eid, PlayerTag); + addComponent(world, eid, Health); + Position.x[eid] = x; + Position.y[eid] = y; + Health.current[eid] = 100; + Health.max[eid] = 100; + return eid; +} + +// ─── Projectile-Creature Collision ─────────────────────────────── + +describe('Projectile-Creature Collision', () => { + let world: World; + const speciesLookup = buildSpeciesLookup(); + + beforeEach(() => { + world = createWorld(); + }); + + it('projectile hits creature within range', () => { + const species = getSpecies('crystallid'); + const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + const hpBefore = Health.current[creature]; + + const projData = new Map(); + createProjectile(world, 105, 100, projData); // 5px away, within 20px hit radius + + const hits = creatureProjectileSystem(world, projData, speciesLookup); + + expect(hits).toHaveLength(1); + expect(hits[0].creatureEid).toBe(creature); + expect(hits[0].killed).toBe(false); + expect(Health.current[creature]).toBeLessThan(hpBefore); + }); + + it('projectile is removed after hitting creature', () => { + const species = getSpecies('crystallid'); + createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + + const projData = new Map(); + const projEid = createProjectile(world, 105, 100, projData); + + creatureProjectileSystem(world, projData, speciesLookup); + + // Projectile should be removed + expect(projData.has(projEid)).toBe(false); + expect([...query(world, [Projectile])]).not.toContain(projEid); + }); + + it('armor reduces damage (Crystallid has 0.3 armor)', () => { + const species = getSpecies('crystallid'); // armor = 0.3 + const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + + const projData = new Map(); + createProjectile(world, 105, 100, projData); + + const hits = creatureProjectileSystem(world, projData, speciesLookup); + + // Base damage 15, armor 0.3 → 15 * 0.7 = 10.5 → rounded to 11 + expect(hits[0].damage).toBe(11); + }); + + it('Reagent takes full damage (0 armor)', () => { + const species = getSpecies('reagent'); // armor = 0 + const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + + const projData = new Map(); + createProjectile(world, 105, 100, projData); + + const hits = creatureProjectileSystem(world, projData, speciesLookup); + + expect(hits[0].damage).toBe(15); + }); + + it('projectile misses creature outside range', () => { + const species = getSpecies('crystallid'); + createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + + const projData = new Map(); + createProjectile(world, 200, 200, projData); // far away + + const hits = creatureProjectileSystem(world, projData, speciesLookup); + + expect(hits).toHaveLength(0); + }); + + it('passive creature flees after being hit', () => { + const species = getSpecies('crystallid'); // aggressionRadius = 0 → passive + const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + + const projData = new Map(); + createProjectile(world, 105, 100, projData); + + creatureProjectileSystem(world, projData, speciesLookup); + + expect(AI.state[creature]).toBe(AIState.Flee); + }); + + it('aggressive creature attacks player after being hit', () => { + const species = getSpecies('acidophile'); // aggressionRadius = 100 → aggressive + const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + const player = createPlayer(world, 150, 100); + + const projData = new Map(); + createProjectile(world, 105, 100, projData); + + creatureProjectileSystem(world, projData, speciesLookup); + + expect(AI.state[creature]).toBe(AIState.Attack); + expect(AI.targetEid[creature]).toBe(player); + }); + + it('killing a creature reports killed=true', () => { + const species = getSpecies('reagent'); // 60 hp + const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + Health.current[creature] = 10; // nearly dead + + const projData = new Map(); + createProjectile(world, 105, 100, projData); + + const hits = creatureProjectileSystem(world, projData, speciesLookup); + + expect(hits[0].killed).toBe(true); + }); +}); + +// ─── Creature Observation ──────────────────────────────────────── + +describe('Creature Observation', () => { + let world: World; + + beforeEach(() => { + world = createWorld(); + }); + + it('detects creature near player', () => { + const species = getSpecies('crystallid'); + createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + createPlayer(world, 120, 100); // 20px away, within 60px observe range + + const observations = getObservableCreatures(world); + + expect(observations).toHaveLength(1); + expect(observations[0].speciesId).toBe(SpeciesId.Crystallid); + expect(observations[0].stage).toBe(LifeStage.Mature); + }); + + it('does not detect distant creature', () => { + const species = getSpecies('crystallid'); + createCreatureEntity(world, species, 500, 500, LifeStage.Mature); + createPlayer(world, 100, 100); + + const observations = getObservableCreatures(world); + + expect(observations).toHaveLength(0); + }); + + it('returns health and energy percentages', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + Health.current[eid] = 60; // 50% of 120 + Metabolism.energy[eid] = 50; // 50% of 100 + createPlayer(world, 110, 100); + + const observations = getObservableCreatures(world); + + expect(observations[0].healthPercent).toBe(50); + expect(observations[0].energyPercent).toBe(50); + }); + + it('returns empty array when no player', () => { + const species = getSpecies('crystallid'); + createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + + const observations = getObservableCreatures(world); + + expect(observations).toHaveLength(0); + }); +}); + +// ─── Creature Attacks Player ───────────────────────────────────── + +describe('Creature Attacks Player', () => { + let world: World; + const speciesLookup = buildSpeciesLookup(); + + beforeEach(() => { + world = createWorld(); + }); + + it('attacking creature damages player in range', () => { + const species = getSpecies('reagent'); // attackRange = 18, damage = 20 + const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + const player = createPlayer(world, 110, 100); // 10px away + + AI.state[creature] = AIState.Attack; + AI.targetEid[creature] = player; + AI.attackCooldown[creature] = 0; + + const damage = creatureAttackPlayerSystem(world, speciesLookup); + + expect(damage).toBe(20); + expect(Health.current[player]).toBe(80); + }); + + it('no damage if creature is out of range', () => { + const species = getSpecies('reagent'); + const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + const player = createPlayer(world, 200, 100); // 100px, beyond attackRange 18 + + AI.state[creature] = AIState.Attack; + AI.targetEid[creature] = player; + AI.attackCooldown[creature] = 0; + + const damage = creatureAttackPlayerSystem(world, speciesLookup); + + expect(damage).toBe(0); + }); + + it('attack cooldown prevents rapid attacks', () => { + const species = getSpecies('reagent'); + const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + const player = createPlayer(world, 110, 100); + + AI.state[creature] = AIState.Attack; + AI.targetEid[creature] = player; + AI.attackCooldown[creature] = 500; // on cooldown + + const damage = creatureAttackPlayerSystem(world, speciesLookup); + + expect(damage).toBe(0); + }); + + it('non-attacking creature does not damage player', () => { + const species = getSpecies('crystallid'); + const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + const player = createPlayer(world, 110, 100); + + AI.state[creature] = AIState.Wander; // not attacking + AI.attackCooldown[creature] = 0; + + const damage = creatureAttackPlayerSystem(world, speciesLookup); + + expect(damage).toBe(0); + }); +});