Initial commit

This commit is contained in:
Peter Stockings
2026-01-04 09:22:55 +11:00
commit 04277726db
19 changed files with 1364 additions and 0 deletions

62
src/game/generator.ts Normal file
View File

@@ -0,0 +1,62 @@
import { type World, type EntityId, type RunState, type Tile, type Actor, type Vec2 } from "./types";
import { idx } from "./world";
export function makeTestWorld(level: number, runState: RunState): { world: World; playerId: EntityId } {
const width = 30;
const height = 18;
const tiles: Tile[] = new Array(width * height).fill(0);
const fakeWorldForIdx: World = { width, height, tiles, actors: new Map(), exit: { x: 0, y: 0 } };
// Border walls
for (let x = 0; x < width; x++) {
tiles[idx(fakeWorldForIdx, x, 0)] = 1;
tiles[idx(fakeWorldForIdx, x, height - 1)] = 1;
}
for (let y = 0; y < height; y++) {
tiles[idx(fakeWorldForIdx, 0, y)] = 1;
tiles[idx(fakeWorldForIdx, width - 1, y)] = 1;
}
// Internal walls (vary slightly with level so it feels different)
const shift = level % 4;
for (let x = 6; x < 22; x++) tiles[idx(fakeWorldForIdx, x, 7 + (shift % 2))] = 1;
for (let y = 4; y < 14; y++) tiles[idx(fakeWorldForIdx, 14 + ((shift + 1) % 2), y)] = 1;
// Exit (stairs)
const exit: Vec2 = { x: width - 3, y: height - 3 };
tiles[idx(fakeWorldForIdx, exit.x, exit.y)] = 0;
const actors = new Map<EntityId, Actor>();
const playerId = 1;
actors.set(playerId, {
id: playerId,
isPlayer: true,
pos: { x: 3, y: 3 },
speed: 100,
energy: 0,
stats: { ...runState.stats },
inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] }
});
// Enemies
actors.set(2, {
id: 2,
isPlayer: false,
pos: { x: 24, y: 13 },
speed: 90,
energy: 0,
stats: { maxHp: 10, hp: 10, attack: 3, defense: 1 }
});
actors.set(3, {
id: 3,
isPlayer: false,
pos: { x: 20, y: 4 },
speed: 130,
energy: 0,
stats: { maxHp: 8, hp: 8, attack: 4, defense: 0 }
});
return { world: { width, height, tiles, actors, exit }, playerId };
}

102
src/game/pathfinding.ts Normal file
View File

@@ -0,0 +1,102 @@
import type { World, Vec2 } from "./types";
import { key, manhattan } from "./utils";
import { inBounds, isWall, isBlocked, idx } from "./world";
/**
* Simple 4-dir A* pathfinding.
* Returns an array of positions INCLUDING start and end. If no path, returns [].
*
* Exploration rule:
* - 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[] {
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 (seen[idx(w, end.x, end.y)] !== 1) return [];
const open: Vec2[] = [start];
const cameFrom = new Map<string, string>();
const gScore = new Map<string, number>();
const fScore = new Map<string, number>();
const startK = key(start.x, start.y);
gScore.set(startK, 0);
fScore.set(startK, manhattan(start, end));
const inOpen = new Set<string>([startK]);
const dirs = [
{ x: 1, y: 0 },
{ x: -1, y: 0 },
{ x: 0, y: 1 },
{ x: 0, y: -1 }
];
while (open.length > 0) {
// Pick node with lowest fScore
let bestIdx = 0;
let bestF = Infinity;
for (let i = 0; i < open.length; i++) {
const k = key(open[i].x, open[i].y);
const f = fScore.get(k) ?? Infinity;
if (f < bestF) {
bestF = f;
bestIdx = i;
}
}
const current = open.splice(bestIdx, 1)[0];
const currentK = key(current.x, current.y);
inOpen.delete(currentK);
if (current.x === end.x && current.y === end.y) {
// Reconstruct path
const path: Vec2[] = [end];
let k = currentK;
while (cameFrom.has(k)) {
const prevK = cameFrom.get(k)!;
const [px, py] = prevK.split(",").map(Number);
path.push({ x: px, y: py });
k = prevK;
}
path.reverse();
return path;
}
for (const d of dirs) {
const nx = current.x + d.x;
const ny = current.y + d.y;
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;
// 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;
const nK = key(nx, ny);
const tentativeG = (gScore.get(currentK) ?? Infinity) + 1;
if (tentativeG < (gScore.get(nK) ?? Infinity)) {
cameFrom.set(nK, currentK);
gScore.set(nK, tentativeG);
fScore.set(nK, tentativeG + manhattan({ x: nx, y: ny }, end));
if (!inOpen.has(nK)) {
open.push({ x: nx, y: ny });
inOpen.add(nK);
}
}
}
}
return [];
}

120
src/game/simulation.ts Normal file
View File

@@ -0,0 +1,120 @@
import { ACTION_COST, ENERGY_THRESHOLD } from "./types";
import type { World, EntityId, Action, SimEvent, Actor } from "./types";
import { isBlocked } from "./world";
export function applyAction(w: World, actorId: EntityId, action: Action): SimEvent[] {
const actor = w.actors.get(actorId);
if (!actor) return [];
const events: SimEvent[] = [];
if (action.type === "move") {
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;
const to = { ...actor.pos };
events.push({ type: "moved", actorId, from, to });
} else {
events.push({ type: "waited", actorId });
}
} else if (action.type === "attack") {
console.log("Sim: Processing Attack on", action.targetId);
const target = w.actors.get(action.targetId);
if (target && target.stats && actor.stats) {
events.push({ type: "attacked", attackerId: actorId, targetId: action.targetId });
const dmg = Math.max(1, actor.stats.attack - target.stats.defense);
console.log("Sim: calculated damage:", dmg);
target.stats.hp -= dmg;
events.push({
type: "damaged",
targetId: action.targetId,
amount: dmg,
hp: target.stats.hp,
x: target.pos.x,
y: target.pos.y
});
if (target.stats.hp <= 0) {
w.actors.delete(target.id);
events.push({ type: "killed", targetId: target.id, killerId: actorId });
}
} else {
events.push({ type: "waited", actorId }); // Missed or invalid target
}
} else {
events.push({ type: "waited", actorId });
}
// Spend energy for any action (move/wait/attack)
actor.energy -= ACTION_COST;
return events;
}
/**
* Very basic enemy AI:
* - if adjacent to player, "wait" (placeholder for attack)
* - else step toward player using greedy Manhattan
*/
export function decideEnemyAction(w: World, enemy: Actor, player: Actor): 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);
if (dist === 1) {
return { type: "attack", targetId: player.id };
}
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) });
} else {
options.push({ dx: 0, dy: Math.sign(dy) });
options.push({ dx: Math.sign(dx), 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)) return { type: "move", dx: o.dx, dy: o.dy };
}
return { type: "wait" };
}
/**
* 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[] } {
const player = w.actors.get(playerId);
if (!player) throw new Error("Player missing");
const events: SimEvent[] = [];
while (true) {
while (![...w.actors.values()].some(a => a.energy >= ENERGY_THRESHOLD)) {
for (const a of w.actors.values()) a.energy += a.speed;
}
const ready = [...w.actors.values()].filter(a => a.energy >= ENERGY_THRESHOLD);
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 action = decideEnemyAction(w, actor, player);
events.push(...applyAction(w, actor.id, action));
}
}

58
src/game/types.ts Normal file
View File

@@ -0,0 +1,58 @@
export type EntityId = number;
export type Vec2 = { x: number; y: number };
export type Tile = 0 | 1; // 0 = floor, 1 = wall
export type Action =
| { type: "move"; dx: number; dy: number }
| { type: "attack"; targetId: EntityId }
| { type: "wait" };
export type SimEvent =
| { type: "moved"; actorId: EntityId; from: Vec2; to: Vec2 }
| { type: "moved"; actorId: EntityId; from: Vec2; to: Vec2 }
| { type: "attacked"; attackerId: EntityId; targetId: EntityId }
| { type: "damaged"; targetId: EntityId; amount: number; hp: number; x: number; y: number }
| { type: "killed"; targetId: EntityId; killerId: EntityId }
| { type: "waited"; actorId: EntityId };
export type Stats = {
maxHp: number;
hp: number;
attack: number;
defense: number;
};
export type Inventory = {
gold: number;
items: string[];
};
export type RunState = {
stats: Stats;
inventory: Inventory;
};
export type Actor = {
id: EntityId;
isPlayer: boolean;
pos: Vec2;
speed: number;
energy: number;
stats?: Stats;
inventory?: Inventory;
};
export type World = {
width: number;
height: number;
tiles: Tile[];
actors: Map<EntityId, Actor>;
exit: Vec2;
};
export const TILE_SIZE = 24;
export const ENERGY_THRESHOLD = 100;
export const ACTION_COST = 100;

9
src/game/utils.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { Vec2 } from "./types";
export function key(x: number, y: number) {
return `${x},${y}`;
}
export function manhattan(a: Vec2, b: Vec2) {
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
}

29
src/game/world.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { World, EntityId } from "./types";
export function inBounds(w: World, x: number, y: number) {
return x >= 0 && y >= 0 && x < w.width && y < w.height;
}
export function idx(w: World, x: number, y: number) {
return y * w.width + x;
}
export function isWall(w: World, x: number, y: number) {
return w.tiles[idx(w, x, y)] === 1;
}
export function isBlocked(w: World, x: number, y: number) {
if (!inBounds(w, x, y)) return true;
if (isWall(w, x, y)) return true;
for (const a of w.actors.values()) {
if (a.pos.x === x && a.pos.y === y) return true;
}
return false;
}
export function isPlayerOnExit(w: World, playerId: EntityId) {
const p = w.actors.get(playerId);
if (!p) return false;
return p.pos.x === w.exit.x && p.pos.y === w.exit.y;
}