Compare commits
3 Commits
ace13377a2
...
6a050ac7a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a050ac7a9 | ||
|
|
bfe5ebae8c | ||
|
|
3785885abe |
BIN
public/bat.png
Normal file
BIN
public/bat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/rat.png
Normal file
BIN
public/rat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
BIN
public/warrior.png
Normal file
BIN
public/warrior.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
5
src/core/constants.ts
Normal file
5
src/core/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
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;
|
||||||
17
src/core/math.ts
Normal file
17
src/core/math.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Vec2 } from "./types";
|
||||||
|
|
||||||
|
export function seededRandom(seed: number): () => number {
|
||||||
|
let state = seed;
|
||||||
|
return () => {
|
||||||
|
state = (state * 1103515245 + 12345) & 0x7fffffff;
|
||||||
|
return state / 0x7fffffff;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function manhattan(a: Vec2, b: Vec2): number {
|
||||||
|
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lerp(a: number, b: number, t: number): number {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
@@ -10,11 +10,10 @@ export type Action =
|
|||||||
| { type: "wait" };
|
| { type: "wait" };
|
||||||
|
|
||||||
export type SimEvent =
|
export type SimEvent =
|
||||||
| { type: "moved"; actorId: EntityId; from: Vec2; to: Vec2 }
|
|
||||||
| { type: "moved"; actorId: EntityId; from: Vec2; to: Vec2 }
|
| { type: "moved"; actorId: EntityId; from: Vec2; to: Vec2 }
|
||||||
| { type: "attacked"; attackerId: EntityId; targetId: EntityId }
|
| { type: "attacked"; attackerId: EntityId; targetId: EntityId }
|
||||||
| { type: "damaged"; targetId: EntityId; amount: number; hp: number; x: number; y: number }
|
| { type: "damaged"; targetId: EntityId; amount: number; hp: number; x: number; y: number }
|
||||||
| { type: "killed"; targetId: EntityId; killerId: EntityId }
|
| { type: "killed"; targetId: EntityId; killerId: EntityId; x: number; y: number; victimType?: "player" | "rat" | "bat" }
|
||||||
| { type: "waited"; actorId: EntityId };
|
| { type: "waited"; actorId: EntityId };
|
||||||
|
|
||||||
export type Stats = {
|
export type Stats = {
|
||||||
@@ -37,6 +36,7 @@ export type RunState = {
|
|||||||
export type Actor = {
|
export type Actor = {
|
||||||
id: EntityId;
|
id: EntityId;
|
||||||
isPlayer: boolean;
|
isPlayer: boolean;
|
||||||
|
type?: "player" | "rat" | "bat";
|
||||||
pos: Vec2;
|
pos: Vec2;
|
||||||
speed: number;
|
speed: number;
|
||||||
energy: number;
|
energy: number;
|
||||||
@@ -52,11 +52,3 @@ export type World = {
|
|||||||
actors: Map<EntityId, Actor>;
|
actors: Map<EntityId, Actor>;
|
||||||
exit: Vec2;
|
exit: Vec2;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
7
src/core/utils.ts
Normal file
7
src/core/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function key(x: number, y: number): string {
|
||||||
|
return `${x},${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { generateWorld } from '../generator';
|
import { generateWorld } from '../world/generator';
|
||||||
import { isWall, inBounds } from '../world';
|
import { isWall, inBounds } from '../world/world-logic';
|
||||||
|
|
||||||
describe('World Generator', () => {
|
describe('World Generator', () => {
|
||||||
describe('generateWorld', () => {
|
describe('generateWorld', () => {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { applyAction } from '../simulation';
|
import { applyAction } from '../simulation/simulation';
|
||||||
import { type World, type Actor, type EntityId } from '../types';
|
import { type World, type Actor, type EntityId } from '../../core/types';
|
||||||
|
|
||||||
describe('Combat Simulation', () => {
|
describe('Combat Simulation', () => {
|
||||||
const createTestWorld = (actors: Map<EntityId, Actor>): World => ({
|
const createTestWorld = (actors: Map<EntityId, Actor>): World => ({
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { idx, inBounds, isWall, isBlocked } from '../world';
|
import { idx, inBounds, isWall, isBlocked } from '../world/world-logic';
|
||||||
import { type World, type Tile } from '../types';
|
import { type World, type Tile } from '../../core/types';
|
||||||
|
|
||||||
describe('World Utilities', () => {
|
describe('World Utilities', () => {
|
||||||
const createTestWorld = (width: number, height: number, tiles: Tile[]): World => ({
|
const createTestWorld = (width: number, height: number, tiles: Tile[]): World => ({
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ACTION_COST, ENERGY_THRESHOLD } from "./types";
|
import { ACTION_COST, ENERGY_THRESHOLD } from "../../core/constants";
|
||||||
import type { World, EntityId, Action, SimEvent, Actor } from "./types";
|
import type { World, EntityId, Action, SimEvent, Actor } from "../../core/types";
|
||||||
import { isBlocked } from "./world";
|
import { isBlocked } from "../world/world-logic";
|
||||||
|
|
||||||
export function applyAction(w: World, actorId: EntityId, action: Action): SimEvent[] {
|
export function applyAction(w: World, actorId: EntityId, action: Action): SimEvent[] {
|
||||||
const actor = w.actors.get(actorId);
|
const actor = w.actors.get(actorId);
|
||||||
@@ -8,47 +8,17 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve
|
|||||||
|
|
||||||
const events: SimEvent[] = [];
|
const events: SimEvent[] = [];
|
||||||
|
|
||||||
if (action.type === "move") {
|
switch (action.type) {
|
||||||
const from = { ...actor.pos };
|
case "move":
|
||||||
const nx = actor.pos.x + action.dx;
|
events.push(...handleMove(w, actor, action));
|
||||||
const ny = actor.pos.y + action.dy;
|
break;
|
||||||
|
case "attack":
|
||||||
if (!isBlocked(w, nx, ny)) {
|
events.push(...handleAttack(w, actor, action));
|
||||||
actor.pos.x = nx;
|
break;
|
||||||
actor.pos.y = ny;
|
case "wait":
|
||||||
const to = { ...actor.pos };
|
default:
|
||||||
events.push({ type: "moved", actorId, from, to });
|
|
||||||
} else {
|
|
||||||
events.push({ type: "waited", actorId });
|
events.push({ type: "waited", actorId });
|
||||||
}
|
break;
|
||||||
} 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)
|
// Spend energy for any action (move/wait/attack)
|
||||||
@@ -57,9 +27,57 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve
|
|||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }): 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;
|
||||||
|
const to = { ...actor.pos };
|
||||||
|
return [{ type: "moved", actorId: actor.id, from, to }];
|
||||||
|
} else {
|
||||||
|
return [{ type: "waited", actorId: actor.id }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): SimEvent[] {
|
||||||
|
const target = w.actors.get(action.targetId);
|
||||||
|
if (target && target.stats && actor.stats) {
|
||||||
|
const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }];
|
||||||
|
|
||||||
|
const dmg = Math.max(1, actor.stats.attack - target.stats.defense);
|
||||||
|
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) {
|
||||||
|
events.push({
|
||||||
|
type: "killed",
|
||||||
|
targetId: target.id,
|
||||||
|
killerId: actor.id,
|
||||||
|
x: target.pos.x,
|
||||||
|
y: target.pos.y,
|
||||||
|
victimType: target.type
|
||||||
|
});
|
||||||
|
w.actors.delete(target.id);
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
return [{ type: "waited", actorId: actor.id }];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Very basic enemy AI:
|
* Very basic enemy AI:
|
||||||
* - if adjacent to player, "wait" (placeholder for attack)
|
* - if adjacent to player, attack
|
||||||
* - else step toward player using greedy Manhattan
|
* - else step toward player using greedy Manhattan
|
||||||
*/
|
*/
|
||||||
export function decideEnemyAction(w: World, enemy: Actor, player: Actor): Action {
|
export function decideEnemyAction(w: World, enemy: Actor, player: Actor): Action {
|
||||||
@@ -116,5 +134,10 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPla
|
|||||||
|
|
||||||
const action = decideEnemyAction(w, actor, player);
|
const action = decideEnemyAction(w, actor, player);
|
||||||
events.push(...applyAction(w, actor.id, action));
|
events.push(...applyAction(w, actor.id, action));
|
||||||
|
|
||||||
|
// Check if player was killed by this action
|
||||||
|
if (!w.actors.has(playerId)) {
|
||||||
|
return { awaitingPlayerId: null as any, events };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type World, type EntityId, type RunState, type Tile, type Actor, type Vec2 } from "./types";
|
import { type World, type EntityId, type RunState, type Tile, type Actor, type Vec2 } from "../../core/types";
|
||||||
import { idx } from "./world";
|
import { idx } from "./world-logic";
|
||||||
import { GAME_CONFIG } from "./config/GameConfig";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
|
import { seededRandom } from "../../core/math";
|
||||||
|
|
||||||
interface Room {
|
interface Room {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -9,14 +10,6 @@ interface Room {
|
|||||||
height: 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
|
* Generates a procedural dungeon world with rooms and corridors
|
||||||
* @param level The level number (affects difficulty and randomness seed)
|
* @param level The level number (affects difficulty and randomness seed)
|
||||||
@@ -28,74 +21,9 @@ export function generateWorld(level: number, runState: RunState): { world: World
|
|||||||
const height = GAME_CONFIG.map.height;
|
const height = GAME_CONFIG.map.height;
|
||||||
const tiles: Tile[] = new Array(width * height).fill(1); // Start with all walls
|
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);
|
const random = seededRandom(level * 12345);
|
||||||
|
|
||||||
// Generate rooms
|
const rooms = generateRooms(width, height, tiles, random);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Place player in first room
|
// Place player in first room
|
||||||
const firstRoom = rooms[0];
|
const firstRoom = rooms[0];
|
||||||
@@ -114,6 +42,7 @@ export function generateWorld(level: number, runState: RunState): { world: World
|
|||||||
actors.set(playerId, {
|
actors.set(playerId, {
|
||||||
id: playerId,
|
id: playerId,
|
||||||
isPlayer: true,
|
isPlayer: true,
|
||||||
|
type: "player",
|
||||||
pos: { x: playerX, y: playerY },
|
pos: { x: playerX, y: playerY },
|
||||||
speed: GAME_CONFIG.player.speed,
|
speed: GAME_CONFIG.player.speed,
|
||||||
energy: 0,
|
energy: 0,
|
||||||
@@ -121,7 +50,86 @@ export function generateWorld(level: number, runState: RunState): { world: World
|
|||||||
inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] }
|
inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Place enemies in random rooms (skip first room with player)
|
placeEnemies(level, rooms, actors, random);
|
||||||
|
|
||||||
|
return { world: { width, height, tiles, actors, exit }, playerId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRooms(width: number, height: number, tiles: Tile[], random: () => number): Room[] {
|
||||||
|
const rooms: Room[] = [];
|
||||||
|
const numRooms = GAME_CONFIG.map.minRooms + Math.floor(random() * (GAME_CONFIG.map.maxRooms - GAME_CONFIG.map.minRooms + 1));
|
||||||
|
|
||||||
|
const fakeWorldForIdx = { width, height };
|
||||||
|
|
||||||
|
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 };
|
||||||
|
|
||||||
|
if (!doesOverlap(newRoom, rooms)) {
|
||||||
|
carveRoom(newRoom, tiles, fakeWorldForIdx);
|
||||||
|
|
||||||
|
if (rooms.length > 0) {
|
||||||
|
carveCorridor(rooms[rooms.length - 1], newRoom, tiles, fakeWorldForIdx, random);
|
||||||
|
}
|
||||||
|
|
||||||
|
rooms.push(newRoom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rooms;
|
||||||
|
}
|
||||||
|
|
||||||
|
function doesOverlap(newRoom: Room, rooms: Room[]): boolean {
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function carveRoom(room: Room, tiles: Tile[], world: any): void {
|
||||||
|
for (let x = room.x; x < room.x + room.width; x++) {
|
||||||
|
for (let y = room.y; y < room.y + room.height; y++) {
|
||||||
|
tiles[idx(world, x, y)] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function carveCorridor(room1: Room, room2: Room, tiles: Tile[], world: any, random: () => number): void {
|
||||||
|
const x1 = Math.floor(room1.x + room1.width / 2);
|
||||||
|
const y1 = Math.floor(room1.y + room1.height / 2);
|
||||||
|
const x2 = Math.floor(room2.x + room2.width / 2);
|
||||||
|
const y2 = Math.floor(room2.y + room2.height / 2);
|
||||||
|
|
||||||
|
if (random() < 0.5) {
|
||||||
|
// Horizontal then vertical
|
||||||
|
for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) {
|
||||||
|
tiles[idx(world, x, y1)] = 0;
|
||||||
|
}
|
||||||
|
for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) {
|
||||||
|
tiles[idx(world, x2, y)] = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Vertical then horizontal
|
||||||
|
for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) {
|
||||||
|
tiles[idx(world, x1, y)] = 0;
|
||||||
|
}
|
||||||
|
for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) {
|
||||||
|
tiles[idx(world, x, y2)] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeEnemies(level: number, rooms: Room[], actors: Map<EntityId, Actor>, random: () => number): void {
|
||||||
let enemyId = 2;
|
let enemyId = 2;
|
||||||
const numEnemies = GAME_CONFIG.enemy.baseCount + level * GAME_CONFIG.enemy.baseCountPerLevel + Math.floor(random() * GAME_CONFIG.enemy.randomBonus);
|
const numEnemies = GAME_CONFIG.enemy.baseCount + level * GAME_CONFIG.enemy.baseCountPerLevel + Math.floor(random() * GAME_CONFIG.enemy.randomBonus);
|
||||||
|
|
||||||
@@ -132,13 +140,13 @@ export function generateWorld(level: number, runState: RunState): { world: World
|
|||||||
const enemyX = room.x + 1 + Math.floor(random() * (room.width - 2));
|
const enemyX = room.x + 1 + Math.floor(random() * (room.width - 2));
|
||||||
const enemyY = room.y + 1 + Math.floor(random() * (room.height - 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 baseHp = GAME_CONFIG.enemy.baseHp + level * GAME_CONFIG.enemy.baseHpPerLevel;
|
||||||
const baseAttack = GAME_CONFIG.enemy.baseAttack + Math.floor(level / 2) * GAME_CONFIG.enemy.attackPerTwoLevels;
|
const baseAttack = GAME_CONFIG.enemy.baseAttack + Math.floor(level / 2) * GAME_CONFIG.enemy.attackPerTwoLevels;
|
||||||
|
|
||||||
actors.set(enemyId, {
|
actors.set(enemyId, {
|
||||||
id: enemyId,
|
id: enemyId,
|
||||||
isPlayer: false,
|
isPlayer: false,
|
||||||
|
type: random() < 0.5 ? "rat" : "bat",
|
||||||
pos: { x: enemyX, y: enemyY },
|
pos: { x: enemyX, y: enemyY },
|
||||||
speed: GAME_CONFIG.enemy.minSpeed + Math.floor(random() * (GAME_CONFIG.enemy.maxSpeed - GAME_CONFIG.enemy.minSpeed)),
|
speed: GAME_CONFIG.enemy.minSpeed + Math.floor(random() * (GAME_CONFIG.enemy.maxSpeed - GAME_CONFIG.enemy.minSpeed)),
|
||||||
energy: 0,
|
energy: 0,
|
||||||
@@ -151,11 +159,6 @@ export function generateWorld(level: number, runState: RunState): { world: World
|
|||||||
});
|
});
|
||||||
enemyId++;
|
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;
|
export const makeTestWorld = generateWorld;
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { World, Vec2 } from "./types";
|
import type { World, Vec2 } from "../../core/types";
|
||||||
import { key, manhattan } from "./utils";
|
import { key } from "../../core/utils";
|
||||||
import { inBounds, isWall, isBlocked, idx } from "./world";
|
import { manhattan } from "../../core/math";
|
||||||
|
import { inBounds, isWall, isBlocked, idx } from "./world-logic";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple 4-dir A* pathfinding.
|
* Simple 4-dir A* pathfinding.
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import type { World, EntityId } from "./types";
|
import type { World, EntityId } from "../../core/types";
|
||||||
|
|
||||||
export function inBounds(w: World, x: number, y: number) {
|
export function inBounds(w: World, x: number, y: number): boolean {
|
||||||
return x >= 0 && y >= 0 && x < w.width && y < w.height;
|
return x >= 0 && y >= 0 && x < w.width && y < w.height;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function idx(w: World, x: number, y: number) {
|
export function idx(w: World, x: number, y: number): number {
|
||||||
return y * w.width + x;
|
return y * w.width + x;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isWall(w: World, x: number, y: number) {
|
export function isWall(w: World, x: number, y: number): boolean {
|
||||||
return w.tiles[idx(w, x, y)] === 1;
|
return w.tiles[idx(w, x, y)] === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isBlocked(w: World, x: number, y: number) {
|
export function isBlocked(w: World, x: number, y: number): boolean {
|
||||||
if (!inBounds(w, x, y)) return true;
|
if (!inBounds(w, x, y)) return true;
|
||||||
if (isWall(w, x, y)) return true;
|
if (isWall(w, x, y)) return true;
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export function isBlocked(w: World, x: number, y: number) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPlayerOnExit(w: World, playerId: EntityId) {
|
export function isPlayerOnExit(w: World, playerId: EntityId): boolean {
|
||||||
const p = w.actors.get(playerId);
|
const p = w.actors.get(playerId);
|
||||||
if (!p) return false;
|
if (!p) return false;
|
||||||
return p.pos.x === w.exit.x && p.pos.y === w.exit.y;
|
return p.pos.x === w.exit.x && p.pos.y === w.exit.y;
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import GameUI from "./scenes/GameUI";
|
import Phaser from "phaser";
|
||||||
|
import GameUI from "./ui/GameUI";
|
||||||
import { GameScene } from "./scenes/GameScene";
|
import { GameScene } from "./scenes/GameScene";
|
||||||
import { SplashScene } from "./scenes/SplashScene";
|
import { SplashScene } from "./scenes/SplashScene";
|
||||||
import { StartScene } from "./scenes/StartScene";
|
import { StartScene } from "./scenes/StartScene";
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import { FOV } from "rot-js";
|
import { FOV } from "rot-js";
|
||||||
import { type World, type EntityId, type Vec2, TILE_SIZE } from "../game/types";
|
import { type World, type EntityId, type Vec2 } from "../core/types";
|
||||||
import { idx, inBounds, isWall } from "../game/world";
|
import { TILE_SIZE } from "../core/constants";
|
||||||
import { GAME_CONFIG } from "../game/config/GameConfig";
|
import { idx, inBounds, isWall } from "../engine/world/world-logic";
|
||||||
|
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||||
|
|
||||||
export class DungeonRenderer {
|
export class DungeonRenderer {
|
||||||
private scene: Phaser.Scene;
|
private scene: Phaser.Scene;
|
||||||
private gfx: Phaser.GameObjects.Graphics;
|
private gfx: Phaser.GameObjects.Graphics;
|
||||||
|
private playerSprite?: Phaser.GameObjects.Sprite;
|
||||||
|
private enemySprites: Map<EntityId, Phaser.GameObjects.Sprite> = new Map();
|
||||||
|
private corpseSprites: Phaser.GameObjects.Sprite[] = [];
|
||||||
|
|
||||||
// FOV
|
// FOV
|
||||||
private fov!: any;
|
private fov!: any;
|
||||||
@@ -60,6 +64,90 @@ export class DungeonRenderer {
|
|||||||
this.visible = 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.visibleStrength = new Float32Array(this.world.width * this.world.height);
|
||||||
|
|
||||||
|
// Clear old corpses
|
||||||
|
for (const sprite of this.corpseSprites) {
|
||||||
|
sprite.destroy();
|
||||||
|
}
|
||||||
|
this.corpseSprites = [];
|
||||||
|
|
||||||
|
// Setup player sprite
|
||||||
|
if (!this.playerSprite) {
|
||||||
|
this.playerSprite = this.scene.add.sprite(0, 0, "warrior", 0);
|
||||||
|
this.playerSprite.setDepth(100);
|
||||||
|
|
||||||
|
// Calculate display size to fit within tile while maintaining 12:15 aspect ratio
|
||||||
|
const scale = TILE_SIZE / 15; // Fit height to tile size
|
||||||
|
this.playerSprite.setScale(scale);
|
||||||
|
|
||||||
|
// Simple animations from PD source
|
||||||
|
this.scene.anims.create({
|
||||||
|
key: 'warrior-idle',
|
||||||
|
frames: this.scene.anims.generateFrameNumbers('warrior', { frames: [0, 0, 0, 1, 0, 0, 1, 1] }),
|
||||||
|
frameRate: 2,
|
||||||
|
repeat: -1
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scene.anims.create({
|
||||||
|
key: 'warrior-run',
|
||||||
|
frames: this.scene.anims.generateFrameNumbers('warrior', { frames: [2, 3, 4, 5, 6, 7] }),
|
||||||
|
frameRate: 15,
|
||||||
|
repeat: -1
|
||||||
|
});
|
||||||
|
|
||||||
|
this.scene.anims.create({
|
||||||
|
key: 'warrior-die',
|
||||||
|
frames: this.scene.anims.generateFrameNumbers('warrior', { frames: [8, 9, 10, 11, 12] }),
|
||||||
|
frameRate: 10,
|
||||||
|
repeat: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
this.playerSprite.play('warrior-idle');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rat animations
|
||||||
|
if (!this.scene.anims.exists('rat-idle')) {
|
||||||
|
this.scene.anims.create({
|
||||||
|
key: 'rat-idle',
|
||||||
|
frames: this.scene.anims.generateFrameNumbers('rat', { frames: [0, 0, 0, 1] }),
|
||||||
|
frameRate: 4,
|
||||||
|
repeat: -1
|
||||||
|
});
|
||||||
|
this.scene.anims.create({
|
||||||
|
key: 'rat-run',
|
||||||
|
frames: this.scene.anims.generateFrameNumbers('rat', { frames: [6, 7, 8, 9, 10] }),
|
||||||
|
frameRate: 10,
|
||||||
|
repeat: -1
|
||||||
|
});
|
||||||
|
this.scene.anims.create({
|
||||||
|
key: 'rat-die',
|
||||||
|
frames: this.scene.anims.generateFrameNumbers('rat', { frames: [11, 12, 13, 14] }),
|
||||||
|
frameRate: 10,
|
||||||
|
repeat: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bat animations
|
||||||
|
if (!this.scene.anims.exists('bat-idle')) {
|
||||||
|
this.scene.anims.create({
|
||||||
|
key: 'bat-idle',
|
||||||
|
frames: this.scene.anims.generateFrameNumbers('bat', { frames: [0, 1] }),
|
||||||
|
frameRate: 8,
|
||||||
|
repeat: -1
|
||||||
|
});
|
||||||
|
this.scene.anims.create({
|
||||||
|
key: 'bat-run',
|
||||||
|
frames: this.scene.anims.generateFrameNumbers('bat', { frames: [0, 1] }),
|
||||||
|
frameRate: 12,
|
||||||
|
repeat: -1
|
||||||
|
});
|
||||||
|
this.scene.anims.create({
|
||||||
|
key: 'bat-die',
|
||||||
|
frames: this.scene.anims.generateFrameNumbers('bat', { frames: [4, 5, 6] }),
|
||||||
|
frameRate: 10,
|
||||||
|
repeat: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
|
this.fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
|
||||||
if (!inBounds(this.world, x, y)) return false;
|
if (!inBounds(this.world, x, y)) return false;
|
||||||
return !isWall(this.world, x, y);
|
return !isWall(this.world, x, y);
|
||||||
@@ -176,14 +264,50 @@ export class DungeonRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Actors (enemies only if visible)
|
// Actors (enemies only if visible)
|
||||||
|
const activeEnemyIds = new Set<EntityId>();
|
||||||
|
|
||||||
for (const a of this.world.actors.values()) {
|
for (const a of this.world.actors.values()) {
|
||||||
const i = idx(this.world, a.pos.x, a.pos.y);
|
const i = idx(this.world, a.pos.x, a.pos.y);
|
||||||
const isVis = this.visible[i] === 1;
|
const isVis = this.visible[i] === 1;
|
||||||
if (!a.isPlayer && !isVis) continue;
|
|
||||||
|
|
||||||
const color = a.isPlayer ? GAME_CONFIG.rendering.playerColor : GAME_CONFIG.rendering.enemyColor;
|
if (a.isPlayer) {
|
||||||
this.gfx.fillStyle(color, 1);
|
if (this.playerSprite) {
|
||||||
this.gfx.fillRect(a.pos.x * TILE_SIZE + 4, a.pos.y * TILE_SIZE + 4, TILE_SIZE - 8, TILE_SIZE - 8);
|
this.playerSprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2);
|
||||||
|
this.playerSprite.setVisible(true);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isVis) continue;
|
||||||
|
|
||||||
|
activeEnemyIds.add(a.id);
|
||||||
|
let sprite = this.enemySprites.get(a.id);
|
||||||
|
|
||||||
|
const textureKey = a.type === "bat" ? "bat" : "rat";
|
||||||
|
|
||||||
|
if (!sprite) {
|
||||||
|
sprite = this.scene.add.sprite(0, 0, textureKey, 0);
|
||||||
|
sprite.setDepth(99);
|
||||||
|
const scale = TILE_SIZE / 15;
|
||||||
|
sprite.setScale(scale);
|
||||||
|
sprite.play(`${textureKey}-idle`);
|
||||||
|
this.enemySprites.set(a.id, sprite);
|
||||||
|
}
|
||||||
|
|
||||||
|
sprite.setPosition(a.pos.x * TILE_SIZE + TILE_SIZE / 2, a.pos.y * TILE_SIZE + TILE_SIZE / 2);
|
||||||
|
sprite.setVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide/Cleanup inactive/non-visible enemy sprites
|
||||||
|
for (const [id, sprite] of this.enemySprites.entries()) {
|
||||||
|
if (!activeEnemyIds.has(id)) {
|
||||||
|
sprite.setVisible(false);
|
||||||
|
// We could also destroy if they are dead, but hide is safer for now
|
||||||
|
if (!this.world.actors.has(id)) {
|
||||||
|
sprite.destroy();
|
||||||
|
this.enemySprites.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render minimap
|
// Render minimap
|
||||||
@@ -295,4 +419,40 @@ export class DungeonRenderer {
|
|||||||
onComplete: () => text.destroy()
|
onComplete: () => text.destroy()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
spawnCorpse(x: number, y: number, type: "player" | "rat" | "bat") {
|
||||||
|
const textureKey = type === "player" ? "warrior" : type;
|
||||||
|
const corpse = this.scene.add.sprite(
|
||||||
|
x * TILE_SIZE + TILE_SIZE / 2,
|
||||||
|
y * TILE_SIZE + TILE_SIZE / 2,
|
||||||
|
textureKey,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
corpse.setDepth(50);
|
||||||
|
corpse.setScale(TILE_SIZE / 15);
|
||||||
|
corpse.play(`${textureKey}-die`);
|
||||||
|
this.corpseSprites.push(corpse);
|
||||||
|
}
|
||||||
|
|
||||||
|
showWait(x: number, y: number) {
|
||||||
|
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
|
||||||
|
const screenY = y * TILE_SIZE;
|
||||||
|
|
||||||
|
const text = this.scene.add.text(screenX, screenY, "zZz", {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#aaaaff",
|
||||||
|
stroke: "#000",
|
||||||
|
strokeThickness: 2,
|
||||||
|
fontStyle: "bold"
|
||||||
|
}).setOrigin(0.5, 1).setDepth(200);
|
||||||
|
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: text,
|
||||||
|
y: screenY - 20,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 600,
|
||||||
|
ease: "Power1",
|
||||||
|
onComplete: () => text.destroy()
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
130
src/rendering/__tests__/DungeonRenderer.test.ts
Normal file
130
src/rendering/__tests__/DungeonRenderer.test.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { DungeonRenderer } from '../DungeonRenderer';
|
||||||
|
import { type World } from '../../core/types';
|
||||||
|
|
||||||
|
// Mock Phaser
|
||||||
|
vi.mock('phaser', () => {
|
||||||
|
const mockSprite = {
|
||||||
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
|
setScale: vi.fn().mockReturnThis(),
|
||||||
|
play: vi.fn().mockReturnThis(),
|
||||||
|
setPosition: vi.fn().mockReturnThis(),
|
||||||
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
|
destroy: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockGraphics = {
|
||||||
|
clear: vi.fn().mockReturnThis(),
|
||||||
|
fillStyle: vi.fn().mockReturnThis(),
|
||||||
|
fillRect: vi.fn().mockReturnThis(),
|
||||||
|
lineStyle: vi.fn().mockReturnThis(),
|
||||||
|
strokeRect: vi.fn().mockReturnThis(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockContainer = {
|
||||||
|
add: vi.fn().mockReturnThis(),
|
||||||
|
setPosition: vi.fn().mockReturnThis(),
|
||||||
|
setVisible: vi.fn().mockReturnThis(),
|
||||||
|
setScrollFactor: vi.fn().mockReturnThis(),
|
||||||
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRectangle = {
|
||||||
|
setStrokeStyle: vi.fn().mockReturnThis(),
|
||||||
|
setInteractive: vi.fn().mockReturnThis(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
GameObjects: {
|
||||||
|
Sprite: vi.fn(() => mockSprite),
|
||||||
|
Graphics: vi.fn(() => mockGraphics),
|
||||||
|
Container: vi.fn(() => mockContainer),
|
||||||
|
Rectangle: vi.fn(() => mockRectangle),
|
||||||
|
},
|
||||||
|
Scene: vi.fn(),
|
||||||
|
Math: {
|
||||||
|
Clamp: vi.fn((v, min, max) => Math.min(Math.max(v, min), max)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DungeonRenderer', () => {
|
||||||
|
let mockScene: any;
|
||||||
|
let renderer: DungeonRenderer;
|
||||||
|
let mockWorld: World;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockScene = {
|
||||||
|
add: {
|
||||||
|
graphics: vi.fn().mockReturnValue({
|
||||||
|
clear: vi.fn(),
|
||||||
|
fillStyle: vi.fn(),
|
||||||
|
fillRect: vi.fn(),
|
||||||
|
}),
|
||||||
|
sprite: vi.fn(() => ({
|
||||||
|
setDepth: vi.fn().mockReturnThis(),
|
||||||
|
setScale: vi.fn().mockReturnThis(),
|
||||||
|
play: vi.fn().mockReturnThis(),
|
||||||
|
destroy: vi.fn(),
|
||||||
|
})),
|
||||||
|
container: vi.fn().mockReturnValue({
|
||||||
|
add: vi.fn(),
|
||||||
|
setPosition: vi.fn(),
|
||||||
|
setVisible: vi.fn(),
|
||||||
|
setScrollFactor: vi.fn(),
|
||||||
|
setDepth: vi.fn(),
|
||||||
|
}),
|
||||||
|
rectangle: vi.fn().mockReturnValue({
|
||||||
|
setStrokeStyle: vi.fn().mockReturnThis(),
|
||||||
|
setInteractive: vi.fn().mockReturnThis(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
cameras: {
|
||||||
|
main: {
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
anims: {
|
||||||
|
create: vi.fn(),
|
||||||
|
exists: vi.fn().mockReturnValue(true),
|
||||||
|
generateFrameNumbers: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockWorld = {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
tiles: new Array(100).fill(0),
|
||||||
|
actors: new Map(),
|
||||||
|
exit: { x: 9, y: 9 },
|
||||||
|
};
|
||||||
|
|
||||||
|
renderer = new DungeonRenderer(mockScene);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track and clear corpse sprites on level initialization', () => {
|
||||||
|
renderer.initializeLevel(mockWorld);
|
||||||
|
|
||||||
|
// Spawn a couple of corpses
|
||||||
|
renderer.spawnCorpse(1, 1, 'rat');
|
||||||
|
renderer.spawnCorpse(2, 2, 'bat');
|
||||||
|
|
||||||
|
// Get the mock sprites that were returned by scene.add.sprite
|
||||||
|
const corpse1 = mockScene.add.sprite.mock.results[1].value;
|
||||||
|
const corpse2 = mockScene.add.sprite.mock.results[2].value;
|
||||||
|
|
||||||
|
expect(mockScene.add.sprite).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
// Initialize level again (changing level)
|
||||||
|
renderer.initializeLevel(mockWorld);
|
||||||
|
|
||||||
|
// Verify destroy was called on both corpse sprites
|
||||||
|
expect(corpse1.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(corpse2.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,15 +4,15 @@ import {
|
|||||||
type Vec2,
|
type Vec2,
|
||||||
type Action,
|
type Action,
|
||||||
type RunState,
|
type RunState,
|
||||||
type World,
|
type World
|
||||||
TILE_SIZE
|
} from "../core/types";
|
||||||
} from "../game/types";
|
import { TILE_SIZE } from "../core/constants";
|
||||||
import { inBounds, isBlocked, isPlayerOnExit } from "../game/world";
|
import { inBounds, isBlocked, isPlayerOnExit } from "../engine/world/world-logic";
|
||||||
import { findPathAStar } from "../game/pathfinding";
|
import { findPathAStar } from "../engine/world/pathfinding";
|
||||||
import { applyAction, stepUntilPlayerTurn } from "../game/simulation";
|
import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulation";
|
||||||
import { makeTestWorld } from "../game/generator";
|
import { makeTestWorld } from "../engine/world/generator";
|
||||||
import { DungeonRenderer } from "./DungeonRenderer";
|
import { DungeonRenderer } from "../rendering/DungeonRenderer";
|
||||||
import { GAME_CONFIG } from "../game/config/GameConfig";
|
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||||
|
|
||||||
export class GameScene extends Phaser.Scene {
|
export class GameScene extends Phaser.Scene {
|
||||||
private world!: World;
|
private world!: World;
|
||||||
@@ -38,6 +38,12 @@ export class GameScene extends Phaser.Scene {
|
|||||||
super("GameScene");
|
super("GameScene");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
preload() {
|
||||||
|
this.load.spritesheet("warrior", "warrior.png", { frameWidth: 12, frameHeight: 15 });
|
||||||
|
this.load.spritesheet("rat", "rat.png", { frameWidth: 16, frameHeight: 15 });
|
||||||
|
this.load.spritesheet("bat", "bat.png", { frameWidth: 15, frameHeight: 15 });
|
||||||
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
this.cursors = this.input.keyboard!.createCursorKeys();
|
this.cursors = this.input.keyboard!.createCursorKeys();
|
||||||
|
|
||||||
@@ -81,6 +87,12 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.dungeonRenderer.toggleMinimap();
|
this.dungeonRenderer.toggleMinimap();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.input.keyboard?.on("keydown-SPACE", () => {
|
||||||
|
if (!this.awaitingPlayer) return;
|
||||||
|
if (this.isMenuOpen || this.dungeonRenderer.isMinimapVisible()) return;
|
||||||
|
this.commitPlayerAction({ type: "wait" });
|
||||||
|
});
|
||||||
|
|
||||||
// Listen for Map button click from UI
|
// Listen for Map button click from UI
|
||||||
this.events.on("toggle-minimap", () => {
|
this.events.on("toggle-minimap", () => {
|
||||||
this.dungeonRenderer.toggleMinimap();
|
this.dungeonRenderer.toggleMinimap();
|
||||||
@@ -91,6 +103,11 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.emitUIUpdate();
|
this.emitUIUpdate();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for game restart
|
||||||
|
this.events.on("restart-game", () => {
|
||||||
|
this.restartGame();
|
||||||
|
});
|
||||||
|
|
||||||
// Mouse click -> compute path (only during player turn, and not while menu/minimap is open)
|
// Mouse click -> compute path (only during player turn, and not while menu/minimap is open)
|
||||||
this.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
|
this.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
|
||||||
if (!this.awaitingPlayer) return;
|
if (!this.awaitingPlayer) return;
|
||||||
@@ -170,17 +187,14 @@ export class GameScene extends Phaser.Scene {
|
|||||||
else if (Phaser.Input.Keyboard.JustDown(this.cursors.down!)) dy = 1;
|
else if (Phaser.Input.Keyboard.JustDown(this.cursors.down!)) dy = 1;
|
||||||
|
|
||||||
if (dx !== 0 || dy !== 0) {
|
if (dx !== 0 || dy !== 0) {
|
||||||
console.log("Input: ", dx, dy);
|
|
||||||
const player = this.world.actors.get(this.playerId)!;
|
const player = this.world.actors.get(this.playerId)!;
|
||||||
const targetX = player.pos.x + dx;
|
const targetX = player.pos.x + dx;
|
||||||
const targetY = player.pos.y + dy;
|
const targetY = player.pos.y + dy;
|
||||||
console.log("Target: ", targetX, targetY);
|
|
||||||
|
|
||||||
// Check for enemy at target position
|
// Check for enemy at target position
|
||||||
const targetId = [...this.world.actors.values()].find(
|
const targetId = [...this.world.actors.values()].find(
|
||||||
a => a.pos.x === targetX && a.pos.y === targetY && !a.isPlayer
|
a => a.pos.x === targetX && a.pos.y === targetY && !a.isPlayer
|
||||||
)?.id;
|
)?.id;
|
||||||
console.log("Found Target ID:", targetId);
|
|
||||||
|
|
||||||
if (targetId !== undefined) {
|
if (targetId !== undefined) {
|
||||||
action = { type: "attack", targetId };
|
action = { type: "attack", targetId };
|
||||||
@@ -212,14 +226,33 @@ export class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
// Process events for visual fx
|
// Process events for visual fx
|
||||||
const allEvents = [...playerEvents, ...enemyStep.events];
|
const allEvents = [...playerEvents, ...enemyStep.events];
|
||||||
if (allEvents.length > 0) console.log("Events:", allEvents);
|
|
||||||
for (const ev of allEvents) {
|
for (const ev of allEvents) {
|
||||||
if (ev.type === "damaged") {
|
if (ev.type === "damaged") {
|
||||||
console.log("Showing damage:", ev.amount, "at", ev.x, ev.y);
|
|
||||||
this.dungeonRenderer.showDamage(ev.x, ev.y, ev.amount);
|
this.dungeonRenderer.showDamage(ev.x, ev.y, ev.amount);
|
||||||
|
} else if (ev.type === "killed") {
|
||||||
|
this.dungeonRenderer.spawnCorpse(ev.x, ev.y, ev.victimType || "rat");
|
||||||
|
} else if (ev.type === "waited" && ev.actorId === this.playerId) {
|
||||||
|
const player = this.world.actors.get(this.playerId);
|
||||||
|
if (player) {
|
||||||
|
this.dungeonRenderer.showWait(player.pos.x, player.pos.y);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if player died
|
||||||
|
if (!this.world.actors.has(this.playerId)) {
|
||||||
|
this.syncRunStateFromPlayer(); // Save final stats for death screen
|
||||||
|
const uiScene = this.scene.get("GameUI") as any;
|
||||||
|
if (uiScene) {
|
||||||
|
uiScene.showDeathScreen({
|
||||||
|
level: this.levelIndex,
|
||||||
|
gold: this.runState.inventory.gold,
|
||||||
|
stats: this.runState.stats
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Level transition
|
// Level transition
|
||||||
if (isPlayerOnExit(this.world, this.playerId)) {
|
if (isPlayerOnExit(this.world, this.playerId)) {
|
||||||
this.syncRunStateFromPlayer();
|
this.syncRunStateFromPlayer();
|
||||||
@@ -271,6 +304,14 @@ export class GameScene extends Phaser.Scene {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private restartGame() {
|
||||||
|
this.runState = {
|
||||||
|
stats: { ...GAME_CONFIG.player.initialStats },
|
||||||
|
inventory: { gold: 0, items: [] }
|
||||||
|
};
|
||||||
|
this.loadLevel(1);
|
||||||
|
}
|
||||||
|
|
||||||
private centerCameraOnPlayer() {
|
private centerCameraOnPlayer() {
|
||||||
const player = this.world.actors.get(this.playerId)!;
|
const player = this.world.actors.get(this.playerId)!;
|
||||||
this.cameras.main.centerOn(
|
this.cameras.main.centerOn(
|
||||||
|
|||||||
177
src/scenes/__tests__/GameScene.test.ts
Normal file
177
src/scenes/__tests__/GameScene.test.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { GameScene } from '../GameScene';
|
||||||
|
import * as simulation from '../../engine/simulation/simulation';
|
||||||
|
import * as generator from '../../engine/world/generator';
|
||||||
|
|
||||||
|
// Mock Phaser
|
||||||
|
vi.mock('phaser', () => {
|
||||||
|
const mockEventEmitter = {
|
||||||
|
on: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
Scene: class {
|
||||||
|
events = mockEventEmitter;
|
||||||
|
input = {
|
||||||
|
keyboard: {
|
||||||
|
createCursorKeys: vi.fn(() => ({})),
|
||||||
|
on: vi.fn(),
|
||||||
|
},
|
||||||
|
on: vi.fn(),
|
||||||
|
};
|
||||||
|
cameras = {
|
||||||
|
main: {
|
||||||
|
setZoom: vi.fn(),
|
||||||
|
setBounds: vi.fn(),
|
||||||
|
centerOn: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
scene = {
|
||||||
|
launch: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
};
|
||||||
|
add = {
|
||||||
|
graphics: vi.fn(() => ({})),
|
||||||
|
text: vi.fn(() => ({})),
|
||||||
|
rectangle: vi.fn(() => ({})),
|
||||||
|
container: vi.fn(() => ({})),
|
||||||
|
};
|
||||||
|
load = {
|
||||||
|
spritesheet: vi.fn(),
|
||||||
|
};
|
||||||
|
anims = {
|
||||||
|
create: vi.fn(),
|
||||||
|
exists: vi.fn(() => true),
|
||||||
|
generateFrameNumbers: vi.fn(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
Input: {
|
||||||
|
Keyboard: {
|
||||||
|
JustDown: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock other modules
|
||||||
|
vi.mock('../../rendering/DungeonRenderer', () => ({
|
||||||
|
DungeonRenderer: vi.fn().mockImplementation(function() {
|
||||||
|
return {
|
||||||
|
initializeLevel: vi.fn(),
|
||||||
|
computeFov: vi.fn(),
|
||||||
|
render: vi.fn(),
|
||||||
|
showDamage: vi.fn(),
|
||||||
|
spawnCorpse: vi.fn(),
|
||||||
|
showWait: vi.fn(),
|
||||||
|
isMinimapVisible: vi.fn(() => false),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../engine/simulation/simulation', () => ({
|
||||||
|
applyAction: vi.fn(),
|
||||||
|
stepUntilPlayerTurn: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../engine/world/generator', () => ({
|
||||||
|
makeTestWorld: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../engine/world/world-logic', () => ({
|
||||||
|
inBounds: vi.fn(() => true),
|
||||||
|
isBlocked: vi.fn(() => false),
|
||||||
|
isPlayerOnExit: vi.fn(() => false),
|
||||||
|
idx: vi.fn((w, x, y) => y * w.width + x),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('GameScene', () => {
|
||||||
|
let scene: GameScene;
|
||||||
|
let mockWorld: any;
|
||||||
|
let mockUI: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Setup mock UI
|
||||||
|
mockUI = {
|
||||||
|
showDeathScreen: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize Scene
|
||||||
|
scene = new GameScene();
|
||||||
|
|
||||||
|
// Mock the Phaser scene system to return our mock UI
|
||||||
|
(scene as any).scene = {
|
||||||
|
launch: vi.fn(),
|
||||||
|
get: vi.fn((key) => {
|
||||||
|
if (key === 'GameUI') return mockUI;
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock initial world state
|
||||||
|
mockWorld = {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
tiles: new Array(100).fill(0),
|
||||||
|
actors: new Map(),
|
||||||
|
exit: { x: 9, y: 9 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPlayer = {
|
||||||
|
id: 1,
|
||||||
|
isPlayer: true,
|
||||||
|
pos: { x: 1, y: 1 },
|
||||||
|
speed: 100,
|
||||||
|
energy: 0,
|
||||||
|
stats: { hp: 10, maxHp: 10, attack: 5, defense: 2 },
|
||||||
|
inventory: { gold: 0, items: [] },
|
||||||
|
};
|
||||||
|
mockWorld.actors.set(1, mockPlayer);
|
||||||
|
|
||||||
|
(generator.makeTestWorld as any).mockReturnValue({
|
||||||
|
world: mockWorld,
|
||||||
|
playerId: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
(simulation.stepUntilPlayerTurn as any).mockReturnValue({
|
||||||
|
awaitingPlayerId: 1,
|
||||||
|
events: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run create to initialize some things
|
||||||
|
scene.create();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger death screen when player is killed', () => {
|
||||||
|
// 1. Mock simulation so that after action, player is gone from world
|
||||||
|
(simulation.applyAction as any).mockImplementation((world: any) => {
|
||||||
|
// simulate player being killed
|
||||||
|
world.actors.delete(1);
|
||||||
|
return [{ type: 'killed', targetId: 1, victimType: 'player', x: 1, y: 1 }];
|
||||||
|
});
|
||||||
|
|
||||||
|
(simulation.stepUntilPlayerTurn as any).mockReturnValue({
|
||||||
|
awaitingPlayerId: null,
|
||||||
|
events: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Commit an action
|
||||||
|
// We need to access private method or trigger it via public interface
|
||||||
|
// commitPlayerAction is private, let's cast to any to call it
|
||||||
|
(scene as any).commitPlayerAction({ type: 'wait' });
|
||||||
|
|
||||||
|
// 3. Verify showDeathScreen was called on the mock UI
|
||||||
|
expect(mockUI.showDeathScreen).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Verify it was called with some stats
|
||||||
|
const callArgs = mockUI.showDeathScreen.mock.calls[0][0];
|
||||||
|
expect(callArgs).toHaveProperty('level');
|
||||||
|
expect(callArgs).toHaveProperty('gold');
|
||||||
|
expect(callArgs).toHaveProperty('stats');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import { type World, type EntityId } from "../game/types";
|
import { type World, type EntityId } from "../core/types";
|
||||||
import { GAME_CONFIG } from "../game/config/GameConfig";
|
import { GAME_CONFIG } from "../core/config/GameConfig";
|
||||||
|
|
||||||
export default class GameUI extends Phaser.Scene {
|
export default class GameUI extends Phaser.Scene {
|
||||||
// HUD
|
// HUD
|
||||||
@@ -15,6 +15,11 @@ export default class GameUI extends Phaser.Scene {
|
|||||||
private menuButton!: Phaser.GameObjects.Container;
|
private menuButton!: Phaser.GameObjects.Container;
|
||||||
private mapButton!: Phaser.GameObjects.Container;
|
private mapButton!: Phaser.GameObjects.Container;
|
||||||
|
|
||||||
|
// Death Screen
|
||||||
|
private deathContainer!: Phaser.GameObjects.Container;
|
||||||
|
private deathText!: Phaser.GameObjects.Text;
|
||||||
|
private restartButton!: Phaser.GameObjects.Container;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({ key: "GameUI" });
|
super({ key: "GameUI" });
|
||||||
}
|
}
|
||||||
@@ -22,6 +27,7 @@ export default class GameUI extends Phaser.Scene {
|
|||||||
create() {
|
create() {
|
||||||
this.createHud();
|
this.createHud();
|
||||||
this.createMenu();
|
this.createMenu();
|
||||||
|
this.createDeathScreen();
|
||||||
|
|
||||||
// Listen for updates from GameScene
|
// Listen for updates from GameScene
|
||||||
const gameScene = this.scene.get("GameScene");
|
const gameScene = this.scene.get("GameScene");
|
||||||
@@ -29,11 +35,6 @@ export default class GameUI extends Phaser.Scene {
|
|||||||
this.updateUI(data.world, data.playerId, data.levelIndex);
|
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("toggle-menu", () => this.toggleMenu());
|
||||||
gameScene.events.on("close-menu", () => this.setMenuOpen(false));
|
gameScene.events.on("close-menu", () => this.setMenuOpen(false));
|
||||||
}
|
}
|
||||||
@@ -113,6 +114,78 @@ export default class GameUI extends Phaser.Scene {
|
|||||||
this.setMenuOpen(false);
|
this.setMenuOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createDeathScreen() {
|
||||||
|
const cam = this.cameras.main;
|
||||||
|
const panelW = GAME_CONFIG.ui.menuPanelWidth + 40;
|
||||||
|
const panelH = GAME_CONFIG.ui.menuPanelHeight + 60;
|
||||||
|
|
||||||
|
const bg = this.add
|
||||||
|
.rectangle(0, 0, cam.width, cam.height, 0x000000, 0.85)
|
||||||
|
.setOrigin(0)
|
||||||
|
.setInteractive();
|
||||||
|
|
||||||
|
const panel = this.add
|
||||||
|
.rectangle(cam.width / 2, cam.height / 2, panelW, panelH, 0x000000, 0.9)
|
||||||
|
.setStrokeStyle(2, 0xff3333, 1);
|
||||||
|
|
||||||
|
const title = this.add
|
||||||
|
.text(cam.width / 2, cam.height / 2 - panelH / 2 + 30, "YOU HAVE PERISHED", {
|
||||||
|
fontSize: "28px",
|
||||||
|
color: "#ff3333",
|
||||||
|
fontStyle: "bold"
|
||||||
|
})
|
||||||
|
.setOrigin(0.5);
|
||||||
|
|
||||||
|
this.deathText = this.add
|
||||||
|
.text(cam.width / 2, cam.height / 2 - 20, "", {
|
||||||
|
fontSize: "16px",
|
||||||
|
color: "#ffffff",
|
||||||
|
align: "center",
|
||||||
|
lineSpacing: 10
|
||||||
|
})
|
||||||
|
.setOrigin(0.5);
|
||||||
|
|
||||||
|
// Restart Button
|
||||||
|
const btnW = 160;
|
||||||
|
const btnH = 40;
|
||||||
|
const btnBg = this.add.rectangle(0, 0, btnW, btnH, 0x440000, 1).setStrokeStyle(2, 0xff3333, 1);
|
||||||
|
const btnLabel = this.add.text(0, 0, "NEW GAME", { fontSize: "18px", color: "#ffffff", fontStyle: "bold" }).setOrigin(0.5);
|
||||||
|
|
||||||
|
this.restartButton = this.add.container(cam.width / 2, cam.height / 2 + panelH / 2 - 50, [btnBg, btnLabel]);
|
||||||
|
btnBg.setInteractive({ useHandCursor: true }).on("pointerdown", () => {
|
||||||
|
const gameScene = this.scene.get("GameScene");
|
||||||
|
gameScene.events.emit("restart-game");
|
||||||
|
this.hideDeathScreen();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.deathContainer = this.add.container(0, 0, [bg, panel, title, this.deathText, this.restartButton]);
|
||||||
|
this.deathContainer.setDepth(2000);
|
||||||
|
this.deathContainer.setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
showDeathScreen(data: { level: number; gold: number; stats: any }) {
|
||||||
|
const lines = [
|
||||||
|
`Dungeon Level: ${data.level}`,
|
||||||
|
`Gold Collected: ${data.gold}`,
|
||||||
|
"",
|
||||||
|
`Final HP: 0 / ${data.stats.maxHp}`,
|
||||||
|
`Attack: ${data.stats.attack}`,
|
||||||
|
`Defense: ${data.stats.defense}`
|
||||||
|
];
|
||||||
|
this.deathText.setText(lines.join("\n"));
|
||||||
|
this.deathContainer.setVisible(true);
|
||||||
|
|
||||||
|
// Disable other UI interactions
|
||||||
|
this.menuButton.setVisible(false);
|
||||||
|
this.mapButton.setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
hideDeathScreen() {
|
||||||
|
this.deathContainer.setVisible(false);
|
||||||
|
this.menuButton.setVisible(true);
|
||||||
|
this.mapButton.setVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
private toggleMenu() {
|
private toggleMenu() {
|
||||||
this.setMenuOpen(!this.menuOpen);
|
this.setMenuOpen(!this.menuOpen);
|
||||||
// Request UI update when menu is opened to populate the text
|
// Request UI update when menu is opened to populate the text
|
||||||
Reference in New Issue
Block a user