Compare commits
10 Commits
8bb0dd8780
...
056217437c
| Author | SHA1 | Date | |
|---|---|---|---|
| 056217437c | |||
| 4c8b58151b | |||
| 3bfe9e4809 | |||
| 2f63e54d13 | |||
| 46b5466701 | |||
| 019e527441 | |||
| cd6abb016f | |||
| 7462dd7fed | |||
| dbed4468c5 | |||
| ac536ac96c |
39
DEVLOG.md
@@ -1,5 +1,44 @@
|
||||
# Devlog - Warhammer Quest (Versión Web 3D)
|
||||
|
||||
## Sesión 5: Refinamiento de UX y Jugabilidad (3 Enero 2026)
|
||||
|
||||
### Objetivos Completados
|
||||
1. **Validación Estricta de Puertas**:
|
||||
- Implementado control riguroso para puertas multicelda.
|
||||
- Si una puerta tiene 2 celdas, la tile conectada DEBE tener 2 salidas alineadas.
|
||||
- Fix: `selectDoor` ahora recupera el objeto salida completo (con `tileId`) para poder validar correctamente el grupo de celdas.
|
||||
|
||||
2. **UX de Colocación**:
|
||||
- Reemplazados `alert()` nativos con modales `showModal()` y `showConfirm()` estilizados.
|
||||
- Implementado botón de **Descarte** para bloquear puertas cuando una tile no cabe o no interesa.
|
||||
|
||||
3. **Sistema de Movimiento Táctico**:
|
||||
- **Planificación**: Click en jugador para seleccionar → Clicks en celdas contiguas para trazar ruta (1, 2, 3...).
|
||||
- **Deshacer**: Click en el último paso para eliminarlo.
|
||||
- **Ejecución**: Click derecho para iniciar el movimiento.
|
||||
- **Animación**: Implementado efecto de "botecito" (salto sinusoidal) al mover entre casillas.
|
||||
- **Visualización**: Marcadores amarillos con números sobre las casillas planificadas.
|
||||
|
||||
4. **Aleatoriedad Visual**:
|
||||
- Implementado sistema de **Texturas Aleatorias** para losetas con múltiples variantes (Rooms).
|
||||
- `DungeonGenerator` elige una textura al instanciar, `GameRenderer` la pinta.
|
||||
- Corrección de definición duplicada de `room_objective` en `TileDefinitions.js` (eliminada versión incorrecta de 4x4).
|
||||
|
||||
5. **Mejoras de Cámara**:
|
||||
- **Zoom Ajustado**: Rango modificado a 3-15 (default 6) para una vista más lejana y cómoda.
|
||||
- **Sincronización**: El slider de zoom ahora se actualiza al usar la rueda del ratón.
|
||||
- **Paneo**: Se modificó la lógica de paneo para mover tanto la cámara como el objetivo (`target`), evitando el efecto de órbita indeseado. *Estado final: Pendiente de validación por reporte de fallo en controles.*
|
||||
|
||||
### Estado Actual
|
||||
El juego es completamente jugable en cuanto a exploración y movimiento. La generación de mazmorras es robusta y visualmente más variada gracias a las texturas aleatorias. La interfaz es consistente y amigable.
|
||||
|
||||
### Próximos Pasos
|
||||
- Revisar controles de cámara (Paneo).
|
||||
- Implementar sistema de turnos / fases de juego.
|
||||
- Añadir enemigos y lógica de combate.
|
||||
|
||||
---
|
||||
|
||||
## Sesión 4: Sistema de Colocación Manual - Estado Actual (2 Enero 2026)
|
||||
|
||||
### Objetivo
|
||||
|
||||
57
Reglas/Fases.md
Normal file
@@ -0,0 +1,57 @@
|
||||
En Warhammer Quest, el juego se desarrolla en turnos divididos en **cuatro fases principales** que deben seguirse en orden estricto. Aquí tienes qué ocurre en cada una de ellas:
|
||||
|
||||
### 1. Fase de Poder
|
||||
|
||||
En esta fase, el Hechicero determina cuánta energía mágica tendrá disponible para el turno lanzando **1D6**.
|
||||
|
||||
*
|
||||
**Puntos de Poder:** El resultado del dado indica los puntos que puede gastar para lanzar hechizos.
|
||||
|
||||
|
||||
* **Eventos Inesperados:** Si el Hechicero saca un **1**, ocurre un evento inesperado. Se debe robar una carta del mazo de Eventos y seguir sus instrucciones (que suelen implicar la aparición repentina de monstruos).
|
||||
|
||||
|
||||
|
||||
### 2. Fase de los Aventureros
|
||||
|
||||
Es el momento en que los héroes actúan. El orden de actuación comienza siempre por el **Líder** (quien lleva la Lámpara, normalmente el Bárbaro) y continúa según el valor de **Iniciativa** de los demás (de mayor a mayor).
|
||||
|
||||
*
|
||||
**Movimiento:** Cada aventurero puede moverse tantas casillas como su atributo de Movimiento, a menos que esté "trabado" (adyacente a un monstruo).
|
||||
|
||||
|
||||
*
|
||||
**Combate:** Después de moverse, el aventurero puede atacar a los monstruos adyacentes en combate cuerpo a cuerpo o disparar si tiene un arma de proyectiles y no está trabado.
|
||||
|
||||
|
||||
*
|
||||
**Exploración preliminar:** Si un aventurero entra en una nueva sección de tablero, su turno termina inmediatamente.
|
||||
|
||||
|
||||
|
||||
### 3. Fase de los Monstruos
|
||||
|
||||
Si hay monstruos en el tablero, es su turno de devolver el golpe.
|
||||
|
||||
*
|
||||
**Nuevas Estancias:** Si los aventureros acaban de entrar en una **Estancia de Subterráneo**, se roba una carta de Evento para ver qué enemigos o peligros hay dentro.
|
||||
|
||||
|
||||
*
|
||||
**Ataque de Monstruos:** Los monstruos que ya estaban en juego se mueven hacia los aventureros y atacan siguiendo la regla de **"Uno contra Uno"** (repartiéndose equitativamente entre los héroes). Los monstruos que acaban de ser colocados en esta fase no atacan hasta el siguiente turno.
|
||||
|
||||
|
||||
|
||||
### 4. Fase de Exploración
|
||||
|
||||
Esta fase ocurre solo si no hay monstruos en la misma sección que el Líder.
|
||||
|
||||
*
|
||||
**Revelar la mazmorra:** El Líder, si está junto a una puerta inexplorada, puede declarar que explora.
|
||||
|
||||
|
||||
* **Nuevas secciones:** Se roba una carta del mazo de Mazmorra y se coloca la sección de tablero correspondiente. Los aventureros no podrán entrar en esta nueva zona hasta la Fase de Aventureros del siguiente turno.
|
||||
|
||||
|
||||
|
||||
**Nota importante:** Existe la **"Regla del 1 y el 6"**: un 1 natural siempre es un fallo y un 6 natural siempre es un éxito, sin importar los modificadores.
|
||||
BIN
public/assets/images/dungeon1/doors/door1_blocked.png
Normal file
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 80 KiB |
BIN
public/assets/images/dungeon1/standees/descartes/dwarf_low.png
Normal file
|
After Width: | Height: | Size: 321 KiB |
BIN
public/assets/images/dungeon1/standees/descartes/elfa_low.png
Normal file
|
After Width: | Height: | Size: 312 KiB |
BIN
public/assets/images/dungeon1/standees/descartes/warlock_low.png
Normal file
|
After Width: | Height: | Size: 317 KiB |
BIN
public/assets/images/dungeon1/standees/enemies/chaosWarrior.png
Normal file
|
After Width: | Height: | Size: 5.6 MiB |
BIN
public/assets/images/dungeon1/standees/enemies/orc.png
Normal file
|
After Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 571 KiB |
BIN
public/assets/images/dungeon1/standees/heroes/dwarf.png
Normal file
|
After Width: | Height: | Size: 4.9 MiB |
BIN
public/assets/images/dungeon1/standees/heroes/elfa.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
public/assets/images/dungeon1/standees/heroes/warlock.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
67
src/engine/data/Heroes.js
Normal 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
|
||||
}
|
||||
}
|
||||
};
|
||||
32
src/engine/data/Monsters.js
Normal 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
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -27,6 +27,7 @@ export class DungeonGenerator {
|
||||
// Callbacks for UI
|
||||
this.onStateChange = null;
|
||||
this.onPlacementUpdate = null;
|
||||
this.onDoorBlocked = null;
|
||||
}
|
||||
|
||||
startDungeon(missionConfig) {
|
||||
@@ -64,17 +65,17 @@ export class DungeonGenerator {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate exit exists
|
||||
const exitExists = this.availableExits.some(
|
||||
// Find the full exit object from availableExits (so we have tileId, etc.)
|
||||
const foundExit = this.availableExits.find(
|
||||
e => e.x === exitPoint.x && e.y === exitPoint.y && e.direction === exitPoint.direction
|
||||
);
|
||||
|
||||
if (!exitExists) {
|
||||
if (!foundExit) {
|
||||
console.warn("Invalid exit selected");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.selectedExit = exitPoint;
|
||||
this.selectedExit = foundExit;
|
||||
|
||||
// Draw next card
|
||||
this.currentCard = this.deck.draw();
|
||||
@@ -149,7 +150,68 @@ export class DungeonGenerator {
|
||||
if (!this.currentCard || this.state !== PLACEMENT_STATE.PLACING_TILE) return false;
|
||||
|
||||
const variant = this.currentCard.variants[this.placementRotation];
|
||||
return this.grid.canPlace(variant, this.placementX, this.placementY);
|
||||
|
||||
// 1. Basic Grid Collision
|
||||
if (!this.grid.canPlace(variant, this.placementX, this.placementY)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Strict Door Alignment Check
|
||||
if (this.selectedExit) {
|
||||
// Identify the full "door" group (e.g., the pair of cells forming the exit)
|
||||
// We look for other available exits on the same tile, facing the same way, and adjacent.
|
||||
const sourceExits = this.availableExits.filter(e =>
|
||||
e.tileId === this.selectedExit.tileId &&
|
||||
e.direction === this.selectedExit.direction &&
|
||||
(Math.abs(e.x - this.selectedExit.x) + Math.abs(e.y - this.selectedExit.y)) <= 1
|
||||
);
|
||||
|
||||
// For every cell in the source door, the new tile MUST have a connecting exit
|
||||
for (const source of sourceExits) {
|
||||
// The coordinate where the new tile's exit should be
|
||||
const targetPos = this.neighbor(source.x, source.y, source.direction);
|
||||
const requiredDirection = this.opposite(source.direction);
|
||||
|
||||
// Does the new tile provide an exit here?
|
||||
const hasMatch = variant.exits.some(localExit => {
|
||||
const gx = this.placementX + localExit.x;
|
||||
const gy = this.placementY + localExit.y;
|
||||
return gx === targetPos.x &&
|
||||
gy === targetPos.y &&
|
||||
localExit.direction === requiredDirection;
|
||||
});
|
||||
|
||||
if (!hasMatch) {
|
||||
return false; // Misalignment: New tile doesn't connect to all parts of the door
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
cancelPlacement() {
|
||||
if (this.state !== PLACEMENT_STATE.PLACING_TILE) return;
|
||||
|
||||
// 1. Mark door as blocked visually
|
||||
if (this.onDoorBlocked && this.selectedExit) {
|
||||
this.onDoorBlocked(this.selectedExit);
|
||||
}
|
||||
|
||||
// 2. Remove the selected exit from available exits
|
||||
if (this.selectedExit) {
|
||||
this.availableExits = this.availableExits.filter(e =>
|
||||
!(e.x === this.selectedExit.x && e.y === this.selectedExit.y && e.direction === this.selectedExit.direction)
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Reset state
|
||||
this.currentCard = null;
|
||||
this.selectedExit = null;
|
||||
this.state = PLACEMENT_STATE.WAITING_DOOR;
|
||||
|
||||
this.notifyPlacementUpdate();
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,11 +261,20 @@ export class DungeonGenerator {
|
||||
placeCardFinal(card, x, y, rotation) {
|
||||
const variant = card.variants[rotation];
|
||||
|
||||
// Randomize Texture if multiple are available
|
||||
let selectedTexture = null;
|
||||
if (card.textures && card.textures.length > 0) {
|
||||
const idx = Math.floor(Math.random() * card.textures.length);
|
||||
selectedTexture = card.textures[idx];
|
||||
console.log(`[DungeonGenerator] Selected texture for ${card.id}:`, selectedTexture);
|
||||
}
|
||||
|
||||
const instance = {
|
||||
id: `tile_${this.placedTiles.length}`,
|
||||
defId: card.id,
|
||||
x, y, rotation,
|
||||
name: card.name
|
||||
name: card.name,
|
||||
texture: selectedTexture
|
||||
};
|
||||
|
||||
this.grid.placeTile(instance, variant, card);
|
||||
|
||||
@@ -9,14 +9,19 @@ export const TILES = {
|
||||
id: 'corridor_straight',
|
||||
name: 'Corridor',
|
||||
type: TILE_TYPES.CORRIDOR,
|
||||
textures: ['/assets/images/dungeon1/tiles/corridor1.png'],
|
||||
textures: ['/assets/images/dungeon1/tiles/corridor1.png',
|
||||
'/assets/images/dungeon1/tiles/corridor2.png',
|
||||
'/assets/images/dungeon1/tiles/corridor3.png',
|
||||
],
|
||||
variants: {
|
||||
[DIRECTIONS.NORTH]: {
|
||||
width: 2, height: 6,
|
||||
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: 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]: {
|
||||
@@ -24,7 +29,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: 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]: {
|
||||
@@ -32,7 +39,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: 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]: {
|
||||
@@ -40,7 +49,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: 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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -61,6 +72,8 @@ export const TILES = {
|
||||
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 }
|
||||
]
|
||||
},
|
||||
[DIRECTIONS.SOUTH]: {
|
||||
@@ -69,6 +82,8 @@ export const TILES = {
|
||||
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 }
|
||||
]
|
||||
},
|
||||
[DIRECTIONS.EAST]: {
|
||||
@@ -77,6 +92,8 @@ export const TILES = {
|
||||
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 }
|
||||
]
|
||||
},
|
||||
[DIRECTIONS.WEST]: {
|
||||
@@ -85,6 +102,8 @@ export const TILES = {
|
||||
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 }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -323,53 +342,7 @@ export const TILES = {
|
||||
}
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ROOM OBJECTIVE
|
||||
// -------------------------------------------------------------------------
|
||||
'room_objective': {
|
||||
id: 'room_objective',
|
||||
name: 'Dungeon Room',
|
||||
type: TILE_TYPES.ROOM,
|
||||
textures: [
|
||||
'/assets/images/dungeon1/tiles/room_4x4_circle.png',
|
||||
'/assets/images/dungeon1/tiles/room_4x4_orange.png',
|
||||
'/assets/images/dungeon1/tiles/room_4x4_squeleton.png'
|
||||
],
|
||||
variants: {
|
||||
[DIRECTIONS.NORTH]: {
|
||||
width: 4, height: 4,
|
||||
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
|
||||
exits: [
|
||||
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH },
|
||||
{ x: 1, y: 3, direction: DIRECTIONS.NORTH }, { x: 2, y: 3, direction: DIRECTIONS.NORTH }
|
||||
]
|
||||
},
|
||||
[DIRECTIONS.EAST]: {
|
||||
width: 4, height: 4,
|
||||
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
|
||||
exits: [
|
||||
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST },
|
||||
{ x: 3, y: 1, direction: DIRECTIONS.EAST }, { x: 3, y: 2, direction: DIRECTIONS.EAST }
|
||||
]
|
||||
},
|
||||
[DIRECTIONS.SOUTH]: {
|
||||
width: 4, height: 4,
|
||||
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
|
||||
exits: [
|
||||
{ x: 1, y: 3, direction: DIRECTIONS.NORTH }, { x: 2, y: 3, direction: DIRECTIONS.NORTH },
|
||||
{ x: 1, y: 0, direction: DIRECTIONS.SOUTH }, { x: 2, y: 0, direction: DIRECTIONS.SOUTH }
|
||||
]
|
||||
},
|
||||
[DIRECTIONS.WEST]: {
|
||||
width: 4, height: 4,
|
||||
layout: [[1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1], [1, 1, 1, 1]],
|
||||
exits: [
|
||||
{ x: 3, y: 1, direction: DIRECTIONS.EAST }, { x: 3, y: 2, direction: DIRECTIONS.EAST },
|
||||
{ x: 0, y: 1, direction: DIRECTIONS.WEST }, { x: 0, y: 2, direction: DIRECTIONS.WEST }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ROOM OBJECTIVE
|
||||
|
||||
@@ -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,9 +9,11 @@ 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;
|
||||
this.plannedPath = []; // Array of {x,y}
|
||||
|
||||
// Callbacks
|
||||
this.onEntityUpdate = null;
|
||||
@@ -21,47 +26,225 @@ 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");
|
||||
}
|
||||
|
||||
if (this.onEntityUpdate) {
|
||||
this.onEntityUpdate(this.player);
|
||||
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];
|
||||
}
|
||||
|
||||
onCellClick(x, y) {
|
||||
// If no player selected, select player on click
|
||||
if (!this.selectedEntity && this.player && x === this.player.x && y === this.player.y) {
|
||||
this.selectedEntity = this.player;
|
||||
if (this.onEntitySelect) {
|
||||
this.onEntitySelect(this.player.id, true);
|
||||
}
|
||||
|
||||
spawnMonster(monsterKey, x, y) {
|
||||
const definition = MONSTER_DEFINITIONS[monsterKey];
|
||||
if (!definition) {
|
||||
console.error(`Monster definition not found: ${monsterKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If player selected, move to clicked cell
|
||||
if (this.selectedEntity === this.player) {
|
||||
if (this.canMoveTo(x, y)) {
|
||||
this.movePlayer(x, y);
|
||||
} else {
|
||||
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(monster);
|
||||
}
|
||||
|
||||
return monster;
|
||||
}
|
||||
|
||||
onCellClick(x, y) {
|
||||
// 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.deselectEntity();
|
||||
} else {
|
||||
// Select new entity
|
||||
if (this.selectedEntity) this.deselectEntity();
|
||||
|
||||
this.selectedEntity = clickedEntity;
|
||||
if (this.onEntitySelect) {
|
||||
this.onEntitySelect(clickedEntity.id, true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. PLAN MOVEMENT (If entity selected and we clicked empty space)
|
||||
if (this.selectedEntity) {
|
||||
this.planStep(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
deselectEntity() {
|
||||
if (!this.selectedEntity) return;
|
||||
const id = this.selectedEntity.id;
|
||||
this.selectedEntity = null;
|
||||
this.plannedPath = [];
|
||||
if (this.onEntitySelect) this.onEntitySelect(id, false);
|
||||
if (this.onPathChange) this.onPathChange([]);
|
||||
}
|
||||
|
||||
// Alias for legacy calls if any
|
||||
deselectPlayer() {
|
||||
this.deselectEntity();
|
||||
}
|
||||
|
||||
planStep(x, y) {
|
||||
if (!this.selectedEntity) return;
|
||||
|
||||
// Determine start point
|
||||
const lastStep = this.plannedPath.length > 0
|
||||
? this.plannedPath[this.plannedPath.length - 1]
|
||||
: { x: this.selectedEntity.x, y: this.selectedEntity.y };
|
||||
|
||||
// Check Adjacency
|
||||
const dx = Math.abs(x - lastStep.x);
|
||||
const dy = Math.abs(y - lastStep.y);
|
||||
const isAdjacent = (dx + dy) === 1;
|
||||
|
||||
// Check Walkability
|
||||
const isWalkable = this.canMoveTo(x, y);
|
||||
|
||||
// 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) {
|
||||
this.plannedPath.pop();
|
||||
this.onPathChange && this.onPathChange(this.plannedPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAdjacent && isWalkable) {
|
||||
const alreadyInPath = this.plannedPath.some(p => p.x === x && p.y === y);
|
||||
const isEntityPos = this.selectedEntity.x === x && this.selectedEntity.y === y;
|
||||
|
||||
// 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 });
|
||||
this.onPathChange && this.onPathChange(this.plannedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
executeMovePath() {
|
||||
if (!this.selectedEntity || !this.plannedPath.length) return;
|
||||
|
||||
const path = [...this.plannedPath];
|
||||
const entity = this.selectedEntity;
|
||||
|
||||
// Update verify immediately
|
||||
const finalDest = path[path.length - 1];
|
||||
entity.x = finalDest.x;
|
||||
entity.y = finalDest.y;
|
||||
|
||||
// Visual animation
|
||||
if (this.onEntityMove) {
|
||||
this.onEntityMove(entity, path);
|
||||
}
|
||||
|
||||
// Deduct Moves
|
||||
if (entity.currentMoves !== undefined) {
|
||||
entity.currentMoves -= path.length;
|
||||
if (entity.currentMoves < 0) entity.currentMoves = 0;
|
||||
}
|
||||
|
||||
this.deselectEntity();
|
||||
}
|
||||
|
||||
canMoveTo(x, y) {
|
||||
@@ -69,37 +252,82 @@ export class GameEngine {
|
||||
return this.dungeon.grid.isOccupied(x, y);
|
||||
}
|
||||
|
||||
// Deprecated direct move
|
||||
movePlayer(x, y) {
|
||||
// Simple direct movement (no pathfinding for now)
|
||||
const path = [{ x, y }];
|
||||
|
||||
this.player.x = x;
|
||||
this.player.y = y;
|
||||
|
||||
if (this.onEntityMove) {
|
||||
this.onEntityMove(this.player, path);
|
||||
}
|
||||
|
||||
// Deselect after move
|
||||
this.selectedEntity = null;
|
||||
if (this.onEntitySelect) {
|
||||
this.onEntitySelect(this.player.id, false);
|
||||
}
|
||||
|
||||
|
||||
if (this.onEntityMove) this.onEntityMove(this.player, [{ x, y }]);
|
||||
}
|
||||
|
||||
isPlayerAdjacentToDoor(doorExit) {
|
||||
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 dx = Math.abs(this.player.x - doorExit.x);
|
||||
const dy = Math.abs(this.player.y - doorExit.y);
|
||||
const leader = this.getLeader();
|
||||
if (!leader) return false;
|
||||
|
||||
// Adjacent means distance of 1 in one direction and 0 in the other
|
||||
return (dx === 1 && dy === 0) || (dx === 0 && dy === 1);
|
||||
const cells = Array.isArray(doorCells) ? doorCells : [doorCells];
|
||||
|
||||
for (const cell of cells) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 --
|
||||
|
||||
101
src/main.js
@@ -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);
|
||||
@@ -83,6 +81,14 @@ generator.onPlacementUpdate = (preview) => {
|
||||
}
|
||||
};
|
||||
|
||||
generator.onDoorBlocked = (exitData) => {
|
||||
renderer.blockDoor(exitData);
|
||||
};
|
||||
|
||||
game.onPathChange = (path) => {
|
||||
renderer.updatePathVisualization(path);
|
||||
};
|
||||
|
||||
// 6. Handle Clicks
|
||||
const handleClick = (x, y, doorMesh) => {
|
||||
// PRIORITY 1: Tile Placement Mode - ignore all clicks
|
||||
@@ -92,26 +98,52 @@ const handleClick = (x, y, doorMesh) => {
|
||||
}
|
||||
|
||||
// PRIORITY 2: Door Click (must be adjacent to player)
|
||||
if (doorMesh && doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
|
||||
const doorExit = doorMesh.userData.cells[0];
|
||||
|
||||
if (game.isPlayerAdjacentToDoor(doorExit)) {
|
||||
|
||||
|
||||
// Open door visually
|
||||
renderer.openDoor(doorMesh);
|
||||
|
||||
// Get proper exit data with direction
|
||||
const exitData = doorMesh.userData.exitData;
|
||||
if (exitData) {
|
||||
generator.selectDoor(exitData);
|
||||
} else {
|
||||
console.error('[Main] Door missing exitData');
|
||||
}
|
||||
} else {
|
||||
|
||||
if (doorMesh && doorMesh.userData.isDoor) {
|
||||
if (doorMesh.userData.isBlocked) {
|
||||
ui.showModal('¡Derrumbe!', 'Esta puerta está bloqueada por un derrumbe. No se puede pasar.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!doorMesh.userData.isOpen) {
|
||||
|
||||
// 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);
|
||||
|
||||
// Get proper exit data with direction
|
||||
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 {
|
||||
ui.showModal('Demasiado lejos', 'El Líder debe estar <b>adyacente</b> a la puerta para abrirla.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// PRIORITY 3: Normal cell click (player selection/movement)
|
||||
@@ -123,9 +155,26 @@ const handleClick = (x, y, doorMesh) => {
|
||||
renderer.setupInteraction(
|
||||
() => cameraManager.getCamera(),
|
||||
handleClick,
|
||||
() => { } // No right-click
|
||||
() => {
|
||||
// Right Click Handler
|
||||
game.executeMovePath();
|
||||
}
|
||||
);
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -7,9 +7,11 @@ export class CameraManager {
|
||||
|
||||
// Configuration
|
||||
// Configuration
|
||||
this.zoomLevel = 2.5; // Orthographic zoom factor (Lower = Closer)
|
||||
this.zoomLevel = 6.0; // Started further back as requested
|
||||
this.aspect = window.innerWidth / window.innerHeight;
|
||||
|
||||
this.onZoomChange = null;
|
||||
|
||||
// Isometric Setup: Orthographic Camera
|
||||
this.camera = new THREE.OrthographicCamera(
|
||||
-this.zoomLevel * this.aspect,
|
||||
@@ -52,9 +54,14 @@ export class CameraManager {
|
||||
}
|
||||
|
||||
centerOn(x, y) {
|
||||
// Grid (x, y) -> World (x, 0, -y)
|
||||
// Calculate current offset relative to OLD target
|
||||
const currentOffset = this.camera.position.clone().sub(this.target);
|
||||
|
||||
// Update target: Grid (x, y) -> World (x, 0, -y)
|
||||
this.target.set(x, 0, -y);
|
||||
this.camera.position.copy(this.target).add(this.isoOffset);
|
||||
|
||||
// Restore position with new target + same relative offset
|
||||
this.camera.position.copy(this.target).add(currentOffset);
|
||||
this.camera.lookAt(this.target);
|
||||
}
|
||||
|
||||
@@ -64,9 +71,10 @@ export class CameraManager {
|
||||
e.preventDefault();
|
||||
// Adjust Zoom Level property
|
||||
if (e.deltaY < 0) this.zoomLevel = Math.max(3, this.zoomLevel - 1);
|
||||
else this.zoomLevel = Math.min(30, this.zoomLevel + 1);
|
||||
else this.zoomLevel = Math.min(15, this.zoomLevel + 1);
|
||||
|
||||
this.updateProjection();
|
||||
if (this.onZoomChange) this.onZoomChange(this.zoomLevel);
|
||||
}, { passive: false });
|
||||
|
||||
// Pan Listeners (Middle Click)
|
||||
@@ -109,28 +117,30 @@ export class CameraManager {
|
||||
}
|
||||
|
||||
pan(dx, dy) {
|
||||
// Move Target and Camera together
|
||||
// We pan on the logical "Ground Plane" relative to screen movement
|
||||
|
||||
// Move Speed Factor
|
||||
const moveSpeed = this.panSpeed * 0.05 * (this.zoomLevel / 10);
|
||||
|
||||
// Transform screen delta to world delta
|
||||
// In Iso view, Right on screen = (1, 0, 1) in world?
|
||||
// Or using camera right/up vectors
|
||||
// Direction: Dragging the "World"
|
||||
// Mouse Left (dx < 0) -> Camera moves Right (+X)
|
||||
// Mouse Up (dy < 0) -> Camera moves Down (-Y)
|
||||
const moveX = -dx * moveSpeed;
|
||||
const moveY = dy * moveSpeed;
|
||||
|
||||
const right = new THREE.Vector3(1, 0, 1).normalize(); // Approx logic for standard Iso
|
||||
const forward = new THREE.Vector3(-1, 0, 1).normalize();
|
||||
// Apply to Camera (Local Space)
|
||||
this.camera.translateX(moveX);
|
||||
this.camera.translateY(moveY);
|
||||
|
||||
// Let's use camera vectors for generic support
|
||||
// Project camera right/up onto XZ plane
|
||||
// Or just direct translation:
|
||||
// Calculate World Movement to update Target
|
||||
const vRight = new THREE.Vector3(1, 0, 0).applyQuaternion(this.camera.quaternion);
|
||||
const vUp = new THREE.Vector3(0, 1, 0).applyQuaternion(this.camera.quaternion);
|
||||
|
||||
this.camera.translateX(dx * moveSpeed);
|
||||
this.camera.translateY(dy * moveSpeed);
|
||||
const worldTranslation = new THREE.Vector3()
|
||||
.addScaledVector(vRight, moveX)
|
||||
.addScaledVector(vUp, moveY);
|
||||
|
||||
// This moves camera. We need to update target reference too if we want to snap back correctly
|
||||
// But for now, simple pan is "offsetting everything".
|
||||
// centerOn resets this.
|
||||
// Apply same movement to Target so relative offset is preserved
|
||||
// This ensures lookAt() doesn't pivot the camera around the old center
|
||||
this.target.add(worldTranslation);
|
||||
}
|
||||
|
||||
update(deltaTime) {
|
||||
|
||||
@@ -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 });
|
||||
@@ -240,32 +248,47 @@ export class GameRenderer {
|
||||
}
|
||||
|
||||
if (data.isMoving) {
|
||||
const duration = 400; // ms per tile
|
||||
const duration = 300; // Faster jump (300ms)
|
||||
const elapsed = time - data.startTime;
|
||||
const t = Math.min(elapsed / duration, 1);
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Lerp X/Z
|
||||
mesh.position.x = THREE.MathUtils.lerp(data.startPos.x, data.targetPos.x, t);
|
||||
mesh.position.z = THREE.MathUtils.lerp(data.startPos.z, data.targetPos.z, t);
|
||||
mesh.position.x = THREE.MathUtils.lerp(data.startPos.x, data.targetPos.x, progress);
|
||||
mesh.position.z = THREE.MathUtils.lerp(data.startPos.z, data.targetPos.z, progress);
|
||||
|
||||
// Jump Arc
|
||||
// Hop (Botecito)
|
||||
const jumpHeight = 0.5;
|
||||
const baseHeight = 1.56 / 2;
|
||||
mesh.position.y = baseHeight + (0.5 * Math.sin(t * Math.PI));
|
||||
mesh.position.y = baseHeight + Math.sin(progress * Math.PI) * jumpHeight;
|
||||
|
||||
if (t >= 1) {
|
||||
mesh.position.set(data.targetPos.x, baseHeight, data.targetPos.z);
|
||||
if (progress >= 1) {
|
||||
data.isMoving = false;
|
||||
mesh.position.y = baseHeight; // Reset height
|
||||
|
||||
// IF Finished Sequence (Queue empty)
|
||||
if (data.pathQueue.length === 0) {
|
||||
// Check if it's the player (id 'p1')
|
||||
if (id === 'p1' && this.onHeroFinishedMove) {
|
||||
// Grid Coords from World Coords (X, -Z)
|
||||
this.onHeroFinishedMove(mesh.position.x, -mesh.position.z);
|
||||
// Remove the visualization tile for this step
|
||||
if (this.pathGroup) {
|
||||
for (let i = this.pathGroup.children.length - 1; i >= 0; i--) {
|
||||
const child = this.pathGroup.children[i];
|
||||
// Match X and Z (ignoring small float errors)
|
||||
if (Math.abs(child.position.x - data.targetPos.x) < 0.1 &&
|
||||
Math.abs(child.position.z - data.targetPos.z) < 0.1) {
|
||||
this.pathGroup.remove(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// IF Finished Sequence (Queue empty)
|
||||
if (data.pathQueue.length === 0) {
|
||||
// Check if it's the player (id 'p1')
|
||||
if (id === 'p1' && this.onHeroFinishedMove) {
|
||||
// Grid Coords from World Coords (X, -Z)
|
||||
this.onHeroFinishedMove(mesh.position.x, -mesh.position.z);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
renderExits(exits) {
|
||||
@@ -456,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;
|
||||
@@ -483,7 +506,8 @@ export class GameRenderer {
|
||||
// Draw Texture Plane (The Image) - WAIT FOR TEXTURE TO LOAD
|
||||
if (tileDef && tileInstance && tileDef.textures && tileDef.textures.length > 0) {
|
||||
|
||||
const texturePath = tileDef.textures[0];
|
||||
// Use specific texture if assigned (randomized), otherwise default to first
|
||||
const texturePath = tileInstance.texture || tileDef.textures[0];
|
||||
|
||||
// Load texture with callback
|
||||
this.getTexture(texturePath, (texture) => {
|
||||
@@ -585,6 +609,65 @@ export class GameRenderer {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========== PATH VISUALIZATION ==========
|
||||
|
||||
updatePathVisualization(path) {
|
||||
if (!this.pathGroup) {
|
||||
this.pathGroup = new THREE.Group();
|
||||
this.scene.add(this.pathGroup);
|
||||
}
|
||||
|
||||
this.pathGroup.clear();
|
||||
|
||||
if (!path || path.length === 0) return;
|
||||
|
||||
path.forEach((step, index) => {
|
||||
const geometry = new THREE.PlaneGeometry(0.8, 0.8);
|
||||
const texture = this.createNumberTexture(index + 1);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: 0.8, // Texture itself has opacity
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
const plane = new THREE.Mesh(geometry, material);
|
||||
plane.position.set(step.x, 0.02, -step.y); // Slightly above floor
|
||||
plane.rotation.x = -Math.PI / 2;
|
||||
|
||||
// Store step index to identify it later if needed
|
||||
plane.userData.stepIndex = index;
|
||||
|
||||
this.pathGroup.add(plane);
|
||||
});
|
||||
}
|
||||
|
||||
createNumberTexture(number) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Yellow background with 50% opacity
|
||||
ctx.fillStyle = 'rgba(255, 255, 0, 0.5)';
|
||||
ctx.fillRect(0, 0, 64, 64);
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = '#EDA900';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.strokeRect(0, 0, 64, 64);
|
||||
|
||||
// Text
|
||||
ctx.font = 'bold 36px Arial';
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(number.toString(), 32, 32);
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
// tex.magFilter = THREE.NearestFilter; // Optional, might look pixelated
|
||||
return tex;
|
||||
}
|
||||
|
||||
isPlayerAdjacentToDoor(playerX, playerY, doorMesh) {
|
||||
if (!doorMesh || !doorMesh.userData.isDoor) return false;
|
||||
|
||||
@@ -601,6 +684,36 @@ export class GameRenderer {
|
||||
return false;
|
||||
}
|
||||
|
||||
blockDoor(exitData) {
|
||||
if (!this.exitGroup || !exitData) return;
|
||||
|
||||
// Find the door mesh
|
||||
let targetDoor = null;
|
||||
|
||||
for (const child of this.exitGroup.children) {
|
||||
if (child.userData.isDoor) {
|
||||
// Check if this door corresponds to the exitData
|
||||
// exitData has x,y of one of the cells
|
||||
for (const cell of child.userData.cells) {
|
||||
if (cell.x === exitData.x && cell.y === exitData.y) {
|
||||
targetDoor = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targetDoor) break;
|
||||
}
|
||||
|
||||
if (targetDoor) {
|
||||
this.getTexture('/assets/images/dungeon1/doors/door1_blocked.png', (texture) => {
|
||||
targetDoor.material.map = texture;
|
||||
targetDoor.material.needsUpdate = true;
|
||||
targetDoor.userData.isBlocked = true;
|
||||
targetDoor.userData.isOpen = false; // Ensure strictly not open
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ========== MANUAL PLACEMENT SYSTEM ==========
|
||||
|
||||
enableDoorSelection(enabled) {
|
||||
@@ -727,12 +840,31 @@ export class GameRenderer {
|
||||
});
|
||||
}
|
||||
|
||||
// 2. GROUND PROJECTION (Green/Red)
|
||||
const projectionColor = isValid ? 0x00ff00 : 0xff0000;
|
||||
// 2. GROUND PROJECTION (Green/Red/Blue)
|
||||
const baseColor = isValid ? 0x00ff00 : 0xff0000;
|
||||
|
||||
// Calculate global exit positions
|
||||
const exitKeys = new Set();
|
||||
if (preview.variant && preview.variant.exits) {
|
||||
preview.variant.exits.forEach(ex => {
|
||||
const gx = x + ex.x;
|
||||
const gy = y + ex.y;
|
||||
exitKeys.add(`${gx},${gy}`);
|
||||
});
|
||||
}
|
||||
|
||||
cells.forEach(cell => {
|
||||
const key = `${cell.x},${cell.y}`;
|
||||
let color = baseColor;
|
||||
|
||||
// If this cell is an exit, color it Blue
|
||||
if (exitKeys.has(key)) {
|
||||
color = 0x0000ff; // Blue
|
||||
}
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(0.95, 0.95);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: projectionColor,
|
||||
color: color,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
|
||||
@@ -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() {
|
||||
@@ -65,20 +95,26 @@ export class UIManager {
|
||||
|
||||
const zoomSlider = document.createElement('input');
|
||||
zoomSlider.type = 'range';
|
||||
zoomSlider.min = '2.5'; // Closest zoom
|
||||
zoomSlider.max = '30'; // Farthest zoom
|
||||
zoomSlider.value = '2.5'; // Start at closest
|
||||
zoomSlider.min = '3';
|
||||
zoomSlider.max = '15';
|
||||
zoomSlider.value = '6';
|
||||
zoomSlider.step = '0.5';
|
||||
zoomSlider.style.width = '100px';
|
||||
zoomSlider.style.transform = 'rotate(-90deg)';
|
||||
zoomSlider.style.transformOrigin = 'center';
|
||||
zoomSlider.style.cursor = 'pointer';
|
||||
zoomSlider.style.marginTop = '40px'; // Push slider down to make room for label
|
||||
zoomSlider.style.marginTop = '40px';
|
||||
|
||||
// Set initial zoom to closest
|
||||
this.cameraManager.zoomLevel = 2.5;
|
||||
this.zoomSlider = zoomSlider;
|
||||
|
||||
// Set initial zoom
|
||||
this.cameraManager.zoomLevel = 6;
|
||||
this.cameraManager.updateProjection();
|
||||
|
||||
this.cameraManager.onZoomChange = (val) => {
|
||||
if (this.zoomSlider) this.zoomSlider.value = val;
|
||||
};
|
||||
|
||||
zoomSlider.oninput = (e) => {
|
||||
this.cameraManager.zoomLevel = parseFloat(e.target.value);
|
||||
this.cameraManager.updateProjection();
|
||||
@@ -224,7 +260,6 @@ export class UIManager {
|
||||
};
|
||||
placementControls.appendChild(this.rotateBtn);
|
||||
|
||||
// Place button
|
||||
this.placeBtn = document.createElement('button');
|
||||
this.placeBtn.textContent = '⬇ Bajar';
|
||||
this.placeBtn.style.padding = '10px 20px';
|
||||
@@ -238,11 +273,34 @@ export class UIManager {
|
||||
if (this.dungeon) {
|
||||
const success = this.dungeon.confirmPlacement();
|
||||
if (!success) {
|
||||
alert('❌ No se puede colocar la loseta en esta posición');
|
||||
this.showModal('Error de Colocación', 'No se puede colocar la loseta en esta posición.');
|
||||
}
|
||||
}
|
||||
};
|
||||
placementControls.appendChild(this.placeBtn);
|
||||
|
||||
// Discard button
|
||||
this.discardBtn = document.createElement('button');
|
||||
this.discardBtn.textContent = '❌ Cancelar';
|
||||
this.discardBtn.style.padding = '10px 20px';
|
||||
this.discardBtn.style.backgroundColor = '#d33';
|
||||
this.discardBtn.style.color = '#fff';
|
||||
this.discardBtn.style.border = '1px solid #888';
|
||||
this.discardBtn.style.cursor = 'pointer';
|
||||
this.discardBtn.style.fontSize = '16px';
|
||||
this.discardBtn.style.borderRadius = '4px';
|
||||
this.discardBtn.onclick = () => {
|
||||
if (this.dungeon) {
|
||||
this.showConfirm(
|
||||
'Confirmar acción',
|
||||
'¿Quieres descartar esta loseta y bloquear la puerta?',
|
||||
() => {
|
||||
this.dungeon.cancelPlacement();
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
placementControls.appendChild(this.discardBtn);
|
||||
}
|
||||
|
||||
showPlacementControls(show) {
|
||||
@@ -295,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';
|
||||
|
||||
@@ -351,4 +389,325 @@ export class UIManager {
|
||||
ctx.lineTo(centerX, centerY + 5);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
showModal(title, message) {
|
||||
// Overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'absolute';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100%';
|
||||
overlay.style.height = '100%';
|
||||
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.justifyContent = 'center';
|
||||
overlay.style.alignItems = 'center';
|
||||
overlay.style.pointerEvents = 'auto'; // Block clicks behind
|
||||
overlay.style.zIndex = '1000';
|
||||
|
||||
// Content Box
|
||||
const content = document.createElement('div');
|
||||
content.style.backgroundColor = '#222';
|
||||
content.style.border = '2px solid #888';
|
||||
content.style.borderRadius = '8px';
|
||||
content.style.padding = '20px';
|
||||
content.style.width = '300px';
|
||||
content.style.textAlign = 'center';
|
||||
content.style.color = '#fff';
|
||||
content.style.fontFamily = 'sans-serif';
|
||||
|
||||
// Title
|
||||
const titleEl = document.createElement('h2');
|
||||
titleEl.textContent = title;
|
||||
titleEl.style.marginTop = '0';
|
||||
titleEl.style.color = '#f44'; // Reddish for importance
|
||||
content.appendChild(titleEl);
|
||||
|
||||
// Message
|
||||
const msgEl = document.createElement('p');
|
||||
msgEl.innerHTML = message;
|
||||
msgEl.style.fontSize = '16px';
|
||||
msgEl.style.lineHeight = '1.5';
|
||||
content.appendChild(msgEl);
|
||||
|
||||
// OK Button
|
||||
const btn = document.createElement('button');
|
||||
btn.textContent = 'Entendido';
|
||||
btn.style.marginTop = '20px';
|
||||
btn.style.padding = '10px 20px';
|
||||
btn.style.fontSize = '16px';
|
||||
btn.style.cursor = 'pointer';
|
||||
btn.style.backgroundColor = '#444';
|
||||
btn.style.color = '#fff';
|
||||
btn.style.border = '1px solid #888';
|
||||
btn.onclick = () => {
|
||||
this.container.removeChild(overlay);
|
||||
};
|
||||
content.appendChild(btn);
|
||||
|
||||
overlay.appendChild(content);
|
||||
this.container.appendChild(overlay);
|
||||
}
|
||||
|
||||
showConfirm(title, message, onConfirm) {
|
||||
// Overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'absolute';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100%';
|
||||
overlay.style.height = '100%';
|
||||
overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.justifyContent = 'center';
|
||||
overlay.style.alignItems = 'center';
|
||||
overlay.style.pointerEvents = 'auto'; // Block clicks behind
|
||||
overlay.style.zIndex = '1000';
|
||||
|
||||
// Content Box
|
||||
const content = document.createElement('div');
|
||||
content.style.backgroundColor = '#222';
|
||||
content.style.border = '2px solid #888';
|
||||
content.style.borderRadius = '8px';
|
||||
content.style.padding = '20px';
|
||||
content.style.width = '300px';
|
||||
content.style.textAlign = 'center';
|
||||
content.style.color = '#fff';
|
||||
content.style.fontFamily = 'sans-serif';
|
||||
|
||||
// Title
|
||||
const titleEl = document.createElement('h2');
|
||||
titleEl.textContent = title;
|
||||
titleEl.style.marginTop = '0';
|
||||
titleEl.style.color = '#f44';
|
||||
content.appendChild(titleEl);
|
||||
|
||||
// Message
|
||||
const msgEl = document.createElement('p');
|
||||
msgEl.innerHTML = message;
|
||||
msgEl.style.fontSize = '16px';
|
||||
msgEl.style.lineHeight = '1.5';
|
||||
content.appendChild(msgEl);
|
||||
|
||||
// Buttons Container
|
||||
const buttons = document.createElement('div');
|
||||
buttons.style.display = 'flex';
|
||||
buttons.style.justifyContent = 'space-around';
|
||||
buttons.style.marginTop = '20px';
|
||||
|
||||
// Cancel Button
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.textContent = 'Cancelar';
|
||||
cancelBtn.style.padding = '10px 20px';
|
||||
cancelBtn.style.fontSize = '16px';
|
||||
cancelBtn.style.cursor = 'pointer';
|
||||
cancelBtn.style.backgroundColor = '#555';
|
||||
cancelBtn.style.color = '#fff';
|
||||
cancelBtn.style.border = '1px solid #888';
|
||||
cancelBtn.onclick = () => {
|
||||
this.container.removeChild(overlay);
|
||||
};
|
||||
buttons.appendChild(cancelBtn);
|
||||
|
||||
// Confirm Button
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.textContent = 'Aceptar';
|
||||
confirmBtn.style.padding = '10px 20px';
|
||||
confirmBtn.style.fontSize = '16px';
|
||||
confirmBtn.style.cursor = 'pointer';
|
||||
confirmBtn.style.backgroundColor = '#2a5';
|
||||
confirmBtn.style.color = '#fff';
|
||||
confirmBtn.style.border = '1px solid #888';
|
||||
confirmBtn.onclick = () => {
|
||||
if (onConfirm) onConfirm();
|
||||
this.container.removeChild(overlay);
|
||||
};
|
||||
buttons.appendChild(confirmBtn);
|
||||
|
||||
content.appendChild(buttons);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||