Implement Lantern Bearer logic, Phase buttons, and Monster spawning basics

This commit is contained in:
2026-01-04 23:48:53 +01:00
parent 4c8b58151b
commit 056217437c
9 changed files with 638 additions and 102 deletions

View File

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 571 KiB

67
src/engine/data/Heroes.js Normal file
View File

@@ -0,0 +1,67 @@
export const HERO_DEFINITIONS = {
barbarian: {
id: 'barbarian',
name: 'Bárbaro',
portrait: '/assets/images/dungeon1/standees/heroes/barbarian.png?v=1',
stats: {
move: 4,
ws: 4, // Weapon Skill
bs: 5, // Ballistic Skill (3+ to hit, often lower is better in WHQ, let's use standard table numbers for now)
str: 4,
toughness: 4,
wounds: 12,
attacks: 1,
init: 3,
luck: 2 // Rerolls??
}
},
dwarf: {
id: 'dwarf',
name: 'Enano',
portrait: '/assets/images/dungeon1/standees/heroes/dwarf.png',
stats: {
move: 3,
ws: 5,
bs: 5,
str: 3,
toughness: 5,
wounds: 13,
attacks: 1,
init: 2,
luck: 0
}
},
elf: {
id: 'elf',
name: 'Elfa',
portrait: '/assets/images/dungeon1/standees/heroes/elfa.png',
stats: {
move: 5,
ws: 4,
bs: 2, // Amazing shot
str: 3,
toughness: 3,
wounds: 10,
attacks: 1,
init: 6,
luck: 1
}
},
wizard: {
id: 'wizard',
name: 'Hechicero',
portrait: '/assets/images/dungeon1/standees/heroes/warlock.png',
stats: {
move: 4,
ws: 3,
bs: 6,
str: 3,
toughness: 3,
wounds: 9,
attacks: 1,
init: 4,
luck: 1,
power: 0 // Special mechanic
}
}
};

View File

@@ -0,0 +1,32 @@
export const MONSTER_DEFINITIONS = {
orc: {
id: 'orc',
name: 'Orco',
portrait: '/assets/images/dungeon1/standees/enemies/orc.png',
stats: {
move: 4,
ws: 3,
bs: 5,
str: 3,
toughness: 4,
wounds: 4,
attacks: 1,
gold: 15
}
},
chaos_warrior: {
id: 'chaos_warrior',
name: 'Guerrero del Caos',
portrait: '/assets/images/dungeon1/standees/enemies/chaosWarrior.png',
stats: {
move: 4,
ws: 5,
bs: 0,
str: 5,
toughness: 5,
wounds: 8,
attacks: 2,
gold: 150
}
}
};

View File

@@ -71,9 +71,9 @@ export const TILES = {
layout: [[1, 1], [1, 1], [1, 1], [1, 1], [1, 1], [1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH },
{ x: 0, y: 3, direction: DIRECTIONS.WEST }, { x: 0, y: 4, direction: DIRECTIONS.WEST },
{ x: 1, y: 3, direction: DIRECTIONS.EAST }, { x: 1, y: 4, direction: DIRECTIONS.EAST }
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
//{ x: 0, y: 3, direction: DIRECTIONS.WEST }, { x: 0, y: 4, direction: DIRECTIONS.WEST },
//{ x: 1, y: 3, direction: DIRECTIONS.EAST }, { x: 1, y: 4, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.SOUTH]: {
@@ -81,9 +81,9 @@ export const TILES = {
layout: [[1, 1], [1, 1], [1, 1], [1, 1], [1, 1], [1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.SOUTH }, { x: 1, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH },
{ x: 0, y: 3, direction: DIRECTIONS.WEST }, { x: 0, y: 4, direction: DIRECTIONS.WEST },
{ x: 1, y: 3, direction: DIRECTIONS.EAST }, { x: 1, y: 4, direction: DIRECTIONS.EAST }
{ x: 0, y: 5, direction: DIRECTIONS.NORTH }, { x: 1, y: 5, direction: DIRECTIONS.NORTH }
//{ x: 0, y: 3, direction: DIRECTIONS.WEST }, { x: 0, y: 4, direction: DIRECTIONS.WEST },
//{ x: 1, y: 3, direction: DIRECTIONS.EAST }, { x: 1, y: 4, direction: DIRECTIONS.EAST }
]
},
[DIRECTIONS.EAST]: {
@@ -91,9 +91,9 @@ export const TILES = {
layout: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST },
{ x: 3, y: 0, direction: DIRECTIONS.SOUTH }, { x: 4, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 3, y: 1, direction: DIRECTIONS.NORTH }, { x: 4, y: 1, direction: DIRECTIONS.NORTH }
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
//{ x: 3, y: 0, direction: DIRECTIONS.SOUTH }, { x: 4, y: 0, direction: DIRECTIONS.SOUTH },
//{ x: 3, y: 1, direction: DIRECTIONS.NORTH }, { x: 4, y: 1, direction: DIRECTIONS.NORTH }
]
},
[DIRECTIONS.WEST]: {
@@ -101,9 +101,9 @@ export const TILES = {
layout: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]],
exits: [
{ x: 0, y: 0, direction: DIRECTIONS.WEST }, { x: 0, y: 1, direction: DIRECTIONS.WEST },
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST },
{ x: 3, y: 0, direction: DIRECTIONS.SOUTH }, { x: 4, y: 0, direction: DIRECTIONS.SOUTH },
{ x: 3, y: 1, direction: DIRECTIONS.NORTH }, { x: 4, y: 1, direction: DIRECTIONS.NORTH }
{ x: 5, y: 0, direction: DIRECTIONS.EAST }, { x: 5, y: 1, direction: DIRECTIONS.EAST }
//{ x: 3, y: 0, direction: DIRECTIONS.SOUTH }, { x: 4, y: 0, direction: DIRECTIONS.SOUTH },
//{ x: 3, y: 1, direction: DIRECTIONS.NORTH }, { x: 4, y: 1, direction: DIRECTIONS.NORTH }
]
}
}

View File

@@ -1,4 +1,7 @@
import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
import { TurnManager } from './TurnManager.js';
import { HERO_DEFINITIONS } from '../data/Heroes.js';
import { MONSTER_DEFINITIONS } from '../data/Monsters.js';
/**
* GameEngine for Manual Dungeon Construction with Player Movement
@@ -6,6 +9,7 @@ import { DungeonGenerator } from '../dungeon/DungeonGenerator.js';
export class GameEngine {
constructor() {
this.dungeon = new DungeonGenerator();
this.turnManager = new TurnManager();
this.player = null;
this.selectedEntity = null;
this.isRunning = false;
@@ -22,60 +26,160 @@ export class GameEngine {
this.dungeon.startDungeon(missionConfig);
// Create player at center of first tile
this.createPlayer(1.5, 2.5); // Center of 2x6 corridor
// Create Party (4 Heroes)
this.createParty();
this.isRunning = true;
this.turnManager.startGame();
// Listen for Phase Changes to Reset Moves
this.turnManager.on('phase_changed', (phase) => {
if (phase === 'hero') {
this.resetHeroMoves();
}
});
}
createPlayer(x, y) {
this.player = {
id: 'p1',
name: 'Barbarian',
x: Math.floor(x),
y: Math.floor(y),
texturePath: '/assets/images/dungeon1/standees/barbaro.png'
resetHeroMoves() {
if (!this.heroes) return;
this.heroes.forEach(hero => {
hero.currentMoves = hero.stats.move;
hero.hasAttacked = false;
});
console.log("Refilled Hero Moves");
}
createParty() {
this.heroes = [];
this.monsters = []; // Initialize monsters array
// Definition Keys
const heroKeys = ['barbarian', 'dwarf', 'elf', 'wizard'];
// Find valid spawn points dynamically
const startPositions = this.findSpawnPoints(4);
if (startPositions.length < 4) {
console.error("Could not find enough spawn points!");
// Fallback
startPositions.push({ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }, { x: 1, y: 1 });
}
heroKeys.forEach((key, index) => {
const definition = HERO_DEFINITIONS[key];
const pos = startPositions[index];
const hero = {
id: `hero_${key}`,
type: 'hero',
key: key,
name: definition.name,
x: pos.x,
y: pos.y,
texturePath: definition.portrait,
stats: { ...definition.stats },
// Game State
currentMoves: definition.stats.move,
hasAttacked: false,
isConscious: true,
hasLantern: key === 'barbarian' // Default leader
};
this.heroes.push(hero);
if (this.onEntityUpdate) {
this.onEntityUpdate(hero);
}
});
// Set First Player as Active
this.activeHeroIndex = 0;
// Legacy support for single player var (getter proxy)
this.player = this.heroes[0];
}
spawnMonster(monsterKey, x, y) {
const definition = MONSTER_DEFINITIONS[monsterKey];
if (!definition) {
console.error(`Monster definition not found: ${monsterKey}`);
return;
}
const id = `monster_${monsterKey}_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
const monster = {
id: id,
type: 'monster',
key: monsterKey,
name: definition.name,
x: x,
y: y,
texturePath: definition.portrait,
stats: { ...definition.stats },
// Game State
currentWounds: definition.stats.wounds,
isDead: false
};
this.monsters.push(monster);
if (this.onEntityUpdate) {
this.onEntityUpdate(this.player);
this.onEntityUpdate(monster);
}
return monster;
}
onCellClick(x, y) {
// 1. SELECT / DESELECT PLAYER
if (this.player && x === this.player.x && y === this.player.y) {
if (this.selectedEntity === this.player) {
// 1. Check for Hero/Monster Selection
const clickedHero = this.heroes.find(h => h.x === x && h.y === y);
const clickedMonster = this.monsters ? this.monsters.find(m => m.x === x && m.y === y) : null;
const clickedEntity = clickedHero || clickedMonster;
if (clickedEntity) {
if (this.selectedEntity === clickedEntity) {
// Toggle Deselect
this.deselectPlayer();
this.deselectEntity();
} else {
// Select
this.selectedEntity = this.player;
// Select new entity
if (this.selectedEntity) this.deselectEntity();
this.selectedEntity = clickedEntity;
if (this.onEntitySelect) {
this.onEntitySelect(this.player.id, true);
this.onEntitySelect(clickedEntity.id, true);
}
}
return;
}
// 2. PLAN MOVEMENT (If player selected)
if (this.selectedEntity === this.player) {
// 2. PLAN MOVEMENT (If entity selected and we clicked empty space)
if (this.selectedEntity) {
this.planStep(x, y);
}
}
deselectPlayer() {
deselectEntity() {
if (!this.selectedEntity) return;
const id = this.selectedEntity.id;
this.selectedEntity = null;
this.plannedPath = [];
if (this.onEntitySelect) this.onEntitySelect(this.player.id, false);
if (this.onEntitySelect) this.onEntitySelect(id, false);
if (this.onPathChange) this.onPathChange([]);
}
// Alias for legacy calls if any
deselectPlayer() {
this.deselectEntity();
}
planStep(x, y) {
// Determine start point (either current player pos or last planned step)
if (!this.selectedEntity) return;
// Determine start point
const lastStep = this.plannedPath.length > 0
? this.plannedPath[this.plannedPath.length - 1]
: { x: this.player.x, y: this.player.y };
: { x: this.selectedEntity.x, y: this.selectedEntity.y };
// Check Adjacency
const dx = Math.abs(x - lastStep.x);
@@ -85,48 +189,62 @@ export class GameEngine {
// Check Walkability
const isWalkable = this.canMoveTo(x, y);
// Check if already in path (prevent loops for simplicity or allow backtracking? User said "mark contigua", implying adding)
// If clicking the last added step, maybe remove it? (Undo)
// Check against Max Move Stats
const maxMove = this.selectedEntity.currentMoves || 0;
// Also account for the potential next step
if (this.plannedPath.length >= maxMove && !(this.plannedPath.length > 0 && x === lastStep.x && y === lastStep.y)) {
// Allow undo (next block), but block new steps
if (isAdjacent && isWalkable) {
// Prevent adding more steps
return;
}
}
// Undo Logic
if (this.plannedPath.length > 0 && x === lastStep.x && y === lastStep.y) {
// Clicked last step -> Undo
this.plannedPath.pop();
if (this.onPathChange) this.onPathChange(this.plannedPath);
this.onPathChange && this.onPathChange(this.plannedPath);
return;
}
if (isAdjacent && isWalkable) {
// Check if not already visited in this path to prevent self-intersection weirdness
const alreadyInPath = this.plannedPath.some(p => p.x === x && p.y === y);
const isPlayerPos = this.player.x === x && this.player.y === y;
const isEntityPos = this.selectedEntity.x === x && this.selectedEntity.y === y;
if (!alreadyInPath && !isPlayerPos) {
// Also check if occupied by OTHER heroes?
const isOccupiedByHero = this.heroes.some(h => h.x === x && h.y === y && h !== this.selectedEntity);
if (!alreadyInPath && !isEntityPos && !isOccupiedByHero) {
this.plannedPath.push({ x, y });
if (this.onPathChange) {
this.onPathChange(this.plannedPath);
}
this.onPathChange && this.onPathChange(this.plannedPath);
}
}
}
executeMovePath() {
if (!this.player || !this.plannedPath.length) return;
if (!this.selectedEntity || !this.plannedPath.length) return;
// Clone path for the move event
const path = [...this.plannedPath];
const entity = this.selectedEntity;
// Update player logic verification immediately (teleport logic)
// The visualization will handle the "botecitos"
// Update verify immediately
const finalDest = path[path.length - 1];
this.player.x = finalDest.x;
this.player.y = finalDest.y;
entity.x = finalDest.x;
entity.y = finalDest.y;
// Trigger Movement Event (Renderer will animate)
// Visual animation
if (this.onEntityMove) {
this.onEntityMove(this.player, path);
this.onEntityMove(entity, path);
}
// Cleanup
this.deselectPlayer();
// Deduct Moves
if (entity.currentMoves !== undefined) {
entity.currentMoves -= path.length;
if (entity.currentMoves < 0) entity.currentMoves = 0;
}
this.deselectEntity();
}
canMoveTo(x, y) {
@@ -141,18 +259,19 @@ export class GameEngine {
if (this.onEntityMove) this.onEntityMove(this.player, [{ x, y }]);
}
isPlayerAdjacentToDoor(doorCells) {
if (!this.player) return false;
// Check if the Leader (Lamp Bearer) is adjacent to the door
isLeaderAdjacentToDoor(doorCells) {
if (!this.heroes || this.heroes.length === 0) return false;
const leader = this.getLeader();
if (!leader) return false;
// doorCells should be an array of {x, y} objects
// If it sends a single object, wrap it
const cells = Array.isArray(doorCells) ? doorCells : [doorCells];
for (const cell of cells) {
const dx = Math.abs(this.player.x - cell.x);
const dy = Math.abs(this.player.y - cell.y);
// Adjacent means distance of 1 in one direction and 0 in the other
const dx = Math.abs(leader.x - cell.x);
const dy = Math.abs(leader.y - cell.y);
// Orthogonal adjacency check (Manhattan distance === 1)
if ((dx === 1 && dy === 0) || (dx === 0 && dy === 1)) {
return true;
}
@@ -160,7 +279,55 @@ export class GameEngine {
return false;
}
getLeader() {
// Find hero with lantern, default to barbarian if something breaks, or first hero
return this.heroes.find(h => h.hasLantern) || this.heroes.find(h => h.key === 'barbarian') || this.heroes[0];
}
// Deprecated generic adjacency (kept for safety or other interactions)
isPlayerAdjacentToDoor(doorCells) {
return this.isLeaderAdjacentToDoor(doorCells);
}
update(time) {
// Minimal update loop
}
findSpawnPoints(count) {
const points = [];
const queue = [{ x: 1, y: 1 }]; // Start search near origin but ensure not 0,0 which might be tricky if it's door
// Actually, just scan the grid or BFS from center of first tile?
// First tile is placed at 0,0. Let's scan from 0,0.
const startNode = { x: 0, y: 0 };
const searchQueue = [startNode];
const visited = new Set(['0,0']);
let loops = 0;
while (searchQueue.length > 0 && points.length < count && loops < 200) {
const current = searchQueue.shift();
if (this.dungeon.grid.isOccupied(current.x, current.y)) {
points.push(current);
}
// Neighbors
const neighbors = [
{ x: current.x + 1, y: current.y },
{ x: current.x - 1, y: current.y },
{ x: current.x, y: current.y + 1 },
{ x: current.x, y: current.y - 1 }
];
for (const n of neighbors) {
const key = `${n.x},${n.y}`;
if (!visited.has(key)) {
visited.add(key);
searchQueue.push(n);
}
}
loops++;
}
return points;
}
}

View File

@@ -5,30 +5,36 @@ export class TurnManager {
this.currentTurn = 0;
this.currentPhase = GAME_PHASES.SETUP;
this.listeners = {}; // Simple event system
// Power Phase State
this.currentPowerRoll = 0;
this.eventsTriggered = [];
}
startGame() {
this.currentTurn = 1;
this.setPhase(GAME_PHASES.HERO); // Jump straight to Hero phase for now
console.log(`--- TURN ${this.currentTurn} START ---`);
this.startPowerPhase();
}
nextPhase() {
// Simple sequential flow for now
// Simple sequential flow
switch (this.currentPhase) {
case GAME_PHASES.POWER:
this.setPhase(GAME_PHASES.HERO);
break;
case GAME_PHASES.HERO:
// Usually goes to Exploration if at edge, or Monster if not.
// For this dev stage, let's allow manual triggering of Exploration
// via UI, so we stay in HERO until confirmed done.
// Move to Monster Phase
this.setPhase(GAME_PHASES.MONSTER);
break;
case GAME_PHASES.MONSTER:
// Move to Exploration Phase
this.setPhase(GAME_PHASES.EXPLORATION);
break;
case GAME_PHASES.EXPLORATION:
// End Turn and restart
this.endTurn();
break;
// Exploration is usually triggered as an interrupt, not strictly sequential
}
}
@@ -40,6 +46,37 @@ export class TurnManager {
}
}
startPowerPhase() {
this.setPhase(GAME_PHASES.POWER);
this.rollPowerDice();
}
rollPowerDice() {
const roll = Math.floor(Math.random() * 6) + 1;
this.currentPowerRoll = roll;
console.log(`Power Roll: ${roll}`);
let message = "The dungeon is quiet...";
let eventTriggered = false;
if (roll === 1) {
message = "UNEXPECTED EVENT! (Roll of 1)";
eventTriggered = true;
this.triggerRandomEvent();
}
this.emit('POWER_RESULT', { roll, message, eventTriggered });
// Auto-advance to Hero phase after short delay (game feel)
setTimeout(() => {
this.nextPhase();
}, 2000);
}
triggerRandomEvent() {
console.warn("TODO: TRIGGER EVENT CARD DRAW");
}
triggerExploration() {
this.setPhase(GAME_PHASES.EXPLORATION);
// Logic to return to HERO phase would handle elsewhere
@@ -48,7 +85,7 @@ export class TurnManager {
endTurn() {
console.log(`--- TURN ${this.currentTurn} END ---`);
this.currentTurn++;
this.setPhase(GAME_PHASES.POWER);
this.startPowerPhase();
}
// -- Simple Observer Pattern --

View File

@@ -44,10 +44,10 @@ game.onEntityUpdate = (entity) => {
renderer.addEntity(entity);
renderer.updateEntityPosition(entity);
// Center camera on player spawn
if (entity.id === 'p1' && !entity._centered) {
// Center camera on FIRST hero spawn
if (game.heroes && game.heroes[0] && entity.id === game.heroes[0].id && !window._cameraCentered) {
cameraManager.centerOn(entity.x, entity.y);
entity._centered = true;
window._cameraCentered = true;
}
};
@@ -55,9 +55,7 @@ game.onEntityMove = (entity, path) => {
renderer.moveEntityAlongPath(entity, path);
};
game.onEntitySelect = (entityId, isSelected) => {
renderer.toggleEntitySelection(entityId, isSelected);
};
// game.onEntitySelect is now handled by UIManager to wrap the renderer call
renderer.onHeroFinishedMove = (x, y) => {
cameraManager.centerOn(x, y);
@@ -107,10 +105,24 @@ const handleClick = (x, y, doorMesh) => {
}
if (!doorMesh.userData.isOpen) {
const doorExit = doorMesh.userData.cells[0];
if (game.isPlayerAdjacentToDoor(doorMesh.userData.cells)) {
// 1. Check Selection and Leadership (STRICT)
const selectedHero = game.selectedEntity;
if (!selectedHero) {
ui.showModal('Ningún Héroe seleccionado', 'Selecciona al <b>Líder (Portador de la Lámpara)</b> para abrir la puerta.');
return;
}
if (!selectedHero.hasLantern) {
ui.showModal('Acción no permitida', `<b>${selectedHero.name}</b> no lleva la Lámpara. Solo el <b>Líder</b> puede explorar.`);
return;
}
// 2. Check Adjacency
// Since we know selectedHero IS the leader, we can just check if *this* hero is adjacent.
// game.isLeaderAdjacentToDoor checks the 'getLeader()' position, which aligns with selectedHero here.
if (game.isLeaderAdjacentToDoor(doorMesh.userData.cells)) {
// Open door visually
renderer.openDoor(doorMesh);
@@ -119,11 +131,16 @@ const handleClick = (x, y, doorMesh) => {
const exitData = doorMesh.userData.exitData;
if (exitData) {
generator.selectDoor(exitData);
// Allow UI to update phase if not already
// if (game.turnManager.currentPhase !== 'exploration') {
// game.turnManager.setPhase('exploration');
// }
} else {
console.error('[Main] Door missing exitData');
}
} else {
// Optional: Message if too far?
ui.showModal('Demasiado lejos', 'El Líder debe estar <b>adyacente</b> a la puerta para abrirla.');
}
return;
}
@@ -144,6 +161,20 @@ renderer.setupInteraction(
}
);
// Debug: Spawn Monster
window.addEventListener('keydown', (e) => {
if (e.key === 'm' || e.key === 'M') {
const x = game.player.x + 2;
const y = game.player.y;
if (game.dungeon.grid.isOccupied(x, y)) {
console.log("Spawning Orc...");
game.spawnMonster('orc', x, y);
} else {
console.log("Cannot spawn here");
}
}
});
// 7. Start
game.startMission(mission);

View File

@@ -185,6 +185,14 @@ export class GameRenderer {
mesh.position.set(entity.x, h / 2, -entity.y);
// Clear old children if re-adding (to prevent multiple rings)
for (let i = mesh.children.length - 1; i >= 0; i--) {
const child = mesh.children[i];
if (child.name === "SelectionRing") {
mesh.remove(child);
}
}
// Selection Circle
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
const ringMat = new THREE.MeshBasicMaterial({ color: 0xffff00, side: THREE.DoubleSide, transparent: true, opacity: 0.35 });
@@ -471,7 +479,7 @@ export class GameRenderer {
},
undefined,
(err) => {
console.error(`[TextureLoader] ✗ Failed to load: ${path}`, err);
console.error(`[TextureLoader] [Checked] ✗ Failed to load: ${path}`, err);
}
);
tex.magFilter = THREE.NearestFilter;

View File

@@ -5,9 +5,39 @@ export class UIManager {
this.cameraManager = cameraManager;
this.game = gameEngine;
this.dungeon = gameEngine.dungeon;
this.selectedHero = null;
this.createHUD();
this.createGameStatusPanel(); // New Panel
this.setupMinimapLoop();
this.setupGameListeners(); // New Listeners
// Hook into engine callbacks for UI updates
const originalSelect = this.game.onEntitySelect;
this.game.onEntitySelect = (id, isSelected) => {
// 1. Call Renderer (was in main.js)
if (this.cameraManager && this.cameraManager.renderer) {
this.cameraManager.renderer.toggleEntitySelection(id, isSelected);
} else if (window.RENDERER) {
window.RENDERER.toggleEntitySelection(id, isSelected);
}
// 2. Update UI
if (isSelected) {
const hero = this.game.heroes.find(h => h.id === id);
this.selectedHero = hero; // Store state
this.updateHeroStats(hero);
} else {
this.selectedHero = null;
this.updateHeroStats(null);
}
};
const originalMove = this.game.onEntityMove;
this.game.onEntityMove = (entity, path) => {
if (originalMove) originalMove(entity, path);
this.updateHeroStats(entity);
};
}
createHUD() {
@@ -323,37 +353,17 @@ export class UIManager {
ctx.clearRect(0, 0, w, h);
// Center the view on 0,0 or the average?
// Let's rely on fixed scale for now
const cellSize = 5;
const centerX = w / 2;
const centerY = h / 2;
// Draw placed tiles
// We can access this.dungeon.grid.occupiedCells for raw occupied spots
// Or this.dungeon.placedTiles for structural info (type, color)
ctx.fillStyle = '#666'; // Generic floor
// Iterate over grid occupied cells
// But grid is a Map, iterating keys is slow.
// Better to iterate placedTiles which is an Array
// Simpler approach: Iterate the Grid Map directly
// It's a Map<"x,y", tileId>
// Use an iterator
for (const [key, tileId] of this.dungeon.grid.occupiedCells) {
const [x, y] = key.split(',').map(Number);
// Coordinate transformation to Canvas
// Dungeon (0,0) -> Canvas (CenterX, CenterY)
// Y in dungeon is Up/North. Y in Canvas is Down.
// So CanvasY = CenterY - (DungeonY * size)
const cx = centerX + (x * cellSize);
const cy = centerY - (y * cellSize);
// Color based on TileId type?
if (tileId.includes('room')) ctx.fillStyle = '#55a';
else ctx.fillStyle = '#aaa';
@@ -379,6 +389,7 @@ export class UIManager {
ctx.lineTo(centerX, centerY + 5);
ctx.stroke();
}
showModal(title, message) {
// Overlay
const overlay = document.createElement('div');
@@ -414,7 +425,7 @@ export class UIManager {
// Message
const msgEl = document.createElement('p');
msgEl.textContent = message;
msgEl.innerHTML = message;
msgEl.style.fontSize = '16px';
msgEl.style.lineHeight = '1.5';
content.appendChild(msgEl);
@@ -437,6 +448,7 @@ export class UIManager {
overlay.appendChild(content);
this.container.appendChild(overlay);
}
showConfirm(title, message, onConfirm) {
// Overlay
const overlay = document.createElement('div');
@@ -472,7 +484,7 @@ export class UIManager {
// Message
const msgEl = document.createElement('p');
msgEl.textContent = message;
msgEl.innerHTML = message;
msgEl.style.fontSize = '16px';
msgEl.style.lineHeight = '1.5';
content.appendChild(msgEl);
@@ -516,4 +528,186 @@ export class UIManager {
overlay.appendChild(content);
this.container.appendChild(overlay);
}
createGameStatusPanel() {
// Top Center Panel
this.statusPanel = document.createElement('div');
this.statusPanel.style.position = 'absolute';
this.statusPanel.style.top = '20px';
this.statusPanel.style.left = '50%';
this.statusPanel.style.transform = 'translateX(-50%)';
this.statusPanel.style.display = 'flex';
this.statusPanel.style.flexDirection = 'column';
this.statusPanel.style.alignItems = 'center';
this.statusPanel.style.pointerEvents = 'none';
// Turn/Phase Info
this.phaseInfo = document.createElement('div');
this.phaseInfo.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
this.phaseInfo.style.padding = '10px 20px';
this.phaseInfo.style.border = '2px solid #daa520'; // GoldenRod
this.phaseInfo.style.borderRadius = '5px';
this.phaseInfo.style.color = '#fff';
this.phaseInfo.style.fontFamily = '"Cinzel", serif';
this.phaseInfo.style.fontSize = '20px';
this.phaseInfo.style.textAlign = 'center';
this.phaseInfo.style.textTransform = 'uppercase';
this.phaseInfo.style.minWidth = '200px';
this.phaseInfo.innerHTML = `
<div style="font-size: 14px; color: #aaa;">Turn 1</div>
<div style="font-size: 24px; color: #daa520;">Setup</div>
`;
this.statusPanel.appendChild(this.phaseInfo);
// End Phase Button
this.endPhaseBtn = document.createElement('button');
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
this.endPhaseBtn.style.marginTop = '10px';
this.endPhaseBtn.style.width = '100%';
this.endPhaseBtn.style.padding = '8px';
this.endPhaseBtn.style.backgroundColor = '#daa520'; // Gold
this.endPhaseBtn.style.color = '#000';
this.endPhaseBtn.style.border = '1px solid #8B4513';
this.endPhaseBtn.style.borderRadius = '3px';
this.endPhaseBtn.style.fontWeight = 'bold';
this.endPhaseBtn.style.cursor = 'pointer';
this.endPhaseBtn.style.display = 'none'; // Hidden by default
this.endPhaseBtn.style.fontFamily = '"Cinzel", serif';
this.endPhaseBtn.style.fontSize = '12px';
this.endPhaseBtn.style.pointerEvents = 'auto'; // Enable clicking
this.endPhaseBtn.onmouseover = () => { this.endPhaseBtn.style.backgroundColor = '#ffd700'; };
this.endPhaseBtn.onmouseout = () => { this.endPhaseBtn.style.backgroundColor = '#daa520'; };
this.endPhaseBtn.onclick = () => {
console.log('[UIManager] End Phase Button Clicked', this.game.turnManager.currentPhase);
this.game.turnManager.nextPhase();
};
this.statusPanel.appendChild(this.endPhaseBtn);
// Notification Area (Power Roll results, etc)
this.notificationArea = document.createElement('div');
this.notificationArea.style.marginTop = '10px';
this.notificationArea.style.transition = 'opacity 0.5s';
this.notificationArea.style.opacity = '0';
this.statusPanel.appendChild(this.notificationArea);
this.container.appendChild(this.statusPanel);
// Inject Font
if (!document.getElementById('game-font')) {
const link = document.createElement('link');
link.id = 'game-font';
link.href = 'https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&display=swap';
link.rel = 'stylesheet';
document.head.appendChild(link);
}
}
setupGameListeners() {
if (this.game.turnManager) {
this.game.turnManager.on('phase_changed', (phase) => {
this.updatePhaseDisplay(phase);
});
this.game.turnManager.on('POWER_RESULT', (data) => {
this.showPowerRollResult(data);
});
}
}
updatePhaseDisplay(phase) {
if (!this.phaseInfo) return;
const turn = this.game.turnManager.currentTurn;
let content = `
<div style="font-size: 14px; color: #aaa;">Turn ${turn}</div>
<div style="font-size: 24px; color: #daa520;">${phase.replace('_', ' ')}</div>
`;
if (this.selectedHero) {
content += this.getHeroStatsHTML(this.selectedHero);
}
this.phaseInfo.innerHTML = content;
if (this.endPhaseBtn) {
if (phase === 'hero') {
this.endPhaseBtn.style.display = 'block';
this.endPhaseBtn.textContent = 'ACABAR FASE AVENTUREROS';
this.endPhaseBtn.title = "Pasar a la Fase de Monstruos";
} else if (phase === 'monster') {
this.endPhaseBtn.style.display = 'block';
this.endPhaseBtn.textContent = 'ACABAR FASE MONSTRUOS';
this.endPhaseBtn.title = "Pasar a Fase de Exploración";
} else if (phase === 'exploration') {
this.endPhaseBtn.style.display = 'block';
this.endPhaseBtn.textContent = 'ACABAR TURNO';
this.endPhaseBtn.title = "Finalizar turno y comenzar Fase de Poder";
} else {
this.endPhaseBtn.style.display = 'none';
}
}
}
updateHeroStats(hero) {
if (!this.phaseInfo) return;
const turn = this.game.turnManager.currentTurn;
const phase = this.game.turnManager.currentPhase;
let content = `
<div style="font-size: 14px; color: #aaa;">Turn ${turn}</div>
<div style="font-size: 24px; color: #daa520;">${phase.replace('_', ' ')}</div>
`;
if (hero) {
content += this.getHeroStatsHTML(hero);
}
this.phaseInfo.innerHTML = content;
}
getHeroStatsHTML(hero) {
const portraitUrl = hero.texturePath || '';
const lanternIcon = hero.hasLantern ? '<span style="font-size: 20px; cursor: help;" title="Portador de la Lámpara">🏮</span>' : '';
return `
<div style="margin-top: 15px; border-top: 1px solid #555; paddingTop: 10px; display: flex; align-items: center; justify-content: center; gap: 15px;">
<div style="width: 50px; height: 50px; border-radius: 50%; overflow: hidden; border: 2px solid #daa520; background: #000;">
<img src="${portraitUrl}" style="width: 100%; height: 100%; object-fit: cover;" alt="${hero.name}">
</div>
<div style="text-align: left;">
<div style="color: #daa520; font-weight: bold; font-size: 16px;">
${hero.name} ${lanternIcon}
</div>
<div style="font-size: 14px;">
Moves: <span style="color: ${hero.currentMoves > 0 ? '#4f4' : '#f44'}; font-weight: bold;">${hero.currentMoves}</span> / ${hero.stats.move}
</div>
</div>
</div>
`;
}
showPowerRollResult(data) {
if (!this.notificationArea) return;
const { roll, message, eventTriggered } = data;
const color = eventTriggered ? '#ff4444' : '#44ff44';
this.notificationArea.innerHTML = `
<div style="background-color: rgba(0,0,0,0.9); padding: 15px; border: 1px solid ${color}; border-radius: 5px; text-align: center;">
<div style="font-family: 'Cinzel'; font-size: 18px; color: #fff; margin-bottom: 5px;">Power Phase</div>
<div style="font-size: 40px; font-weight: bold; color: ${color};">${roll}</div>
<div style="font-size: 14px; color: #ccc;">${message}</div>
</div>
`;
this.notificationArea.style.opacity = '1';
setTimeout(() => {
if (this.notificationArea) this.notificationArea.style.opacity = '0';
}, 3000);
}
}