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;
}

15
src/main.ts Normal file
View File

@@ -0,0 +1,15 @@
import GameUI from "./scenes/GameUI";
import { GameScene } from "./scenes/GameScene";
import { SplashScene } from "./scenes/SplashScene";
import { StartScene } from "./scenes/StartScene";
new Phaser.Game({
type: Phaser.AUTO,
parent: "app",
width: 960,
height: 540,
backgroundColor: "#111",
pixelArt: true,
roundPixels: true,
scene: [SplashScene, StartScene, GameScene, GameUI]
});

View File

@@ -0,0 +1,161 @@
import Phaser from "phaser";
import { FOV } from "rot-js";
import { type World, type EntityId, type Vec2, TILE_SIZE } from "../game/types";
import { idx, inBounds, isWall } from "../game/world";
export class DungeonRenderer {
private scene: Phaser.Scene;
private gfx: Phaser.GameObjects.Graphics;
// FOV
private fov!: any;
private seen!: Uint8Array;
private visible!: Uint8Array;
private visibleStrength!: Float32Array;
private viewRadius = 8;
// State refs
private world!: World;
constructor(scene: Phaser.Scene) {
this.scene = scene;
this.gfx = this.scene.add.graphics();
}
initializeLevel(world: World) {
this.world = world;
this.seen = new Uint8Array(this.world.width * this.world.height);
this.visible = new Uint8Array(this.world.width * this.world.height);
this.visibleStrength = new Float32Array(this.world.width * this.world.height);
this.fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
if (!inBounds(this.world, x, y)) return false;
return !isWall(this.world, x, y);
});
}
computeFov(playerId: EntityId) {
this.visible.fill(0);
this.visibleStrength.fill(0);
const player = this.world.actors.get(playerId)!;
const ox = player.pos.x;
const oy = player.pos.y;
this.fov.compute(ox, oy, this.viewRadius, (x: number, y: number, r: number, v: number) => {
if (!inBounds(this.world, x, y)) return;
const i = idx(this.world, x, y);
this.visible[i] = 1;
this.seen[i] = 1;
// falloff: 1 at center, ~0.4 at radius edge
const radiusT = Phaser.Math.Clamp(r / this.viewRadius, 0, 1);
const falloff = 1 - radiusT * 0.6;
const strength = Phaser.Math.Clamp(v * falloff, 0, 1);
if (strength > this.visibleStrength[i]) this.visibleStrength[i] = strength;
});
}
isSeen(x: number, y: number): boolean {
if (!this.world || !inBounds(this.world, x, y)) return false;
return this.seen[idx(this.world, x, y)] === 1;
}
get seenArray() {
return this.seen;
}
render(playerPath: Vec2[]) {
this.gfx.clear();
if (!this.world) return;
// Tiles w/ fog + falloff + silhouettes
for (let y = 0; y < this.world.height; y++) {
for (let x = 0; x < this.world.width; x++) {
const i = idx(this.world, x, y);
const isSeen = this.seen[i] === 1;
const isVis = this.visible[i] === 1;
if (!isSeen) {
this.gfx.fillStyle(0x000000, 1);
this.gfx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
continue;
}
const wall = isWall(this.world, x, y);
const base = wall ? 0x2b2b2b : 0x161616;
let alpha: number;
if (isVis) {
const s = this.visibleStrength[i];
alpha = Phaser.Math.Clamp(0.35 + s * 0.65, 0.35, 1.0);
} else {
alpha = wall ? 0.35 : 0.15;
}
this.gfx.fillStyle(base, alpha);
this.gfx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}
}
// Exit (stairs) if seen
{
const ex = this.world.exit.x;
const ey = this.world.exit.y;
const i = idx(this.world, ex, ey);
if (this.seen[i] === 1) {
const alpha = this.visible[i] === 1 ? 1.0 : 0.35;
this.gfx.fillStyle(0xffd166, alpha);
this.gfx.fillRect(ex * TILE_SIZE + 7, ey * TILE_SIZE + 7, TILE_SIZE - 14, TILE_SIZE - 14);
}
}
// Path preview (seen only)
if (playerPath.length >= 2) {
this.gfx.fillStyle(0x3355ff, 0.35);
for (const p of playerPath) {
// We can check isSeen via internal helper or just local array since we're inside
const i = idx(this.world, p.x, p.y);
if (this.seen[i] !== 1) continue;
this.gfx.fillRect(p.x * TILE_SIZE + 6, p.y * TILE_SIZE + 6, TILE_SIZE - 12, TILE_SIZE - 12);
}
}
// Actors (enemies only if visible)
for (const a of this.world.actors.values()) {
const i = idx(this.world, a.pos.x, a.pos.y);
const isVis = this.visible[i] === 1;
if (!a.isPlayer && !isVis) continue;
const color = a.isPlayer ? 0x66ff66 : 0xff6666;
this.gfx.fillStyle(color, 1);
this.gfx.fillRect(a.pos.x * TILE_SIZE + 4, a.pos.y * TILE_SIZE + 4, TILE_SIZE - 8, TILE_SIZE - 8);
}
}
showDamage(x: number, y: number, amount: number) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
const screenY = y * TILE_SIZE;
const text = this.scene.add.text(screenX, screenY, amount.toString(), {
fontSize: "16px",
color: "#ff3333",
stroke: "#000",
strokeThickness: 2,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(200);
this.scene.tweens.add({
targets: text,
y: screenY - 24,
alpha: 0,
duration: 800,
ease: "Power1",
onComplete: () => text.destroy()
});
}
}

257
src/scenes/GameScene.ts Normal file
View File

@@ -0,0 +1,257 @@
import Phaser from "phaser";
import {
type EntityId,
type Vec2,
type Action,
type RunState,
type World,
TILE_SIZE
} from "../game/types";
import { inBounds, isBlocked, isPlayerOnExit } from "../game/world";
import { findPathAStar } from "../game/pathfinding";
import { applyAction, stepUntilPlayerTurn } from "../game/simulation";
import { makeTestWorld } from "../game/generator";
import { DungeonRenderer } from "./DungeonRenderer";
export class GameScene extends Phaser.Scene {
private world!: World;
private playerId!: EntityId;
private levelIndex = 1;
private runState: RunState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
inventory: { gold: 0, items: [] }
};
private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
private playerPath: Vec2[] = [];
private awaitingPlayer = false;
// Sub-systems
private dungeonRenderer!: DungeonRenderer;
private isMenuOpen = false;
constructor() {
super("GameScene");
}
create() {
this.cursors = this.input.keyboard!.createCursorKeys();
// Camera
this.cameras.main.setZoom(2);
// Initialize Sub-systems
this.dungeonRenderer = new DungeonRenderer(this);
// Launch UI Scene
this.scene.launch("GameUI");
// Listen for Menu State
this.events.on("menu-toggled", (isOpen: boolean) => {
this.isMenuOpen = isOpen;
});
// Load initial level
this.loadLevel(1);
// Menu Inputs
this.input.keyboard?.on("keydown-I", () => {
this.events.emit("toggle-menu");
// Force update UI in case it opened
this.emitUIUpdate();
});
this.input.keyboard?.on("keydown-ESC", () => {
this.events.emit("close-menu");
});
// Mouse click -> compute path (only during player turn, and not while menu is open)
this.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
if (!this.awaitingPlayer) return;
if (this.isMenuOpen) return;
const tx = Math.floor(p.worldX / TILE_SIZE);
const ty = Math.floor(p.worldY / TILE_SIZE);
if (!inBounds(this.world, tx, ty)) return;
// Exploration rule: cannot click-to-move into unseen tiles
if (!this.dungeonRenderer.isSeen(tx, ty)) return;
// Check if clicking on an enemy
const isEnemy = [...this.world.actors.values()].some(a => a.pos.x === tx && a.pos.y === ty && !a.isPlayer);
const player = this.world.actors.get(this.playerId)!;
const path = findPathAStar(
this.world,
this.dungeonRenderer.seenArray,
{ ...player.pos },
{ x: tx, y: ty },
{ ignoreBlockedTarget: isEnemy }
);
if (path.length >= 2) this.playerPath = path;
this.dungeonRenderer.render(this.playerPath);
});
}
update() {
if (!this.awaitingPlayer) return;
if (this.isMenuOpen) return;
// Auto-walk one step per turn
if (this.playerPath.length >= 2) {
const player = this.world.actors.get(this.playerId)!;
const next = this.playerPath[1];
const dx = next.x - player.pos.x;
const dy = next.y - player.pos.y;
if (Math.abs(dx) + Math.abs(dy) !== 1) {
this.playerPath = [];
return;
}
if (isBlocked(this.world, next.x, next.y)) {
// Check if it's an enemy at 'next'
const targetId = [...this.world.actors.values()].find(
a => a.pos.x === next.x && a.pos.y === next.y && !a.isPlayer
)?.id;
if (targetId !== undefined) {
this.commitPlayerAction({ type: "attack", targetId });
this.playerPath = []; // Stop after attack
return;
} else {
// Blocked by something else (friendly?)
this.playerPath = [];
return;
}
}
this.commitPlayerAction({ type: "move", dx, dy });
this.playerPath.shift();
return;
}
// Arrow keys
let action: Action | null = null;
let dx = 0;
let dy = 0;
if (Phaser.Input.Keyboard.JustDown(this.cursors.left!)) dx = -1;
else if (Phaser.Input.Keyboard.JustDown(this.cursors.right!)) dx = 1;
else if (Phaser.Input.Keyboard.JustDown(this.cursors.up!)) dy = -1;
else if (Phaser.Input.Keyboard.JustDown(this.cursors.down!)) dy = 1;
if (dx !== 0 || dy !== 0) {
console.log("Input: ", dx, dy);
const player = this.world.actors.get(this.playerId)!;
const targetX = player.pos.x + dx;
const targetY = player.pos.y + dy;
console.log("Target: ", targetX, targetY);
// Check for enemy at target position
const targetId = [...this.world.actors.values()].find(
a => a.pos.x === targetX && a.pos.y === targetY && !a.isPlayer
)?.id;
console.log("Found Target ID:", targetId);
if (targetId !== undefined) {
action = { type: "attack", targetId };
} else {
action = { type: "move", dx, dy };
}
}
if (action) {
this.playerPath = [];
this.commitPlayerAction(action);
}
}
private emitUIUpdate() {
this.events.emit("update-ui", {
world: this.world,
playerId: this.playerId,
levelIndex: this.levelIndex
});
}
private commitPlayerAction(action: Action) {
this.awaitingPlayer = false;
const playerEvents = applyAction(this.world, this.playerId, action);
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId);
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
// Process events for visual fx
const allEvents = [...playerEvents, ...enemyStep.events];
if (allEvents.length > 0) console.log("Events:", allEvents);
for (const ev of allEvents) {
if (ev.type === "damaged") {
console.log("Showing damage:", ev.amount, "at", ev.x, ev.y);
this.dungeonRenderer.showDamage(ev.x, ev.y, ev.amount);
}
}
// Level transition
if (isPlayerOnExit(this.world, this.playerId)) {
this.syncRunStateFromPlayer();
this.loadLevel(this.levelIndex + 1);
return;
}
this.dungeonRenderer.computeFov(this.playerId);
this.centerCameraOnPlayer();
this.dungeonRenderer.render(this.playerPath);
this.emitUIUpdate();
}
private loadLevel(level: number) {
this.levelIndex = level;
const { world, playerId } = makeTestWorld(level, this.runState);
this.world = world;
this.playerId = playerId;
// Reset transient state
this.playerPath = [];
this.awaitingPlayer = false;
// Camera bounds for this level
this.cameras.main.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE);
// Initialize Renderer for new level
this.dungeonRenderer.initializeLevel(this.world);
// Step until player turn
const enemyStep = stepUntilPlayerTurn(this.world, this.playerId);
this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId;
this.dungeonRenderer.computeFov(this.playerId);
this.centerCameraOnPlayer();
this.dungeonRenderer.render(this.playerPath);
this.emitUIUpdate();
}
private syncRunStateFromPlayer() {
const p = this.world.actors.get(this.playerId);
if (!p?.stats || !p.inventory) return;
this.runState = {
stats: { ...p.stats },
inventory: { gold: p.inventory.gold, items: [...p.inventory.items] }
};
}
private centerCameraOnPlayer() {
const player = this.world.actors.get(this.playerId)!;
this.cameras.main.centerOn(
player.pos.x * TILE_SIZE + TILE_SIZE / 2,
player.pos.y * TILE_SIZE + TILE_SIZE / 2
);
}
}

173
src/scenes/GameUI.ts Normal file
View File

@@ -0,0 +1,173 @@
import Phaser from "phaser";
import { type World, type EntityId } from "../game/types";
export default class GameUI extends Phaser.Scene {
// HUD
private levelText!: Phaser.GameObjects.Text;
private healthBar!: Phaser.GameObjects.Graphics;
// Menu
private menuOpen = false;
private menuContainer!: Phaser.GameObjects.Container;
private menuText!: Phaser.GameObjects.Text;
private menuBg!: Phaser.GameObjects.Rectangle;
private menuButton!: Phaser.GameObjects.Container;
constructor() {
super({ key: "GameUI" });
}
create() {
this.createHud();
this.createMenu();
// Listen for updates from GameScene
const gameScene = this.scene.get("GameScene");
gameScene.events.on("update-ui", (data: { world: World; playerId: EntityId; levelIndex: number }) => {
this.updateUI(data.world, data.playerId, data.levelIndex);
});
// Also listen for toggle request if needed, or stick to inputs in GameScene?
// GameScene handles Input 'I' -> calls events.emit('toggle-menu')?
// Or GameUI handles input?
// Let's keep input in GameScene for now to avoid conflicts, or move 'I' here.
// If 'I' is pressed, GameScene might need to know if menu is open (to pause).
gameScene.events.on("toggle-menu", () => this.toggleMenu());
gameScene.events.on("close-menu", () => this.setMenuOpen(false));
}
private createHud() {
this.levelText = this.add.text(10, 10, "Level 1", {
fontSize: "20px",
color: "#ffffff",
fontStyle: "bold"
}).setDepth(100);
this.healthBar = this.add.graphics().setDepth(100);
}
private createMenu() {
const cam = this.cameras.main;
// Button (top-right)
const btnW = 90;
const btnH = 28;
const btnBg = this.add.rectangle(0, 0, btnW, btnH, 0x000000, 0.6).setStrokeStyle(1, 0xffffff, 0.8);
const btnLabel = this.add.text(0, 0, "Menu", { fontSize: "14px", color: "#ffffff" }).setOrigin(0.5);
this.menuButton = this.add.container(0, 0, [btnBg, btnLabel]);
this.menuButton.setDepth(1000);
const placeButton = () => {
this.menuButton.setPosition(cam.width - btnW / 2 - 10, btnH / 2 + 10);
};
placeButton();
this.scale.on("resize", placeButton); // Use scale manager resize
btnBg.setInteractive({ useHandCursor: true }).on("pointerdown", () => this.toggleMenu());
// Panel (center)
const panelW = 340;
const panelH = 220;
this.menuBg = this.add
.rectangle(0, 0, panelW, panelH, 0x000000, 0.8)
.setStrokeStyle(1, 0xffffff, 0.9)
.setInteractive(); // capture clicks
this.menuText = this.add
.text(-panelW / 2 + 14, -panelH / 2 + 12, "", {
fontSize: "14px",
color: "#ffffff",
wordWrap: { width: panelW - 28 }
})
.setOrigin(0, 0);
this.menuContainer = this.add.container(0, 0, [this.menuBg, this.menuText]);
this.menuContainer.setDepth(1001);
const placePanel = () => {
this.menuContainer.setPosition(cam.width / 2, cam.height / 2);
};
placePanel();
this.scale.on("resize", placePanel);
this.setMenuOpen(false);
}
private toggleMenu() {
this.setMenuOpen(!this.menuOpen);
}
private setMenuOpen(open: boolean) {
this.menuOpen = open;
this.menuContainer.setVisible(open);
// Notify GameScene back?
const gameScene = this.scene.get("GameScene");
gameScene.events.emit("menu-toggled", open);
}
private updateUI(world: World, playerId: EntityId, levelIndex: number) {
this.updateHud(world, playerId, levelIndex);
if (this.menuOpen) {
this.updateMenuText(world, playerId, levelIndex);
}
}
private updateHud(world: World, playerId: EntityId, levelIndex: number) {
this.levelText.setText(`Level ${levelIndex}`);
const p = world.actors.get(playerId);
if (!p || !p.stats) return;
const barX = 10;
const barY = 40;
const barW = 200;
const barH = 16;
this.healthBar.clear();
this.healthBar.fillStyle(0x444444, 1);
this.healthBar.fillRect(barX, barY, barW, barH);
const hp = Math.max(0, p.stats.hp);
const maxHp = Math.max(1, p.stats.maxHp);
const pct = Phaser.Math.Clamp(hp / maxHp, 0, 1);
const fillW = Math.floor(barW * pct);
this.healthBar.fillStyle(0xff0000, 1);
this.healthBar.fillRect(barX, barY, fillW, barH);
this.healthBar.lineStyle(2, 0xffffff, 1);
this.healthBar.strokeRect(barX, barY, barW, barH);
}
private updateMenuText(world: World, playerId: EntityId, levelIndex: number) {
const p = world.actors.get(playerId);
const stats = p?.stats;
const inv = p?.inventory;
const lines: string[] = [];
lines.push(`Level ${levelIndex}`);
lines.push("");
lines.push("Stats");
lines.push(` HP: ${stats?.hp ?? 0}/${stats?.maxHp ?? 0}`);
lines.push(` Attack: ${stats?.attack ?? 0}`);
lines.push(` Defense: ${stats?.defense ?? 0}`);
lines.push(` Speed: ${p?.speed ?? 0}`);
lines.push("");
lines.push("Inventory");
lines.push(` Gold: ${inv?.gold ?? 0}`);
lines.push(` Items: ${(inv?.items?.length ?? 0) === 0 ? "(none)" : ""}`);
if (inv?.items?.length) {
for (const it of inv.items) lines.push(` - ${it}`);
}
lines.push("");
lines.push("Hotkeys: I to toggle, Esc to close");
this.menuText.setText(lines.join("\n"));
}
}

41
src/scenes/SplashScene.ts Normal file
View File

@@ -0,0 +1,41 @@
import Phaser from "phaser";
import { Scene } from 'phaser';
export class SplashScene extends Scene {
constructor() {
super("SplashScene");
}
preload() {
this.load.image('splash', 'splash_bg.png');
}
create() {
const { width, height } = this.scale;
// Background (Placeholder for Image)
// If we successfully load the image 'splash', we use it.
if (this.textures.exists('splash')) {
this.add.image(width / 2, height / 2, 'splash').setDisplaySize(width, height);
} else {
this.add.rectangle(0, 0, width, height, 0x110022).setOrigin(0);
this.add.text(width/2, height/2, "ROGUE LEGACY", {
fontSize: "48px",
color: "#ffffff",
fontStyle: "bold"
}).setOrigin(0.5);
}
// Fade In
this.cameras.main.fadeIn(1000, 0, 0, 0);
// Fade Out after delay
this.time.delayedCall(2500, () => {
this.cameras.main.fadeOut(1000, 0, 0, 0);
});
this.cameras.main.once(Phaser.Cameras.Scene2D.Events.FADE_OUT_COMPLETE, () => {
this.scene.start("StartScene");
});
}
}

52
src/scenes/StartScene.ts Normal file
View File

@@ -0,0 +1,52 @@
import Phaser from "phaser";
export class StartScene extends Phaser.Scene {
constructor() {
super("StartScene");
}
create() {
const { width, height } = this.scale;
this.cameras.main.fadeIn(500, 0, 0, 0);
// Title
this.add.text(width / 2, height * 0.3, "ROGUE", {
fontSize: "64px",
color: "#ff0044",
fontStyle: "bold",
stroke: "#ffffff",
strokeThickness: 4
}).setOrigin(0.5);
// Buttons
const startBtn = this.createButton(width / 2, height * 0.55, "Start Game");
const optBtn = this.createButton(width / 2, height * 0.65, "Options");
startBtn.on("pointerdown", () => {
this.scene.start("GameScene");
});
optBtn.on("pointerdown", () => {
console.log("Options clicked");
});
}
private createButton(x: number, y: number, text: string) {
const bg = this.add.rectangle(0, 0, 200, 50, 0x333333).setStrokeStyle(2, 0xffffff);
const txt = this.add.text(0, 0, text, { fontSize: "24px", color: "#ffffff" }).setOrigin(0.5);
const container = this.add.container(x, y, [bg, txt]);
container.setSize(200, 50);
container.setInteractive({ useHandCursor: true });
container.on("pointerover", () => {
bg.setFillStyle(0x555555);
});
container.on("pointerout", () => {
bg.setFillStyle(0x333333);
});
return container;
}
}