432 lines
13 KiB
TypeScript
432 lines
13 KiB
TypeScript
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 };
|
|
}
|
|
}
|
|
}
|