Another refactor

This commit is contained in:
Peter Stockings
2026-01-05 13:24:56 +11:00
parent ac86d612e2
commit ce68470ab1
17 changed files with 853 additions and 801 deletions

View File

@@ -0,0 +1,98 @@
import { type World, type EntityId, type Actor, type Vec2 } from "../core/types";
import { idx } from "./world/world-logic";
export class EntityManager {
private grid: Map<number, EntityId[]> = new Map();
private actors: Map<EntityId, Actor>;
private world: World;
constructor(world: World) {
this.world = world;
this.actors = world.actors;
this.rebuildGrid();
}
rebuildGrid() {
this.grid.clear();
for (const actor of this.actors.values()) {
this.addToGrid(actor);
}
}
private addToGrid(actor: Actor) {
const i = idx(this.world, actor.pos.x, actor.pos.y);
if (!this.grid.has(i)) {
this.grid.set(i, []);
}
this.grid.get(i)!.push(actor.id);
}
private removeFromGrid(actor: Actor) {
const i = idx(this.world, actor.pos.x, actor.pos.y);
const ids = this.grid.get(i);
if (ids) {
const index = ids.indexOf(actor.id);
if (index !== -1) {
ids.splice(index, 1);
}
if (ids.length === 0) {
this.grid.delete(i);
}
}
}
moveActor(actorId: EntityId, from: Vec2, to: Vec2) {
const actor = this.actors.get(actorId);
if (!actor) return;
// Remove from old position
const oldIdx = idx(this.world, from.x, from.y);
const ids = this.grid.get(oldIdx);
if (ids) {
const index = ids.indexOf(actorId);
if (index !== -1) ids.splice(index, 1);
if (ids.length === 0) this.grid.delete(oldIdx);
}
// Update position
actor.pos.x = to.x;
actor.pos.y = to.y;
// Add to new position
const newIdx = idx(this.world, to.x, to.y);
if (!this.grid.has(newIdx)) this.grid.set(newIdx, []);
this.grid.get(newIdx)!.push(actorId);
}
addActor(actor: Actor) {
this.actors.set(actor.id, actor);
this.addToGrid(actor);
}
removeActor(actorId: EntityId) {
const actor = this.actors.get(actorId);
if (actor) {
this.removeFromGrid(actor);
this.actors.delete(actorId);
}
}
getActorsAt(x: number, y: number): Actor[] {
const i = idx(this.world, x, y);
const ids = this.grid.get(i);
if (!ids) return [];
return ids.map(id => this.actors.get(id)!).filter(Boolean);
}
isOccupied(x: number, y: number, ignoreType?: string): boolean {
const actors = this.getActorsAt(x, y);
if (ignoreType) {
return actors.some(a => a.type !== ignoreType);
}
return actors.length > 0;
}
getNextId(): EntityId {
return Math.max(0, ...this.actors.keys()) + 1;
}
}

View File

@@ -0,0 +1,59 @@
import { type CombatantActor, type Stats } from "../core/types";
export class ProgressionManager {
allocateStat(player: CombatantActor, statName: string) {
if (!player.stats || player.stats.statPoints <= 0) return;
player.stats.statPoints--;
if (statName === "strength") {
player.stats.strength++;
player.stats.maxHp += 2;
player.stats.hp += 2;
player.stats.attack += 0.2;
} else if (statName === "dexterity") {
player.stats.dexterity++;
player.speed += 1;
} else if (statName === "intelligence") {
player.stats.intelligence++;
if (player.stats.intelligence % 5 === 0) {
player.stats.defense++;
}
}
}
allocatePassive(player: CombatantActor, nodeId: string) {
if (!player.stats || player.stats.skillPoints <= 0) return;
if (player.stats.passiveNodes.includes(nodeId)) return;
player.stats.skillPoints--;
player.stats.passiveNodes.push(nodeId);
// Apply bonuses
switch (nodeId) {
case "off_1":
player.stats.attack += 2;
break;
case "off_2":
player.stats.attack += 4;
break;
case "def_1":
player.stats.maxHp += 10;
player.stats.hp += 10;
break;
case "def_2":
player.stats.defense += 2;
break;
case "util_1":
player.speed += 5;
break;
case "util_2":
player.stats.expToNextLevel = Math.floor(player.stats.expToNextLevel * 0.9);
break;
}
}
calculateStats(baseStats: Stats): Stats {
return baseStats;
}
}

View File

@@ -1,10 +1,12 @@
import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types";
import { isBlocked } from "../world/world-logic";
import { findPathAStar } from "../world/pathfinding";
import { GAME_CONFIG } from "../../core/config/GameConfig";
import { type EntityManager } from "../EntityManager";
export function applyAction(w: World, actorId: EntityId, action: Action): SimEvent[] {
export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] {
const actor = w.actors.get(actorId);
if (!actor) return [];
@@ -12,10 +14,10 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve
switch (action.type) {
case "move":
events.push(...handleMove(w, actor, action));
events.push(...handleMove(w, actor, action, em));
break;
case "attack":
events.push(...handleAttack(w, actor, action));
events.push(...handleAttack(w, actor, action, em));
break;
case "wait":
default:
@@ -31,7 +33,7 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve
return events;
}
function handleExpCollection(w: World, player: Actor, events: SimEvent[]) {
function handleExpCollection(w: World, player: Actor, events: SimEvent[], em?: EntityManager) {
if (player.category !== "combatant") return;
const orbs = [...w.actors.values()].filter(a =>
@@ -53,7 +55,8 @@ function handleExpCollection(w: World, player: Actor, events: SimEvent[]) {
});
checkLevelUp(player, events);
w.actors.delete(orb.id);
if (em) em.removeActor(orb.id);
else w.actors.delete(orb.id);
}
}
@@ -86,19 +89,23 @@ function checkLevelUp(player: CombatantActor, events: SimEvent[]) {
}
function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }): SimEvent[] {
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)) {
actor.pos.x = nx;
actor.pos.y = ny;
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);
handleExpCollection(w, actor, events, em);
}
return events;
@@ -108,7 +115,8 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }):
}
function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): SimEvent[] {
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 }];
@@ -183,19 +191,25 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): S
y: target.pos.y,
victimType: target.type as ActorType
});
w.actors.delete(target.id);
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 = Math.max(0, ...w.actors.keys(), target.id) + 1;
w.actors.set(orbId, {
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 // Explicit member in CollectibleActor
});
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 });
}
@@ -210,7 +224,7 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): S
* - if adjacent to player, attack
* - else step toward player using greedy Manhattan
*/
export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor): Action {
export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor, em?: EntityManager): Action {
const dx = player.pos.x - enemy.pos.x;
const dy = player.pos.y - enemy.pos.y;
const dist = Math.abs(dx) + Math.abs(dy);
@@ -219,7 +233,21 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
return { type: "attack", targetId: player.id };
}
// Use A* for smarter pathfinding
const dummySeen = new Uint8Array(w.width * w.height).fill(1); // Enemies "know" the map
const path = findPathAStar(w, dummySeen, enemy.pos, player.pos, { 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 { type: "move", dx: adx, dy: ady };
}
// Fallback to greedy if no path found
const options: { dx: number; dy: number }[] = [];
if (Math.abs(dx) >= Math.abs(dy)) {
options.push({ dx: Math.sign(dx), dy: 0 });
options.push({ dx: 0, dy: Math.sign(dy) });
@@ -243,7 +271,7 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
* 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): { awaitingPlayerId: EntityId; events: SimEvent[] } {
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");
@@ -269,8 +297,8 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPla
return { awaitingPlayerId: actor.id, events };
}
const action = decideEnemyAction(w, actor, player);
events.push(...applyAction(w, actor.id, action));
const action = decideEnemyAction(w, actor, player, em);
events.push(...applyAction(w, actor.id, action, em));
// Check if player was killed by this action
if (!w.actors.has(playerId)) {

View File

@@ -214,54 +214,68 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>
const numEnemies = GAME_CONFIG.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor;
const enemyTypes = Object.keys(GAME_CONFIG.enemies);
const occupiedPositions = new Set<string>();
for (let i = 0; i < numEnemies && i < rooms.length - 1; i++) {
for (let i = 0; i < numEnemies; i++) {
// Pick a random room (not the starting room 0)
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
const room = rooms[roomIdx];
const enemyX = room.x + 1 + Math.floor(random() * (room.width - 2));
const enemyY = room.y + 1 + Math.floor(random() * (room.height - 2));
const type = enemyTypes[Math.floor(random() * enemyTypes.length)] as keyof typeof GAME_CONFIG.enemies;
const enemyDef = GAME_CONFIG.enemies[type];
// Try to find an empty spot in the room
for (let attempts = 0; attempts < 5; attempts++) {
const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor;
const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors;
actors.set(enemyId, {
id: enemyId,
category: "combatant",
isPlayer: false,
type,
pos: { x: enemyX, y: enemyY },
speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)),
energy: 0,
stats: {
maxHp: scaledHp + Math.floor(random() * 4),
hp: scaledHp + Math.floor(random() * 4),
attack: scaledAttack + Math.floor(random() * 2),
defense: enemyDef.baseDefense,
level: 0,
exp: 0,
expToNextLevel: 0,
statPoints: 0,
skillPoints: 0,
strength: 0,
dexterity: 0,
intelligence: 0,
critChance: 0,
critMultiplier: 100,
accuracy: 80,
lifesteal: 0,
evasion: 0,
blockChance: 0,
luck: 0,
passiveNodes: []
const ex = room.x + 1 + Math.floor(random() * (room.width - 2));
const ey = room.y + 1 + Math.floor(random() * (room.height - 2));
const k = `${ex},${ey}`;
if (!occupiedPositions.has(k)) {
const type = enemyTypes[Math.floor(random() * enemyTypes.length)] as keyof typeof GAME_CONFIG.enemies;
const enemyDef = GAME_CONFIG.enemies[type];
const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor;
const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors;
actors.set(enemyId, {
id: enemyId,
category: "combatant",
isPlayer: false,
type,
pos: { x: ex, y: ey },
speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)),
energy: 0,
stats: {
maxHp: scaledHp + Math.floor(random() * 4),
hp: scaledHp + Math.floor(random() * 4),
attack: scaledAttack + Math.floor(random() * 2),
defense: enemyDef.baseDefense,
level: 0,
exp: 0,
expToNextLevel: 0,
statPoints: 0,
skillPoints: 0,
strength: 0,
dexterity: 0,
intelligence: 0,
critChance: 0,
critMultiplier: 100,
accuracy: 80,
lifesteal: 0,
evasion: 0,
blockChance: 0,
luck: 0,
passiveNodes: []
}
});
occupiedPositions.add(k);
enemyId++;
break;
}
});
enemyId++;
}
}
}
export const makeTestWorld = generateWorld;

View File

@@ -2,6 +2,7 @@ import type { World, Vec2 } from "../../core/types";
import { key } from "../../core/utils";
import { manhattan } from "../../core/math";
import { inBounds, isWall, isBlocked, idx } from "./world-logic";
import { type EntityManager } from "../EntityManager";
/**
* Simple 4-dir A* pathfinding.
@@ -11,14 +12,14 @@ import { inBounds, isWall, isBlocked, idx } from "./world-logic";
* - You cannot path THROUGH unseen tiles.
* - You cannot path TO an unseen target tile.
*/
export function findPathAStar(w: World, seen: Uint8Array, start: Vec2, end: Vec2, options: { ignoreBlockedTarget?: boolean } = {}): Vec2[] {
export function findPathAStar(w: World, seen: Uint8Array, start: Vec2, end: Vec2, options: { ignoreBlockedTarget?: boolean; ignoreSeen?: boolean; em?: EntityManager } = {}): Vec2[] {
if (!inBounds(w, end.x, end.y)) return [];
if (isWall(w, end.x, end.y)) return [];
// If not ignoring target block, fail if blocked
if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y)) return [];
if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.em)) return [];
if (seen[idx(w, end.x, end.y)] !== 1) return [];
if (!options.ignoreSeen && seen[idx(w, end.x, end.y)] !== 1) return [];
const open: Vec2[] = [start];
const cameFrom = new Map<string, string>();
@@ -76,12 +77,12 @@ export function findPathAStar(w: World, seen: Uint8Array, start: Vec2, end: Vec2
if (!inBounds(w, nx, ny)) continue;
if (isWall(w, nx, ny)) continue;
// Exploration rule: cannot path through unseen (except start)
if (!(nx === start.x && ny === start.y) && seen[idx(w, nx, ny)] !== 1) continue;
// Exploration rule: cannot path through unseen (except start, or if ignoreSeen is set)
if (!options.ignoreSeen && !(nx === start.x && ny === start.y) && seen[idx(w, nx, ny)] !== 1) continue;
// Avoid walking through other actors (except standing on start, OR if it is the target and we ignore block)
const isTarget = nx === end.x && ny === end.y;
if (!isTarget && !(nx === start.x && ny === start.y) && isBlocked(w, nx, ny)) continue;
if (!isTarget && !(nx === start.x && ny === start.y) && isBlocked(w, nx, ny, options.em)) continue;
const nK = key(nx, ny);
const tentativeG = (gScore.get(currentK) ?? Infinity) + 1;

View File

@@ -1,6 +1,8 @@
import type { World, EntityId } from "../../core/types";
import { GAME_CONFIG } from "../../core/config/GameConfig";
import { type EntityManager } from "../EntityManager";
export function inBounds(w: World, x: number, y: number): boolean {
return x >= 0 && y >= 0 && x < w.width && y < w.height;
}
@@ -14,10 +16,14 @@ export function isWall(w: World, x: number, y: number): boolean {
return tile === GAME_CONFIG.terrain.wall || tile === GAME_CONFIG.terrain.wallDeco;
}
export function isBlocked(w: World, x: number, y: number): boolean {
export function isBlocked(w: World, x: number, y: number, em?: EntityManager): boolean {
if (!inBounds(w, x, y)) return true;
if (isWall(w, x, y)) return true;
if (em) {
return em.isOccupied(x, y, "exp_orb");
}
for (const a of w.actors.values()) {
if (a.pos.x === x && a.pos.y === y && a.type !== "exp_orb") return true;
}
@@ -25,6 +31,7 @@ export function isBlocked(w: World, x: number, y: number): boolean {
}
export function isPlayerOnExit(w: World, playerId: EntityId): boolean {
const p = w.actors.get(playerId);
if (!p) return false;