Files
rogue/src/engine/simulation/simulation.ts

305 lines
8.9 KiB
TypeScript

import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types";
import { calculateDamage } from "../gameplay/CombatLogic";
import { isBlocked, tryDestructTile } from "../world/world-logic";
import { isDestructibleByWalk } from "../../core/terrain";
import { GAME_CONFIG } from "../../core/config/GameConfig";
import { type EntityAccessor } from "../EntityAccessor";
import { AISystem } from "../ecs/AISystem";
import { Prefabs } from "../ecs/Prefabs";
export function applyAction(w: World, actorId: EntityId, action: Action, accessor: EntityAccessor): SimEvent[] {
const actor = accessor.getActor(actorId);
if (!actor) return [];
const events: SimEvent[] = [];
switch (action.type) {
case "move":
events.push(...handleMove(w, actor, action, accessor));
break;
case "attack":
events.push(...handleAttack(w, actor, action, accessor));
break;
case "throw":
break;
case "wait":
default:
events.push({ type: "waited", actorId });
break;
}
return events;
}
function handleExpCollection(player: Actor, events: SimEvent[], accessor: EntityAccessor) {
if (player.category !== "combatant") return;
const actorsAtPos = accessor.getActorsAt(player.pos.x, player.pos.y);
const orbs = actorsAtPos.filter(a =>
a.category === "collectible" &&
a.type === "exp_orb"
) as CollectibleActor[];
for (const orb of orbs) {
const amount = orb.expAmount || 0;
player.stats.exp += amount;
events.push({
type: "exp-collected",
actorId: player.id,
amount,
x: player.pos.x,
y: player.pos.y
});
checkLevelUp(player, events);
accessor.removeActor(orb.id);
}
}
function checkLevelUp(player: CombatantActor, events: SimEvent[]) {
const s = player.stats;
while (s.exp >= s.expToNextLevel) {
s.level++;
s.exp -= s.expToNextLevel;
// Growth
s.maxHp += GAME_CONFIG.leveling.hpGainPerLevel;
s.hp = s.maxHp; // Heal on level up
s.maxMana += GAME_CONFIG.leveling.manaGainPerLevel;
s.mana = s.maxMana; // Restore mana on level up
s.attack += GAME_CONFIG.leveling.attackGainPerLevel;
s.statPoints += GAME_CONFIG.leveling.statPointsPerLevel;
s.skillPoints += GAME_CONFIG.leveling.skillPointsPerLevel;
// Scale requirement
s.expToNextLevel = Math.floor(s.expToNextLevel * GAME_CONFIG.leveling.expMultiplier);
events.push({
type: "leveled-up",
actorId: player.id,
level: s.level,
x: player.pos.x,
y: player.pos.y
});
}
}
function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }, accessor: EntityAccessor): SimEvent[] {
const from = { ...actor.pos };
const nx = actor.pos.x + action.dx;
const ny = actor.pos.y + action.dy;
if (!isBlocked(w, nx, ny, accessor)) {
actor.pos.x = nx;
actor.pos.y = ny;
const to = { ...actor.pos };
const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
const tileIdx = ny * w.width + nx;
if (isDestructibleByWalk(w.tiles[tileIdx])) {
tryDestructTile(w, nx, ny);
}
if (actor.category === "combatant" && actor.isPlayer) {
handleExpCollection(actor, events, accessor);
}
return events;
}
return [{ type: "move-blocked", actorId: actor.id, x: nx, y: ny }];
}
function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, accessor: EntityAccessor): SimEvent[] {
const target = accessor.getActor(action.targetId);
if (target && target.category === "combatant" && actor.category === "combatant") {
const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }];
// 1. Calculate Damage
const result = calculateDamage(actor.stats, target.stats);
if (!result.hit) {
events.push({
type: "dodged",
targetId: action.targetId,
x: target.pos.x,
y: target.pos.y
});
return events;
}
const dmg = result.dmg;
const isCrit = result.isCrit;
const isBlock = result.isBlock;
target.stats.hp -= dmg;
if (target.stats.hp > 0 && target.category === "combatant" && !target.isPlayer) {
target.aiState = "pursuing";
target.alertedAt = Date.now();
if (actor.pos) {
target.lastKnownPlayerPos = { ...actor.pos };
}
}
// 5. Lifesteal Logic
if (actor.stats.lifesteal > 0 && dmg > 0) {
const healAmount = Math.floor(dmg * (actor.stats.lifesteal / 100));
if (healAmount > 0) {
actor.stats.hp = Math.min(actor.stats.maxHp, actor.stats.hp + healAmount);
events.push({
type: "healed",
actorId: actor.id,
amount: healAmount,
x: actor.pos.x,
y: actor.pos.y
});
}
}
events.push({
type: "damaged",
targetId: action.targetId,
amount: dmg,
hp: target.stats.hp,
x: target.pos.x,
y: target.pos.y,
isCrit,
isBlock
});
if (target.stats.hp <= 0) {
events.push({
type: "killed",
targetId: target.id,
killerId: actor.id,
x: target.pos.x,
y: target.pos.y,
victimType: target.type as ActorType
});
accessor.removeActor(target.id);
// Extinguish fire at the death position
const ecsWorld = accessor.context;
if (ecsWorld) {
const firesAtPos = ecsWorld.getEntitiesWith("position", "name").filter(id => {
const p = ecsWorld.getComponent(id, "position");
const n = ecsWorld.getComponent(id, "name");
return p?.x === target.pos.x && p?.y === target.pos.y && n?.name === "Fire";
});
for (const fireId of firesAtPos) {
ecsWorld.destroyEntity(fireId);
}
}
// Spawn EXP Orb
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
const expAmount = enemyDef?.expValue || 0;
if (ecsWorld) {
const orbId = Prefabs.expOrb(ecsWorld, target.pos.x, target.pos.y, expAmount);
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
}
}
return events;
}
return [{ type: "waited", actorId: actor.id }];
}
/**
* Enemy AI with state machine:
* - Wandering: Random movement when can't see player
* - Alerted: Brief period after spotting player (shows "!")
* - Pursuing: Chase player while in FOV or toward last known position
*/
export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor, accessor: EntityAccessor): { action: Action; justAlerted: boolean } {
const ecsWorld = accessor.context;
if (ecsWorld) {
const aiSystem = new AISystem(ecsWorld, w, accessor);
const result = aiSystem.update(enemy.id, player.id);
const aiComp = ecsWorld.getComponent(enemy.id, "ai");
if (aiComp) {
enemy.aiState = aiComp.state;
enemy.alertedAt = aiComp.alertedAt;
enemy.lastKnownPlayerPos = aiComp.lastKnownPlayerPos;
}
return result;
}
return { action: { type: "wait" }, justAlerted: false };
}
/**
* Speed-based scheduler using rot-js: runs until it's the player's turn and the game needs input.
* Returns enemy events accumulated along the way.
*/
export function stepUntilPlayerTurn(w: World, playerId: EntityId, accessor: EntityAccessor): { awaitingPlayerId: EntityId; events: SimEvent[] } {
const THRESHOLD = 100;
const player = accessor.getCombatant(playerId);
if (!player) throw new Error("Player missing or invalid");
const events: SimEvent[] = [];
if (player.energy >= THRESHOLD) {
player.energy -= THRESHOLD;
}
while (true) {
if (player.energy >= THRESHOLD) {
return { awaitingPlayerId: playerId, events };
}
const actors = [...accessor.getAllActors()];
for (const actor of actors) {
if (actor.category === "combatant") {
actor.energy += actor.speed;
}
}
let actionsTaken = 0;
while (true) {
const eligibleActors = accessor.getEnemies().filter(a => a.energy >= THRESHOLD);
if (eligibleActors.length === 0) break;
eligibleActors.sort((a, b) => b.energy - a.energy);
const actor = eligibleActors[0];
actor.energy -= THRESHOLD;
const decision = decideEnemyAction(w, actor, player, accessor);
if (decision.justAlerted) {
events.push({
type: "enemy-alerted",
enemyId: actor.id,
x: actor.pos.x,
y: actor.pos.y
});
}
events.push(...applyAction(w, actor.id, decision.action, accessor));
if (!accessor.isPlayerAlive()) {
return { awaitingPlayerId: null as any, events };
}
actionsTaken++;
if (actionsTaken > 1000) break;
}
}
}