Initial commit
This commit is contained in:
62
src/game/generator.ts
Normal file
62
src/game/generator.ts
Normal 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
102
src/game/pathfinding.ts
Normal 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
120
src/game/simulation.ts
Normal 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
58
src/game/types.ts
Normal 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
9
src/game/utils.ts
Normal 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
29
src/game/world.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user