import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types"; import { isBlocked, inBounds, isWall } from "../world/world-logic"; import { findPathAStar } from "../world/pathfinding"; import { GAME_CONFIG } from "../../core/config/GameConfig"; import { type EntityManager } from "../EntityManager"; import { FOV } from "rot-js"; export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] { const actor = w.actors.get(actorId); if (!actor) return []; const events: SimEvent[] = []; switch (action.type) { case "move": events.push(...handleMove(w, actor, action, em)); break; case "attack": events.push(...handleAttack(w, actor, action, em)); break; case "wait": default: events.push({ type: "waited", actorId }); break; } // Spend energy for any action (move/wait/attack) if (actor.category === "combatant") { actor.energy -= GAME_CONFIG.gameplay.actionCost; } return events; } function handleExpCollection(w: World, player: Actor, events: SimEvent[], em?: EntityManager) { if (player.category !== "combatant") return; const orbs = [...w.actors.values()].filter(a => a.category === "collectible" && a.type === "exp_orb" && a.pos.x === player.pos.x && a.pos.y === player.pos.y ) 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); if (em) em.removeActor(orb.id); else w.actors.delete(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.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 }, em?: EntityManager): SimEvent[] { const from = { ...actor.pos }; const nx = actor.pos.x + action.dx; const ny = actor.pos.y + action.dy; if (!isBlocked(w, nx, ny, em)) { if (em) { em.moveActor(actor.id, from, { x: nx, y: ny }); } else { actor.pos.x = nx; actor.pos.y = ny; } const to = { ...actor.pos }; const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }]; if (actor.category === "combatant" && actor.isPlayer) { handleExpCollection(w, actor, events, em); } return events; } else { return [{ type: "waited", actorId: actor.id }]; } } function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em?: EntityManager): SimEvent[] { const target = w.actors.get(action.targetId); if (target && target.category === "combatant" && actor.category === "combatant") { const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }]; // 1. Accuracy vs Evasion Check const hitChance = actor.stats.accuracy - target.stats.evasion; const hitRoll = Math.random() * 100; if (hitRoll > hitChance) { // Miss! events.push({ type: "dodged", targetId: action.targetId, x: target.pos.x, y: target.pos.y }); return events; } // 2. Base Damage Calculation let dmg = Math.max(1, actor.stats.attack - target.stats.defense); // 3. Critical Strike Check const critRoll = Math.random() * 100; const isCrit = critRoll < actor.stats.critChance; if (isCrit) { dmg = Math.floor(dmg * (actor.stats.critMultiplier / 100)); } // 4. Block Chance Check const blockRoll = Math.random() * 100; let isBlock = false; if (blockRoll < target.stats.blockChance) { dmg = Math.floor(dmg * 0.5); // Block reduces damage by 50% isBlock = true; } target.stats.hp -= dmg; // 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 }); if (em) em.removeActor(target.id); else w.actors.delete(target.id); // Spawn EXP Orb const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""]; const expAmount = enemyDef?.expValue || 0; const orbId = em ? em.getNextId() : Math.max(0, ...w.actors.keys(), target.id) + 1; const orb: CollectibleActor = { id: orbId, category: "collectible", type: "exp_orb", pos: { ...target.pos }, expAmount }; if (em) em.addActor(orb); else w.actors.set(orbId, orb); events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y }); } return events; } return [{ type: "waited", actorId: actor.id }]; } /** * Check if an enemy can see the player using FOV calculation */ function canEnemySeePlayer(w: World, enemy: CombatantActor, player: CombatantActor): boolean { const viewRadius = 8; // Enemy vision range let canSee = false; const fov = new FOV.PreciseShadowcasting((x: number, y: number) => { if (!inBounds(w, x, y)) return false; return !isWall(w, x, y); }); fov.compute(enemy.pos.x, enemy.pos.y, viewRadius, (x: number, y: number) => { if (x === player.pos.x && y === player.pos.y) { canSee = true; } }); return canSee; } /** * Get a random wander move for an enemy */ function getRandomWanderMove(w: World, enemy: CombatantActor, em?: EntityManager): Action { const directions = [ { dx: 0, dy: -1 }, // up { dx: 0, dy: 1 }, // down { dx: -1, dy: 0 }, // left { dx: 1, dy: 0 }, // right ]; // Shuffle directions for (let i = directions.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [directions[i], directions[j]] = [directions[j], directions[i]]; } // Try each direction, return first valid one for (const dir of directions) { const nx = enemy.pos.x + dir.dx; const ny = enemy.pos.y + dir.dy; if (!isBlocked(w, nx, ny, em)) { return { type: "move", ...dir }; } } // If no valid move, wait return { type: "wait" }; } /** * 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, em?: EntityManager): { action: Action; justAlerted: boolean } { // Initialize AI state if not set if (!enemy.aiState) { enemy.aiState = "wandering"; } const canSee = canEnemySeePlayer(w, enemy, player); const dx = player.pos.x - enemy.pos.x; const dy = player.pos.y - enemy.pos.y; const dist = Math.abs(dx) + Math.abs(dy); // State transitions let justAlerted = false; if (canSee && enemy.aiState === "wandering") { // Spotted player! Transition to alerted state enemy.aiState = "alerted"; enemy.alertedAt = Date.now(); enemy.lastKnownPlayerPos = { ...player.pos }; justAlerted = true; } else if (enemy.aiState === "alerted") { // Check if alert period is over (1 second = 1000ms) const alertDuration = 1000; if (Date.now() - (enemy.alertedAt || 0) > alertDuration) { enemy.aiState = "pursuing"; } } else if (enemy.aiState === "pursuing") { if (canSee) { // Update last known position enemy.lastKnownPlayerPos = { ...player.pos }; } else { // Lost sight - check if we've reached last known position if (enemy.lastKnownPlayerPos) { const distToLastKnown = Math.abs(enemy.pos.x - enemy.lastKnownPlayerPos.x) + Math.abs(enemy.pos.y - enemy.lastKnownPlayerPos.y); if (distToLastKnown <= 1) { // Reached last known position, return to wandering enemy.aiState = "wandering"; enemy.lastKnownPlayerPos = undefined; } } else { // No last known position, return to wandering enemy.aiState = "wandering"; } } } // Behavior based on current state if (enemy.aiState === "wandering") { return { action: getRandomWanderMove(w, enemy, em), justAlerted }; } if (enemy.aiState === "alerted") { // During alert, stay still (or could do small movement) return { action: { type: "wait" }, justAlerted }; } // Pursuing state - chase player or last known position const targetPos = canSee ? player.pos : (enemy.lastKnownPlayerPos || player.pos); const targetDx = targetPos.x - enemy.pos.x; const targetDy = targetPos.y - enemy.pos.y; // If adjacent to player, attack if (dist === 1 && canSee) { return { action: { type: "attack", targetId: player.id }, justAlerted }; } // Use A* for smarter pathfinding to target const dummySeen = new Uint8Array(w.width * w.height).fill(1); const path = findPathAStar(w, dummySeen, enemy.pos, targetPos, { ignoreBlockedTarget: true, ignoreSeen: true, em }); if (path.length >= 2) { const next = path[1]; const adx = next.x - enemy.pos.x; const ady = next.y - enemy.pos.y; return { action: { type: "move", dx: adx, dy: ady }, justAlerted }; } // Fallback to greedy if no path found const options: { dx: number; dy: number }[] = []; if (Math.abs(targetDx) >= Math.abs(targetDy)) { options.push({ dx: Math.sign(targetDx), dy: 0 }); options.push({ dx: 0, dy: Math.sign(targetDy) }); } else { options.push({ dx: 0, dy: Math.sign(targetDy) }); options.push({ dx: Math.sign(targetDx), dy: 0 }); } options.push({ dx: -options[0].dx, dy: -options[0].dy }); for (const o of options) { if (o.dx === 0 && o.dy === 0) continue; const nx = enemy.pos.x + o.dx; const ny = enemy.pos.y + o.dy; if (!isBlocked(w, nx, ny, em)) return { action: { type: "move", dx: o.dx, dy: o.dy }, justAlerted }; } return { action: { type: "wait" }, justAlerted }; } /** * Energy/speed scheduler: 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, em?: EntityManager): { awaitingPlayerId: EntityId; events: SimEvent[] } { const player = w.actors.get(playerId) as CombatantActor; if (!player || player.category !== "combatant") throw new Error("Player missing or invalid"); const events: SimEvent[] = []; while (true) { while (![...w.actors.values()].some(a => a.category === "combatant" && a.energy >= GAME_CONFIG.gameplay.energyThreshold)) { for (const a of w.actors.values()) { if (a.category === "combatant") { a.energy += a.speed; } } } const ready = [...w.actors.values()].filter(a => a.category === "combatant" && a.energy >= GAME_CONFIG.gameplay.energyThreshold ) as CombatantActor[]; ready.sort((a, b) => (b.energy - a.energy) || (a.id - b.id)); const actor = ready[0]; if (actor.isPlayer) { return { awaitingPlayerId: actor.id, events }; } const decision = decideEnemyAction(w, actor, player, em); // Emit alert event if enemy just spotted player 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, em)); // Check if player was killed by this action if (!w.actors.has(playerId)) { return { awaitingPlayerId: null as any, events }; } } }