Compare commits

...

4 Commits

Author SHA1 Message Date
Peter Stockings
ace13377a2 Fix broken tests 2026-01-04 10:31:37 +11:00
Peter Stockings
f3607bc167 Add tests and move constants to config 2026-01-04 10:27:27 +11:00
Peter Stockings
6e3763a17b Make minimap toggleable 2026-01-04 10:14:01 +11:00
Peter Stockings
0f28a2212e Improve look of generated map 2026-01-04 10:13:44 +11:00
9 changed files with 706 additions and 101 deletions

View File

@@ -0,0 +1,136 @@
import { describe, it, expect } from 'vitest';
import { generateWorld } from '../generator';
import { isWall, inBounds } from '../world';
describe('World Generator', () => {
describe('generateWorld', () => {
it('should generate a world with correct dimensions', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
inventory: { gold: 0, items: [] }
};
const { world } = generateWorld(1, runState);
expect(world.width).toBe(60);
expect(world.height).toBe(40);
expect(world.tiles.length).toBe(60 * 40);
});
it('should place player actor', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
inventory: { gold: 0, items: [] }
};
const { world, playerId } = generateWorld(1, runState);
expect(playerId).toBe(1);
const player = world.actors.get(playerId);
expect(player).toBeDefined();
expect(player?.isPlayer).toBe(true);
expect(player?.stats).toEqual(runState.stats);
});
it('should create walkable rooms', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
inventory: { gold: 0, items: [] }
};
const { world, playerId } = generateWorld(1, runState);
const player = world.actors.get(playerId)!;
// Player should spawn in a walkable area
expect(isWall(world, player.pos.x, player.pos.y)).toBe(false);
});
it('should place exit in valid location', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
inventory: { gold: 0, items: [] }
};
const { world } = generateWorld(1, runState);
expect(inBounds(world, world.exit.x, world.exit.y)).toBe(true);
// Exit should be on a floor tile
expect(isWall(world, world.exit.x, world.exit.y)).toBe(false);
});
it('should create enemies', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
inventory: { gold: 0, items: [] }
};
const { world } = generateWorld(1, runState);
// Should have player + enemies
expect(world.actors.size).toBeGreaterThan(1);
// All non-player actors should be enemies
const enemies = Array.from(world.actors.values()).filter(a => !a.isPlayer);
expect(enemies.length).toBeGreaterThan(0);
// Enemies should have stats
enemies.forEach(enemy => {
expect(enemy.stats).toBeDefined();
expect(enemy.stats!.hp).toBeGreaterThan(0);
expect(enemy.stats!.attack).toBeGreaterThan(0);
});
});
it('should generate deterministic maps for same level', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
inventory: { gold: 0, items: [] }
};
const { world: world1, playerId: player1 } = generateWorld(1, runState);
const { world: world2, playerId: player2 } = generateWorld(1, runState);
// Same level should generate identical layouts
expect(world1.tiles).toEqual(world2.tiles);
expect(world1.exit).toEqual(world2.exit);
const player1Pos = world1.actors.get(player1)!.pos;
const player2Pos = world2.actors.get(player2)!.pos;
expect(player1Pos).toEqual(player2Pos);
});
it('should generate different maps for different levels', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
inventory: { gold: 0, items: [] }
};
const { world: world1 } = generateWorld(1, runState);
const { world: world2 } = generateWorld(2, runState);
// Different levels should have different layouts
expect(world1.tiles).not.toEqual(world2.tiles);
});
it('should scale enemy difficulty with level', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
inventory: { gold: 0, items: [] }
};
const { world: world1 } = generateWorld(1, runState);
const { world: world5 } = generateWorld(5, runState);
const enemies1 = Array.from(world1.actors.values()).filter(a => !a.isPlayer);
const enemies5 = Array.from(world5.actors.values()).filter(a => !a.isPlayer);
// Higher level should have more enemies
expect(enemies5.length).toBeGreaterThan(enemies1.length);
// Higher level enemies should have higher stats
const avgHp1 = enemies1.reduce((sum, e) => sum + (e.stats?.hp || 0), 0) / enemies1.length;
const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats?.hp || 0), 0) / enemies5.length;
expect(avgHp5).toBeGreaterThan(avgHp1);
});
});
});

View File

@@ -0,0 +1,125 @@
import { describe, it, expect } from 'vitest';
import { applyAction } from '../simulation';
import { type World, type Actor, type EntityId } from '../types';
describe('Combat Simulation', () => {
const createTestWorld = (actors: Map<EntityId, Actor>): World => ({
width: 10,
height: 10,
tiles: new Array(100).fill(0),
actors,
exit: { x: 9, y: 9 }
});
describe('applyAction - attack', () => {
it('should deal damage when player attacks enemy', () => {
const actors = new Map<EntityId, Actor>();
actors.set(1, {
id: 1,
isPlayer: true,
pos: { x: 3, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }
});
actors.set(2, {
id: 2,
isPlayer: false,
pos: { x: 4, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 10, hp: 10, attack: 3, defense: 1 }
});
const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: 'attack', targetId: 2 });
const enemy = world.actors.get(2)!;
expect(enemy.stats!.hp).toBeLessThan(10);
// Should have attack event
expect(events.some(e => e.type === 'attacked')).toBe(true);
});
it('should kill enemy when damage exceeds hp', () => {
const actors = new Map<EntityId, Actor>();
actors.set(1, {
id: 1,
isPlayer: true,
pos: { x: 3, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 20, hp: 20, attack: 50, defense: 2 }
});
actors.set(2, {
id: 2,
isPlayer: false,
pos: { x: 4, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 10, hp: 10, attack: 3, defense: 1 }
});
const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: 'attack', targetId: 2 });
// Enemy should be removed from world
expect(world.actors.has(2)).toBe(false);
// Should have killed event
expect(events.some(e => e.type === 'killed')).toBe(true);
});
it('should apply defense to reduce damage', () => {
const actors = new Map<EntityId, Actor>();
actors.set(1, {
id: 1,
isPlayer: true,
pos: { x: 3, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }
});
actors.set(2, {
id: 2,
isPlayer: false,
pos: { x: 4, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 10, hp: 10, attack: 3, defense: 3 }
});
const world = createTestWorld(actors);
applyAction(world, 1, { type: 'attack', targetId: 2 });
const enemy = world.actors.get(2)!;
const damage = 10 - enemy.stats!. hp;
// Damage should be reduced by defense (5 attack - 3 defense = 2 damage)
expect(damage).toBe(2);
});
});
describe('applyAction - move', () => {
it('should move actor to new position', () => {
const actors = new Map<EntityId, Actor>();
actors.set(1, {
id: 1,
isPlayer: true,
pos: { x: 3, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }
});
const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: 'move', dx: 1, dy: 0 });
const player = world.actors.get(1)!;
expect(player.pos).toEqual({ x: 4, y: 3 });
// Should have moved event
expect(events.some(e => e.type === 'moved')).toBe(true);
});
});
});

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import { idx, inBounds, isWall, isBlocked } from '../world';
import { type World, type Tile } from '../types';
describe('World Utilities', () => {
const createTestWorld = (width: number, height: number, tiles: Tile[]): World => ({
width,
height,
tiles,
actors: new Map(),
exit: { x: 0, y: 0 }
});
describe('idx', () => {
it('should calculate correct index for 2D coordinates', () => {
const world = createTestWorld(10, 10, []);
expect(idx(world, 0, 0)).toBe(0);
expect(idx(world, 5, 0)).toBe(5);
expect(idx(world, 0, 1)).toBe(10);
expect(idx(world, 5, 3)).toBe(35);
});
});
describe('inBounds', () => {
it('should return true for coordinates within bounds', () => {
const world = createTestWorld(10, 10, []);
expect(inBounds(world, 0, 0)).toBe(true);
expect(inBounds(world, 5, 5)).toBe(true);
expect(inBounds(world, 9, 9)).toBe(true);
});
it('should return false for coordinates outside bounds', () => {
const world = createTestWorld(10, 10, []);
expect(inBounds(world, -1, 0)).toBe(false);
expect(inBounds(world, 0, -1)).toBe(false);
expect(inBounds(world, 10, 0)).toBe(false);
expect(inBounds(world, 0, 10)).toBe(false);
expect(inBounds(world, 11, 11)).toBe(false);
});
});
describe('isWall', () => {
it('should return true for wall tiles', () => {
const tiles: Tile[] = new Array(100).fill(0);
tiles[0] = 1; // wall at 0,0
tiles[55] = 1; // wall at 5,5
const world = createTestWorld(10, 10, tiles);
expect(isWall(world, 0, 0)).toBe(true);
expect(isWall(world, 5, 5)).toBe(true);
});
it('should return false for floor tiles', () => {
const tiles: Tile[] = new Array(100).fill(0);
const world = createTestWorld(10, 10, tiles);
expect(isWall(world, 3, 3)).toBe(false);
expect(isWall(world, 7, 7)).toBe(false);
});
it('should return false for out of bounds coordinates', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0));
expect(isWall(world, -1, 0)).toBe(false);
expect(isWall(world, 10, 10)).toBe(false);
});
});
describe('isBlocked', () => {
it('should return true for walls', () => {
const tiles: Tile[] = new Array(100).fill(0);
tiles[55] = 1; // wall at 5,5
const world = createTestWorld(10, 10, tiles);
expect(isBlocked(world, 5, 5)).toBe(true);
});
it('should return true for actor positions', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0));
world.actors.set(1, {
id: 1,
isPlayer: true,
pos: { x: 3, y: 3 },
speed: 100,
energy: 0
});
expect(isBlocked(world, 3, 3)).toBe(true);
});
it('should return false for empty floor tiles', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0));
expect(isBlocked(world, 3, 3)).toBe(false);
expect(isBlocked(world, 7, 7)).toBe(false);
});
it('should return true for out of bounds', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0));
expect(isBlocked(world, -1, 0)).toBe(true);
expect(isBlocked(world, 10, 10)).toBe(true);
});
});
});

View File

@@ -0,0 +1,62 @@
export const GAME_CONFIG = {
player: {
initialStats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
speed: 100,
viewRadius: 8
},
map: {
width: 60,
height: 40,
minRooms: 8,
maxRooms: 13,
roomMinWidth: 5,
roomMaxWidth: 12,
roomMinHeight: 4,
roomMaxHeight: 10
},
enemy: {
baseHpPerLevel: 2,
baseHp: 8,
baseAttack: 3,
attackPerTwoLevels: 1,
minSpeed: 80,
maxSpeed: 130,
maxDefense: 2,
baseCountPerLevel: 1,
baseCount: 3,
randomBonus: 4
},
rendering: {
tileSize: 24,
cameraZoom: 2,
wallColor: 0x2b2b2b,
floorColor: 0x161616,
exitColor: 0xffd166,
playerColor: 0x66ff66,
enemyColor: 0xff6666,
pathPreviewColor: 0x3355ff,
fogAlphaFloor: 0.15,
fogAlphaWall: 0.35,
visibleMinAlpha: 0.35,
visibleMaxAlpha: 1.0,
visibleStrengthFactor: 0.65
},
ui: {
minimapPanelWidth: 340,
minimapPanelHeight: 220,
minimapPadding: 20,
menuPanelWidth: 340,
menuPanelHeight: 220
},
gameplay: {
energyThreshold: 100,
actionCost: 100
}
} as const;
export type GameConfig = typeof GAME_CONFIG;

View File

@@ -1,31 +1,112 @@
import { type World, type EntityId, type RunState, type Tile, type Actor, type Vec2 } from "./types";
import { idx } from "./world";
import { GAME_CONFIG } from "./config/GameConfig";
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);
interface Room {
x: number;
y: number;
width: number;
height: number;
}
function seededRandom(seed: number): () => number {
let state = seed;
return () => {
state = (state * 1103515245 + 12345) & 0x7fffffff;
return state / 0x7fffffff;
};
}
/**
* Generates a procedural dungeon world with rooms and corridors
* @param level The level number (affects difficulty and randomness seed)
* @param runState Player's persistent state across levels
* @returns Generated world and player ID
*/
export function generateWorld(level: number, runState: RunState): { world: World; playerId: EntityId } {
const width = GAME_CONFIG.map.width;
const height = GAME_CONFIG.map.height;
const tiles: Tile[] = new Array(width * height).fill(1); // Start with all walls
const fakeWorldForIdx: World = { width, height, tiles, actors: new Map(), exit: { x: 0, y: 0 } };
const random = seededRandom(level * 12345);
// 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;
// Generate rooms
const rooms: Room[] = [];
const numRooms = GAME_CONFIG.map.minRooms + Math.floor(random() * (GAME_CONFIG.map.maxRooms - GAME_CONFIG.map.minRooms + 1));
for (let i = 0; i < numRooms; i++) {
const roomWidth = GAME_CONFIG.map.roomMinWidth + Math.floor(random() * (GAME_CONFIG.map.roomMaxWidth - GAME_CONFIG.map.roomMinWidth + 1));
const roomHeight = GAME_CONFIG.map.roomMinHeight + Math.floor(random() * (GAME_CONFIG.map.roomMaxHeight - GAME_CONFIG.map.roomMinHeight + 1));
const roomX = 1 + Math.floor(random() * (width - roomWidth - 2));
const roomY = 1 + Math.floor(random() * (height - roomHeight - 2));
const newRoom: Room = { x: roomX, y: roomY, width: roomWidth, height: roomHeight };
// Check if room overlaps with existing rooms
let overlaps = false;
for (const room of rooms) {
if (
newRoom.x < room.x + room.width + 1 &&
newRoom.x + newRoom.width + 1 > room.x &&
newRoom.y < room.y + room.height + 1 &&
newRoom.y + newRoom.height + 1 > room.y
) {
overlaps = true;
break;
}
}
if (!overlaps) {
// Carve out the room
for (let x = newRoom.x; x < newRoom.x + newRoom.width; x++) {
for (let y = newRoom.y; y < newRoom.y + newRoom.height; y++) {
tiles[idx(fakeWorldForIdx, x, y)] = 0;
}
}
// Connect to previous room with a corridor
if (rooms.length > 0) {
const prevRoom = rooms[rooms.length - 1];
const prevCenterX = Math.floor(prevRoom.x + prevRoom.width / 2);
const prevCenterY = Math.floor(prevRoom.y + prevRoom.height / 2);
const newCenterX = Math.floor(newRoom.x + newRoom.width / 2);
const newCenterY = Math.floor(newRoom.y + newRoom.height / 2);
// Create L-shaped corridor
if (random() < 0.5) {
// Horizontal then vertical
for (let x = Math.min(prevCenterX, newCenterX); x <= Math.max(prevCenterX, newCenterX); x++) {
tiles[idx(fakeWorldForIdx, x, prevCenterY)] = 0;
}
for (let y = Math.min(prevCenterY, newCenterY); y <= Math.max(prevCenterY, newCenterY); y++) {
tiles[idx(fakeWorldForIdx, newCenterX, y)] = 0;
}
} else {
// Vertical then horizontal
for (let y = Math.min(prevCenterY, newCenterY); y <= Math.max(prevCenterY, newCenterY); y++) {
tiles[idx(fakeWorldForIdx, prevCenterX, y)] = 0;
}
for (let x = Math.min(prevCenterX, newCenterX); x <= Math.max(prevCenterX, newCenterX); x++) {
tiles[idx(fakeWorldForIdx, x, newCenterY)] = 0;
}
}
}
rooms.push(newRoom);
}
}
// 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;
// Place player in first room
const firstRoom = rooms[0];
const playerX = firstRoom.x + Math.floor(firstRoom.width / 2);
const playerY = firstRoom.y + Math.floor(firstRoom.height / 2);
// Exit (stairs)
const exit: Vec2 = { x: width - 3, y: height - 3 };
tiles[idx(fakeWorldForIdx, exit.x, exit.y)] = 0;
// Place exit in last room
const lastRoom = rooms[rooms.length - 1];
const exitX = lastRoom.x + Math.floor(lastRoom.width / 2);
const exitY = lastRoom.y + Math.floor(lastRoom.height / 2);
const exit: Vec2 = { x: exitX, y: exitY };
const actors = new Map<EntityId, Actor>();
@@ -33,30 +114,48 @@ export function makeTestWorld(level: number, runState: RunState): { world: World
actors.set(playerId, {
id: playerId,
isPlayer: true,
pos: { x: 3, y: 3 },
speed: 100,
pos: { x: playerX, y: playerY },
speed: GAME_CONFIG.player.speed,
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 }
});
// Place enemies in random rooms (skip first room with player)
let enemyId = 2;
const numEnemies = GAME_CONFIG.enemy.baseCount + level * GAME_CONFIG.enemy.baseCountPerLevel + Math.floor(random() * GAME_CONFIG.enemy.randomBonus);
for (let i = 0; i < numEnemies && i < rooms.length - 1; i++) {
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));
// Vary enemy stats by level
const baseHp = GAME_CONFIG.enemy.baseHp + level * GAME_CONFIG.enemy.baseHpPerLevel;
const baseAttack = GAME_CONFIG.enemy.baseAttack + Math.floor(level / 2) * GAME_CONFIG.enemy.attackPerTwoLevels;
actors.set(enemyId, {
id: enemyId,
isPlayer: false,
pos: { x: enemyX, y: enemyY },
speed: GAME_CONFIG.enemy.minSpeed + Math.floor(random() * (GAME_CONFIG.enemy.maxSpeed - GAME_CONFIG.enemy.minSpeed)),
energy: 0,
stats: {
maxHp: baseHp + Math.floor(random() * 4),
hp: baseHp + Math.floor(random() * 4),
attack: baseAttack + Math.floor(random() * 2),
defense: Math.floor(random() * (GAME_CONFIG.enemy.maxDefense + 1))
}
});
enemyId++;
}
return { world: { width, height, tiles, actors, exit }, playerId };
}
// Backward compatibility - will be removed in Phase 2
/** @deprecated Use generateWorld instead */
export const makeTestWorld = generateWorld;

View File

@@ -53,6 +53,10 @@ export type World = {
exit: Vec2;
};
export const TILE_SIZE = 24;
export const ENERGY_THRESHOLD = 100;
export const ACTION_COST = 100;
// Import constants from config
import { GAME_CONFIG } from "./config/GameConfig";
export const TILE_SIZE = GAME_CONFIG.rendering.tileSize;
export const ENERGY_THRESHOLD = GAME_CONFIG.gameplay.energyThreshold;
export const ACTION_COST = GAME_CONFIG.gameplay.actionCost;

View File

@@ -2,6 +2,7 @@ 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";
import { GAME_CONFIG } from "../game/config/GameConfig";
export class DungeonRenderer {
private scene: Phaser.Scene;
@@ -12,7 +13,6 @@ export class DungeonRenderer {
private seen!: Uint8Array;
private visible!: Uint8Array;
private visibleStrength!: Float32Array;
private viewRadius = 8;
// State refs
private world!: World;
@@ -20,9 +20,8 @@ export class DungeonRenderer {
// Minimap
private minimapGfx!: Phaser.GameObjects.Graphics;
private minimapContainer!: Phaser.GameObjects.Container;
private minimapBg!: Phaser.GameObjects.Graphics;
private minimapScale = 2; // pixels per tile
private minimapPadding = 8;
private minimapBg!: Phaser.GameObjects.Rectangle;
private minimapVisible = false; // Off by default
constructor(scene: Phaser.Scene) {
this.scene = scene;
@@ -35,13 +34,24 @@ export class DungeonRenderer {
private initMinimap() {
this.minimapContainer = this.scene.add.container(0, 0);
this.minimapContainer.setScrollFactor(0); // Fixed to camera
this.minimapContainer.setDepth(100);
this.minimapContainer.setDepth(1001); // Same as menu
// Background panel (like menu)
this.minimapBg = this.scene.add
.rectangle(0, 0, GAME_CONFIG.ui.minimapPanelWidth, GAME_CONFIG.ui.minimapPanelHeight, 0x000000, 0.8)
.setStrokeStyle(1, 0xffffff, 0.9)
.setInteractive(); // Capture clicks
this.minimapBg = this.scene.add.graphics();
this.minimapGfx = this.scene.add.graphics();
this.minimapContainer.add(this.minimapBg);
this.minimapContainer.add(this.minimapGfx);
// Position in center
this.positionMinimap();
// Start hidden
this.minimapContainer.setVisible(false);
}
initializeLevel(world: World) {
@@ -61,14 +71,17 @@ export class DungeonRenderer {
private positionMinimap() {
const cam = this.scene.cameras.main;
const minimapWidth = this.world.width * this.minimapScale + this.minimapPadding * 2;
const minimapHeight = this.world.height * this.minimapScale + this.minimapPadding * 2;
// Center on screen like menu
this.minimapContainer.setPosition(cam.width / 2, cam.height / 2);
}
// Position in bottom right corner (accounting for zoom)
const x = cam.width / cam.zoom - minimapWidth - 10;
const y = cam.height / cam.zoom - minimapHeight - 10;
toggleMinimap() {
this.minimapVisible = !this.minimapVisible;
this.minimapContainer.setVisible(this.minimapVisible);
}
this.minimapContainer.setPosition(x, y);
isMinimapVisible(): boolean {
return this.minimapVisible;
}
computeFov(playerId: EntityId) {
@@ -79,7 +92,7 @@ export class DungeonRenderer {
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) => {
this.fov.compute(ox, oy, GAME_CONFIG.player.viewRadius, (x: number, y: number, r: number, v: number) => {
if (!inBounds(this.world, x, y)) return;
const i = idx(this.world, x, y);
@@ -87,7 +100,7 @@ export class DungeonRenderer {
this.seen[i] = 1;
// falloff: 1 at center, ~0.4 at radius edge
const radiusT = Phaser.Math.Clamp(r / this.viewRadius, 0, 1);
const radiusT = Phaser.Math.Clamp(r / GAME_CONFIG.player.viewRadius, 0, 1);
const falloff = 1 - radiusT * 0.6;
const strength = Phaser.Math.Clamp(v * falloff, 0, 1);
@@ -124,14 +137,14 @@ export class DungeonRenderer {
}
const wall = isWall(this.world, x, y);
const base = wall ? 0x2b2b2b : 0x161616;
const base = wall ? GAME_CONFIG.rendering.wallColor : GAME_CONFIG.rendering.floorColor;
let alpha: number;
if (isVis) {
const s = this.visibleStrength[i];
alpha = Phaser.Math.Clamp(0.35 + s * 0.65, 0.35, 1.0);
alpha = Phaser.Math.Clamp(GAME_CONFIG.rendering.visibleMinAlpha + s * GAME_CONFIG.rendering.visibleStrengthFactor, GAME_CONFIG.rendering.visibleMinAlpha, GAME_CONFIG.rendering.visibleMaxAlpha);
} else {
alpha = wall ? 0.35 : 0.15;
alpha = wall ? GAME_CONFIG.rendering.fogAlphaWall : GAME_CONFIG.rendering.fogAlphaFloor;
}
this.gfx.fillStyle(base, alpha);
@@ -145,15 +158,15 @@ export class DungeonRenderer {
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);
const alpha = this.visible[i] === 1 ? 1.0 : GAME_CONFIG.rendering.visibleMinAlpha;
this.gfx.fillStyle(GAME_CONFIG.rendering.exitColor, 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);
this.gfx.fillStyle(GAME_CONFIG.rendering.pathPreviewColor, GAME_CONFIG.rendering.visibleMinAlpha);
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);
@@ -168,7 +181,7 @@ export class DungeonRenderer {
const isVis = this.visible[i] === 1;
if (!a.isPlayer && !isVis) continue;
const color = a.isPlayer ? 0x66ff66 : 0xff6666;
const color = a.isPlayer ? GAME_CONFIG.rendering.playerColor : GAME_CONFIG.rendering.enemyColor;
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);
}
@@ -178,21 +191,24 @@ export class DungeonRenderer {
}
private renderMinimap() {
this.minimapBg.clear();
this.minimapGfx.clear();
if (!this.world) return;
const minimapWidth = this.world.width * this.minimapScale + this.minimapPadding * 2;
const minimapHeight = this.world.height * this.minimapScale + this.minimapPadding * 2;
// Calculate scale to fit map within panel
const padding = GAME_CONFIG.ui.minimapPadding;
const availableWidth = GAME_CONFIG.ui.minimapPanelWidth - padding * 2;
const availableHeight = GAME_CONFIG.ui.minimapPanelHeight - padding * 2;
// Background
this.minimapBg.fillStyle(0x000000, 0.7);
this.minimapBg.fillRect(0, 0, minimapWidth, minimapHeight);
const scaleX = availableWidth / this.world.width;
const scaleY = availableHeight / this.world.height;
const tileSize = Math.floor(Math.min(scaleX, scaleY));
// Border
this.minimapBg.lineStyle(1, 0x666666, 1);
this.minimapBg.strokeRect(0, 0, minimapWidth, minimapHeight);
// Center the map within the panel
const mapPixelWidth = this.world.width * tileSize;
const mapPixelHeight = this.world.height * tileSize;
const offsetX = -mapPixelWidth / 2;
const offsetY = -mapPixelHeight / 2;
// Draw only seen tiles
for (let y = 0; y < this.world.height; y++) {
@@ -207,10 +223,10 @@ export class DungeonRenderer {
this.minimapGfx.fillStyle(color, 1);
this.minimapGfx.fillRect(
this.minimapPadding + x * this.minimapScale,
this.minimapPadding + y * this.minimapScale,
this.minimapScale,
this.minimapScale
offsetX + x * tileSize,
offsetY + y * tileSize,
tileSize,
tileSize
);
}
}
@@ -222,10 +238,10 @@ export class DungeonRenderer {
if (this.seen[exitIdx] === 1) {
this.minimapGfx.fillStyle(0xffd166, 1);
this.minimapGfx.fillRect(
this.minimapPadding + ex * this.minimapScale,
this.minimapPadding + ey * this.minimapScale,
this.minimapScale,
this.minimapScale
offsetX + ex * tileSize,
offsetY + ey * tileSize,
tileSize,
tileSize
);
}
@@ -234,10 +250,10 @@ export class DungeonRenderer {
if (player) {
this.minimapGfx.fillStyle(0x66ff66, 1);
this.minimapGfx.fillRect(
this.minimapPadding + player.pos.x * this.minimapScale,
this.minimapPadding + player.pos.y * this.minimapScale,
this.minimapScale,
this.minimapScale
offsetX + player.pos.x * tileSize,
offsetY + player.pos.y * tileSize,
tileSize,
tileSize
);
}
@@ -250,10 +266,10 @@ export class DungeonRenderer {
this.minimapGfx.fillStyle(0xff6666, 1);
this.minimapGfx.fillRect(
this.minimapPadding + a.pos.x * this.minimapScale,
this.minimapPadding + a.pos.y * this.minimapScale,
this.minimapScale,
this.minimapScale
offsetX + a.pos.x * tileSize,
offsetY + a.pos.y * tileSize,
tileSize,
tileSize
);
}
}

View File

@@ -12,6 +12,7 @@ import { findPathAStar } from "../game/pathfinding";
import { applyAction, stepUntilPlayerTurn } from "../game/simulation";
import { makeTestWorld } from "../game/generator";
import { DungeonRenderer } from "./DungeonRenderer";
import { GAME_CONFIG } from "../game/config/GameConfig";
export class GameScene extends Phaser.Scene {
private world!: World;
@@ -20,7 +21,7 @@ export class GameScene extends Phaser.Scene {
private levelIndex = 1;
private runState: RunState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
stats: { ...GAME_CONFIG.player.initialStats },
inventory: { gold: 0, items: [] }
};
@@ -41,7 +42,7 @@ export class GameScene extends Phaser.Scene {
this.cursors = this.input.keyboard!.createCursorKeys();
// Camera
this.cameras.main.setZoom(2);
this.cameras.main.setZoom(GAME_CONFIG.rendering.cameraZoom);
// Initialize Sub-systems
this.dungeonRenderer = new DungeonRenderer(this);
@@ -59,18 +60,41 @@ export class GameScene extends Phaser.Scene {
// Menu Inputs
this.input.keyboard?.on("keydown-I", () => {
// Close minimap if it's open
if (this.dungeonRenderer.isMinimapVisible()) {
this.dungeonRenderer.toggleMinimap();
}
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");
// Also close minimap
if (this.dungeonRenderer.isMinimapVisible()) {
this.dungeonRenderer.toggleMinimap();
}
});
this.input.keyboard?.on("keydown-M", () => {
// Close menu if it's open
this.events.emit("close-menu");
this.dungeonRenderer.toggleMinimap();
});
// Mouse click -> compute path (only during player turn, and not while menu is open)
// Listen for Map button click from UI
this.events.on("toggle-minimap", () => {
this.dungeonRenderer.toggleMinimap();
});
// Listen for UI update requests
this.events.on("request-ui-update", () => {
this.emitUIUpdate();
});
// Mouse click -> compute path (only during player turn, and not while menu/minimap is open)
this.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
if (!this.awaitingPlayer) return;
if (this.isMenuOpen) return;
if (this.isMenuOpen || this.dungeonRenderer.isMinimapVisible()) return;
const tx = Math.floor(p.worldX / TILE_SIZE);
const ty = Math.floor(p.worldY / TILE_SIZE);
@@ -99,7 +123,7 @@ export class GameScene extends Phaser.Scene {
update() {
if (!this.awaitingPlayer) return;
if (this.isMenuOpen) return;
if (this.isMenuOpen || this.dungeonRenderer.isMinimapVisible()) return;
// Auto-walk one step per turn
if (this.playerPath.length >= 2) {

View File

@@ -1,5 +1,6 @@
import Phaser from "phaser";
import { type World, type EntityId } from "../game/types";
import { GAME_CONFIG } from "../game/config/GameConfig";
export default class GameUI extends Phaser.Scene {
// HUD
@@ -12,6 +13,7 @@ export default class GameUI extends Phaser.Scene {
private menuText!: Phaser.GameObjects.Text;
private menuBg!: Phaser.GameObjects.Rectangle;
private menuButton!: Phaser.GameObjects.Container;
private mapButton!: Phaser.GameObjects.Container;
constructor() {
super({ key: "GameUI" });
@@ -49,10 +51,10 @@ export default class GameUI extends Phaser.Scene {
private createMenu() {
const cam = this.cameras.main;
// Button (top-right)
const btnW = 90;
const btnH = 28;
// Menu Button
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);
@@ -63,13 +65,28 @@ export default class GameUI extends Phaser.Scene {
this.menuButton.setPosition(cam.width - btnW / 2 - 10, btnH / 2 + 10);
};
placeButton();
this.scale.on("resize", placeButton); // Use scale manager resize
this.scale.on("resize", placeButton);
btnBg.setInteractive({ useHandCursor: true }).on("pointerdown", () => this.toggleMenu());
// Map Button (left of Menu button)
const mapBtnBg = this.add.rectangle(0, 0, btnW, btnH, 0x000000, 0.6).setStrokeStyle(1, 0xffffff, 0.8);
const mapBtnLabel = this.add.text(0, 0, "Map", { fontSize: "14px", color: "#ffffff" }).setOrigin(0.5);
this.mapButton = this.add.container(0, 0, [mapBtnBg, mapBtnLabel]);
this.mapButton.setDepth(1000);
const placeMapButton = () => {
this.mapButton.setPosition(cam.width - btnW / 2 - 10 - btnW - 5, btnH / 2 + 10);
};
placeMapButton();
this.scale.on("resize", placeMapButton);
mapBtnBg.setInteractive({ useHandCursor: true }).on("pointerdown", () => this.toggleMap());
// Panel (center)
const panelW = 340;
const panelH = 220;
const panelW = GAME_CONFIG.ui.menuPanelWidth;
const panelH = GAME_CONFIG.ui.menuPanelHeight;
this.menuBg = this.add
.rectangle(0, 0, panelW, panelH, 0x000000, 0.8)
@@ -98,6 +115,11 @@ export default class GameUI extends Phaser.Scene {
private toggleMenu() {
this.setMenuOpen(!this.menuOpen);
// Request UI update when menu is opened to populate the text
if (this.menuOpen) {
const gameScene = this.scene.get("GameScene");
gameScene.events.emit("request-ui-update");
}
}
private setMenuOpen(open: boolean) {
@@ -109,6 +131,13 @@ export default class GameUI extends Phaser.Scene {
gameScene.events.emit("menu-toggled", open);
}
private toggleMap() {
// Close menu and toggle minimap
this.setMenuOpen(false);
const gameScene = this.scene.get("GameScene");
gameScene.events.emit("toggle-minimap");
}
private updateUI(world: World, playerId: EntityId, levelIndex: number) {
this.updateHud(world, playerId, levelIndex);
if (this.menuOpen) {