357 lines
11 KiB
TypeScript
357 lines
11 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, TileType } 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;
|
|
}
|
|
|
|
checkDeaths(events, accessor);
|
|
|
|
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;
|
|
const tile = w.tiles[tileIdx];
|
|
if (isDestructibleByWalk(tile)) {
|
|
// Only open if it's currently closed.
|
|
// tryDestructTile toggles, so we must be specific for doors.
|
|
if (tile === TileType.DOOR_CLOSED) {
|
|
tryDestructTile(w, nx, ny);
|
|
} else if (tile !== TileType.DOOR_OPEN) {
|
|
// For other destructibles like grass
|
|
tryDestructTile(w, nx, ny);
|
|
}
|
|
}
|
|
|
|
// Handle "from" tile - Close door if we just left it and no one else is there
|
|
const fromIdx = from.y * w.width + from.x;
|
|
if (w.tiles[fromIdx] === TileType.DOOR_OPEN) {
|
|
const actorsLeft = accessor.getActorsAt(from.x, from.y);
|
|
if (actorsLeft.length === 0) {
|
|
console.log(`[simulation] Closing door at ${from.x},${from.y} - Actor ${actor.id} left`);
|
|
w.tiles[fromIdx] = TileType.DOOR_CLOSED;
|
|
} else {
|
|
console.log(`[simulation] Door at ${from.x},${from.y} stays open - ${actorsLeft.length} actors remain`);
|
|
}
|
|
}
|
|
|
|
if (actor.category === "combatant" && actor.isPlayer) {
|
|
handleExpCollection(actor, events, accessor);
|
|
}
|
|
return events;
|
|
} else {
|
|
// If blocked, check if we can interact with an entity at the target position
|
|
if (actor.category === "combatant" && actor.isPlayer && accessor?.context) {
|
|
const ecsWorld = accessor.context;
|
|
const interactables = ecsWorld.getEntitiesWith("position", "trigger").filter(id => {
|
|
const p = ecsWorld.getComponent(id, "position");
|
|
const t = ecsWorld.getComponent(id, "trigger");
|
|
return p?.x === nx && p?.y === ny && t?.onInteract;
|
|
});
|
|
|
|
if (interactables.length > 0) {
|
|
// Trigger interaction by marking it as triggered
|
|
// The TriggerSystem will pick this up on the next update
|
|
ecsWorld.getComponent(interactables[0], "trigger")!.triggered = true;
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
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) {
|
|
killActor(target, events, accessor, actor.id);
|
|
}
|
|
return events;
|
|
}
|
|
return [{ type: "waited", actorId: actor.id }];
|
|
}
|
|
|
|
export function killActor(target: CombatantActor, events: SimEvent[], accessor: EntityAccessor, killerId?: EntityId): void {
|
|
events.push({
|
|
type: "killed",
|
|
targetId: target.id,
|
|
killerId: killerId ?? (0 as EntityId),
|
|
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;
|
|
|
|
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 });
|
|
}
|
|
}
|
|
|
|
export function checkDeaths(events: SimEvent[], accessor: EntityAccessor): void {
|
|
const combatants = accessor.getCombatants();
|
|
for (const c of combatants) {
|
|
if (c.stats.hp <= 0) {
|
|
killActor(c, events, accessor);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* 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));
|
|
checkDeaths(events, accessor);
|
|
|
|
if (!accessor.isPlayerAlive()) {
|
|
return { awaitingPlayerId: null as any, events };
|
|
}
|
|
|
|
actionsTaken++;
|
|
if (actionsTaken > 1000) break;
|
|
}
|
|
}
|
|
}
|
|
|
|
|