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