Add 3 new schools with real scientific principles (Lever & Moment, Photosynthesis, Angular Measurement). Data-driven unlock conditions check codex elements, creature discoveries, and completed runs. Each school provides passive gameplay bonuses (projectile damage, movement speed, creature aggro range, reaction efficiency) applied through system multiplier parameters. CradleScene shows all 4 schools with locked ones grayed out and showing unlock hints. 24 new tests (511 total). Co-authored-by: Cursor <cursoragent@cursor.com>
217 lines
6.1 KiB
TypeScript
217 lines
6.1 KiB
TypeScript
/**
|
|
* 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.
|
|
*
|
|
* @param damageMultiplier — optional multiplier on projectile damage (default 1.0, e.g. Mechanic bonus)
|
|
* @returns Array of hits that occurred
|
|
*/
|
|
export function creatureProjectileSystem(
|
|
world: World,
|
|
projData: Map<number, ProjectileData>,
|
|
speciesLookup: Map<number, SpeciesData>,
|
|
damageMultiplier = 1.0,
|
|
): 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 and school bonus)
|
|
const species = speciesLookup.get(Creature.speciesId[cEid]);
|
|
const armor = species?.armor ?? 0;
|
|
const damage = Math.round(PROJECTILE_BASE_DAMAGE * (1 - armor) * damageMultiplier);
|
|
|
|
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, SpeciesData>,
|
|
): 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;
|
|
}
|