Compare commits
2 Commits
009c2a4135
...
613fa843ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 613fa843ee | |||
| 5888c59ba4 |
55
DEVLOG.md
55
DEVLOG.md
@@ -1,5 +1,56 @@
|
||||
# Devlog - Warhammer Quest (Versión Web 3D)
|
||||
|
||||
## Sesión 12 (Continuación): Refactorización y Renderizado (Intento II - Exitoso)
|
||||
**Fecha:** 9 de Enero de 2026
|
||||
|
||||
### Objetivos
|
||||
- Completar la refactorización de `GameRenderer.js` sin las regresiones visuales del primer intento.
|
||||
- Solucionar el error crítico de inicialización de módulos (`setPathGroup is not a function`).
|
||||
|
||||
### Cambios Realizados (Refactor V2 "Quirúrgica")
|
||||
- **Modularización Exitosa**:
|
||||
- `SceneManager.js`: Gestiona escena, cámara y luces. Se incluyó el fix de `window.innerHeight` para evitar la pantalla negra.
|
||||
- `DungeonRenderer.js`: Renderizado de tiles, puertas y Niebla de Guerra. Mantiene filtros `NearestFilter` y `SRGBColorSpace`.
|
||||
- `EntityRenderer.js`: Renderizado de héroes, monstruos y animaciones. Mantiene la lógica de limpieza de ruta paso a paso.
|
||||
- `InteractionRenderer.js`: Mantiene la visualización **exacta** de rutas (cuadrados amarillos con números) y gestión de input.
|
||||
- `EffectsRenderer.js`: Partículas y textos flotantes.
|
||||
- `GameRenderer.js`: Actúa como fachada (Facade) delegando llamadas a los módulos.
|
||||
|
||||
### Corrección de Errores (Hotfix)
|
||||
- **Error**: `Uncaught TypeError: this.entityRenderer.setPathGroup is not a function`.
|
||||
- **Causa**: El navegador mantenía una versión caché de `EntityRenderer.js` anterior a la implementación del método `setPathGroup`.
|
||||
- **Solución**:
|
||||
1. Se añadió un log de inicialización en el constructor de `EntityRenderer` (`V2.1`) para forzar la actualización del módulo.
|
||||
2. Se envolvió la llamada a `setPathGroup` en `GameRenderer` con una validación de tipo (`typeof ... === 'function'`) y un log de error explícito para evitar el crash de la aplicación.
|
||||
|
||||
### Estado Actual
|
||||
- El juego carga correctamente.
|
||||
- La estructura de código está modularizada y limpia.
|
||||
- **No hay regresiones visuales**: Los héroes se ven nítidos (pixel art) y la visualización de movimiento es la original.
|
||||
|
||||
---
|
||||
|
||||
## Sesión 12: Refactorización y Renderizado (Intento I)
|
||||
**Fecha:** 9 de Enero de 2026
|
||||
|
||||
### Objetivos
|
||||
- Refactorizar visualmente el `GameRenderer.js` para dividirlo en submódulos ordenados (`Dungeon`, `Entity`, `Effect`, `Interaction`).
|
||||
- Mejorar la escalabilidad del código de renderizado y separar responsabilidades.
|
||||
|
||||
### Ocurrido
|
||||
- Se realizó un intento completo de refactorización que, aunque arquitectónicamente sólido, introdujo regresiones visuales críticas:
|
||||
- **Pantalla Negra**: Debido a una inicialización del canvas basada en un contenedor de altura 0, corregida con `window.innerHeight`.
|
||||
- **Pérdida de Estilo Píxel**: Las miniaturas de los héroes se veían borrosas/blanquecinas por olvidar `THREE.SRGBColorSpace` y `minFilter/magFilter: Nearest`.
|
||||
- **Cambio de Estilo**: La visualización de rutas de movimiento era diferente a la original.
|
||||
|
||||
### Acción Táctica
|
||||
- **Reversión (Rollback)**: Se decidió revertir todos los cambios de renderizado (`git restore`) para volver a un estado visualmente perfecto y comenzar la refactorización (v2) de forma segura y controlada, aplicando las lecciones aprendidas (altura del canvas, filtros de textura) desde el principio.
|
||||
|
||||
### Próximos Pasos (Inmediato)
|
||||
- Re-implementar la refactorización de `GameRenderer` de forma "Quirúrgica", copiando la lógica visual original línea por línea para no alterar la estética pixel-art ni el comportamiento del juego.
|
||||
|
||||
---
|
||||
|
||||
## Sesión 11: Sistema de Iniciativa y Niebla de Guerra (Fog of War)
|
||||
**Fecha:** 9 de Enero de 2026
|
||||
|
||||
@@ -37,6 +88,10 @@
|
||||
- **Ocultación de Entidades en Niebla**: Los héroes y monstruos que quedan fuera del alcance de la lámpara (losetas no visibles) ahora desaparecen completamente de la vista, aumentando la inmersión.
|
||||
- **Salto de Turno Automático**: Si un héroe comienza su turno en una zona oscura (oculta por la Niebla de Guerra), pierde automáticamente su turno hasta que sea "rescatado" (iluminado de nuevo) por el Portador de la Lámpara.
|
||||
- **Botones de Fase**: Se ha reorganizado la barra superior. El botón de "Acabar Fase" ahora comparte espacio con un nuevo botón "Acabar Turno" específico para cada héroe, facilitando el flujo de juego sin tener que buscar en la ficha del personaje.
|
||||
- **Sistema de Destrabado (Break Away)**: Implementada la mecánica para escapar del combate cuerpo a cuerpo ("Trabado").
|
||||
- Los héroes trabados verán un botón de "Destrabarse" en su ficha.
|
||||
- Al pulsarlo, el sistema lanza 1D6 contra el valor `pin_target` del héroe.
|
||||
- Éxito: El héroe recupera su libertad de movimiento. Fallo: El héroe pierde su movimiento y debe luchar.
|
||||
|
||||
### Estado Actual
|
||||
El juego ahora respeta las reglas de visión y turno del juego de mesa original con una fidelidad visual alta. La sensación de exploración es más tensa al ocultarse las zonas lejanas ("se perderán en la oscuridad"), y el orden táctico es crucial. La UI es más intuitiva y limpia durante el combate.
|
||||
|
||||
@@ -77,6 +77,7 @@ export class GameEngine {
|
||||
this.heroes.forEach(hero => {
|
||||
hero.currentMoves = hero.stats.move;
|
||||
hero.hasAttacked = false;
|
||||
hero.hasEscapedPin = false; // Reset pin escape status
|
||||
});
|
||||
console.log("Refilled Hero Moves");
|
||||
}
|
||||
@@ -598,6 +599,9 @@ export class GameEngine {
|
||||
isEntityPinned(entity) {
|
||||
if (!this.monsters || this.monsters.length === 0) return false;
|
||||
|
||||
// If already escaped this turn, not pinned
|
||||
if (entity.hasEscapedPin) return false;
|
||||
|
||||
return this.monsters.some(m => {
|
||||
if (m.isDead) return false;
|
||||
const dx = Math.abs(entity.x - m.x);
|
||||
@@ -629,6 +633,26 @@ export class GameEngine {
|
||||
});
|
||||
}
|
||||
|
||||
attemptBreakAway(hero) {
|
||||
if (!hero || hero.hasEscapedPin) return { success: false, roll: 0 };
|
||||
|
||||
const roll = Math.floor(Math.random() * 6) + 1;
|
||||
const target = hero.stats.pin_target || 6;
|
||||
|
||||
const success = roll >= target;
|
||||
|
||||
if (success) {
|
||||
hero.hasEscapedPin = true;
|
||||
} else {
|
||||
// Failed to escape: Unit loses movement and ranged attacks?
|
||||
// "The Adventurer must stay where he is and fight"
|
||||
// So movement becomes 0.
|
||||
hero.currentMoves = 0;
|
||||
}
|
||||
|
||||
return { success, roll, target };
|
||||
}
|
||||
|
||||
// Alias for legacy calls if any
|
||||
deselectPlayer() {
|
||||
this.deselectEntity();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
367
src/view/render/DungeonRenderer.js
Normal file
367
src/view/render/DungeonRenderer.js
Normal file
@@ -0,0 +1,367 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class DungeonRenderer {
|
||||
constructor(scene, getTextureCallback) {
|
||||
this.scene = scene;
|
||||
this.getTexture = getTextureCallback;
|
||||
|
||||
this.dungeonGroup = new THREE.Group();
|
||||
this.scene.add(this.dungeonGroup);
|
||||
|
||||
this.exitGroup = new THREE.Group();
|
||||
this.scene.add(this.exitGroup);
|
||||
|
||||
// Track pending renders
|
||||
this._pendingExitRender = false;
|
||||
}
|
||||
|
||||
addTile(cells, type, tileDef, tileInstance) {
|
||||
// cells: Array of {x, y} global coordinates
|
||||
// tileDef: The definition object (has textures, dimensions)
|
||||
// tileInstance: The instance object (has x, y, rotation, id)
|
||||
|
||||
// Draw Texture Plane (The Image) - WAIT FOR TEXTURE TO LOAD
|
||||
if (tileDef && tileInstance && tileDef.textures && tileDef.textures.length > 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) => {
|
||||
// --- NEW LOGIC: Calculate center based on DIMENSIONS, not CELLS ---
|
||||
|
||||
// 1. Get the specific variant for this rotation to know the VISUAL bounds
|
||||
const currentVariant = tileDef.variants[tileInstance.rotation];
|
||||
|
||||
if (!currentVariant) {
|
||||
console.error(`[DungeonRenderer] Missing variant for rotation ${tileInstance.rotation}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rotWidth = currentVariant.width;
|
||||
const rotHeight = currentVariant.height;
|
||||
|
||||
// 2. Calculate the Geometric Center of the tile relative to the anchor
|
||||
const cx = tileInstance.x + (rotWidth - 1) / 2;
|
||||
const cy = tileInstance.y + (rotHeight - 1) / 2;
|
||||
|
||||
// 3. Use BASE dimensions from NORTH variant for the Plane
|
||||
const baseWidth = tileDef.variants.N.width;
|
||||
const baseHeight = tileDef.variants.N.height;
|
||||
|
||||
// Create Plane with BASE dimensions
|
||||
const geometry = new THREE.PlaneGeometry(baseWidth, baseHeight);
|
||||
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
side: THREE.FrontSide, // Only visible from top
|
||||
alphaTest: 0.1
|
||||
});
|
||||
const plane = new THREE.Mesh(geometry, material);
|
||||
|
||||
// Initial Rotation: Plane X-Y to X-Z (Flat on ground)
|
||||
plane.rotation.x = -Math.PI / 2;
|
||||
|
||||
// Handle Rotation
|
||||
const rotMap = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 };
|
||||
const r = rotMap[tileInstance.rotation] !== undefined ? rotMap[tileInstance.rotation] : 0;
|
||||
|
||||
// Apply Tile Rotation (Z-axis of the plane corresponds to world Y axis rotation)
|
||||
plane.rotation.z = -r * (Math.PI / 2);
|
||||
|
||||
plane.position.set(cx, 0.01, -cy);
|
||||
plane.receiveShadow = true;
|
||||
|
||||
// Store Metadata for FOW
|
||||
plane.userData.tileId = tileInstance.id;
|
||||
plane.userData.cells = cells;
|
||||
|
||||
this.dungeonGroup.add(plane);
|
||||
});
|
||||
} else {
|
||||
console.warn(`[DungeonRenderer] details missing for texture render. def: ${!!tileDef}, inst: ${!!tileInstance} `);
|
||||
}
|
||||
}
|
||||
|
||||
renderExits(exits) {
|
||||
// Cancel any pending render
|
||||
if (this._pendingExitRender) {
|
||||
this._pendingExitRender = false;
|
||||
}
|
||||
|
||||
if (!exits || exits.length === 0) return;
|
||||
|
||||
// Get existing door cells to avoid duplicates
|
||||
const existingDoorCells = new Set();
|
||||
this.exitGroup.children.forEach(child => {
|
||||
if (child.userData.isDoor) {
|
||||
child.userData.cells.forEach(cell => {
|
||||
existingDoorCells.add(`${cell.x},${cell.y} `);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out exits that already have doors
|
||||
const newExits = exits.filter(ex => !existingDoorCells.has(`${ex.x},${ex.y} `));
|
||||
|
||||
if (newExits.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set flag for this render
|
||||
this._pendingExitRender = true;
|
||||
const thisRender = this._pendingExitRender;
|
||||
|
||||
// LOAD TEXTURE
|
||||
this.getTexture('/assets/images/dungeon1/doors/door1_closed.png', (texture) => {
|
||||
// Check if this render was cancelled
|
||||
if (!thisRender || this._pendingExitRender !== thisRender) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
color: 0xffffff,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
// Grouping Logic
|
||||
const processed = new Set();
|
||||
const doors = [];
|
||||
|
||||
// Helper to normalize direction to number
|
||||
const normalizeDir = (dir) => {
|
||||
if (typeof dir === 'number') return dir;
|
||||
const map = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 };
|
||||
return map[dir] ?? dir;
|
||||
};
|
||||
|
||||
newExits.forEach((ex, i) => {
|
||||
const key = `${ex.x},${ex.y} `;
|
||||
const exDir = normalizeDir(ex.direction);
|
||||
|
||||
if (processed.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let partner = null;
|
||||
for (let j = i + 1; j < newExits.length; j++) {
|
||||
const other = newExits[j];
|
||||
const otherKey = `${other.x},${other.y} `;
|
||||
const otherDir = normalizeDir(other.direction);
|
||||
|
||||
if (processed.has(otherKey)) continue;
|
||||
|
||||
if (exDir !== otherDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let isAdj = false;
|
||||
if (exDir === 0 || exDir === 2) {
|
||||
// North/South: check if same Y and adjacent X
|
||||
isAdj = (ex.y === other.y && Math.abs(ex.x - other.x) === 1);
|
||||
} else {
|
||||
// East/West: check if same X and adjacent Y
|
||||
isAdj = (ex.x === other.x && Math.abs(ex.y - other.y) === 1);
|
||||
}
|
||||
|
||||
if (isAdj) {
|
||||
partner = other;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (partner) {
|
||||
doors.push([ex, partner]);
|
||||
processed.add(key);
|
||||
processed.add(`${partner.x},${partner.y} `);
|
||||
} else {
|
||||
doors.push([ex]);
|
||||
processed.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Render Doors
|
||||
doors.forEach((door, idx) => {
|
||||
const d1 = door[0];
|
||||
const d2 = door.length > 1 ? door[1] : d1;
|
||||
|
||||
const centerX = (d1.x + d2.x) / 2;
|
||||
const centerY = (d1.y + d2.y) / 2;
|
||||
const dir = normalizeDir(d1.direction);
|
||||
|
||||
let angle = 0;
|
||||
let worldX = centerX;
|
||||
let worldZ = -centerY;
|
||||
|
||||
if (dir === 0) {
|
||||
angle = 0;
|
||||
worldZ -= 0.5;
|
||||
} else if (dir === 2) {
|
||||
angle = 0;
|
||||
worldZ += 0.5;
|
||||
} else if (dir === 1) {
|
||||
angle = Math.PI / 2;
|
||||
worldX += 0.5;
|
||||
} else if (dir === 3) {
|
||||
angle = Math.PI / 2;
|
||||
worldX -= 0.5;
|
||||
}
|
||||
|
||||
const geom = new THREE.PlaneGeometry(2, 2);
|
||||
// Clone material for each door so they can have independent textures
|
||||
const doorMat = mat.clone();
|
||||
const mesh = new THREE.Mesh(geom, doorMat);
|
||||
|
||||
mesh.position.set(worldX, 1, worldZ);
|
||||
mesh.rotation.y = angle;
|
||||
|
||||
const dirMap = { 0: 'N', 1: 'E', 2: 'S', 3: 'W' };
|
||||
mesh.userData = {
|
||||
isDoor: true,
|
||||
isOpen: false,
|
||||
cells: [d1, d2],
|
||||
direction: dir,
|
||||
exitData: {
|
||||
x: d1.x,
|
||||
y: d1.y,
|
||||
direction: dirMap[dir] || 'N'
|
||||
}
|
||||
};
|
||||
mesh.name = `door_${idx} `;
|
||||
|
||||
this.exitGroup.add(mesh);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateFogOfWar(visibleTileIds, entitiesMap) {
|
||||
if (!this.dungeonGroup) return;
|
||||
|
||||
const visibleSet = new Set(visibleTileIds);
|
||||
const visibleCellKeys = new Set();
|
||||
|
||||
// 1. Update Tile Visibility & Collect Visible Cells
|
||||
this.dungeonGroup.children.forEach(mesh => {
|
||||
const isVisible = visibleSet.has(mesh.userData.tileId);
|
||||
mesh.visible = isVisible;
|
||||
if (isVisible && mesh.userData.cells) {
|
||||
mesh.userData.cells.forEach(cell => {
|
||||
visibleCellKeys.add(`${cell.x},${cell.y}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Hide/Show Entities based on Tile Visibility
|
||||
if (entitiesMap) {
|
||||
entitiesMap.forEach((mesh, id) => {
|
||||
// Get grid coords (World X, -Z)
|
||||
const gx = Math.round(mesh.position.x);
|
||||
const gy = Math.round(-mesh.position.z);
|
||||
const key = `${gx},${gy}`;
|
||||
|
||||
// If the cell is visible, show the entity. Otherwise hide it.
|
||||
if (visibleCellKeys.has(key)) {
|
||||
mesh.visible = true;
|
||||
} else {
|
||||
mesh.visible = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Also update Doors (in exitGroup)
|
||||
if (this.exitGroup) {
|
||||
this.exitGroup.children.forEach(door => {
|
||||
door.visible = false;
|
||||
});
|
||||
|
||||
// Re-iterate to show close doors
|
||||
this.dungeonGroup.children.forEach(tile => {
|
||||
if (tile.visible) {
|
||||
// Check doors near this tile
|
||||
if (this.exitGroup) {
|
||||
this.exitGroup.children.forEach(door => {
|
||||
if (door.visible) return; // Already shown
|
||||
|
||||
const tx = tile.position.x;
|
||||
const ty = -tile.position.z;
|
||||
|
||||
const dx = Math.abs(door.position.x - tx);
|
||||
const dy = Math.abs(door.position.z - (-ty)); // Z is neg
|
||||
|
||||
if (dx < 4 && dy < 4) {
|
||||
door.visible = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
openDoor(doorMesh) {
|
||||
if (!doorMesh || !doorMesh.userData.isDoor) return;
|
||||
if (doorMesh.userData.isOpen) return; // Already open
|
||||
|
||||
// Load open door texture
|
||||
this.getTexture('/assets/images/dungeon1/doors/door1_open.png', (texture) => {
|
||||
doorMesh.material.map = texture;
|
||||
doorMesh.material.needsUpdate = true;
|
||||
doorMesh.userData.isOpen = true;
|
||||
});
|
||||
}
|
||||
|
||||
blockDoor(exitData) {
|
||||
if (!this.exitGroup || !exitData) return;
|
||||
|
||||
let targetDoor = null;
|
||||
|
||||
for (const child of this.exitGroup.children) {
|
||||
if (child.userData.isDoor) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getDoorAtPosition(x, y) {
|
||||
if (!this.exitGroup) return null;
|
||||
for (const child of this.exitGroup.children) {
|
||||
if (child.userData.isDoor) {
|
||||
for (const cell of child.userData.cells) {
|
||||
if (cell.x === x && cell.y === y) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
isPlayerAdjacentToDoor(playerX, playerY, doorMesh) {
|
||||
if (!doorMesh || !doorMesh.userData.isDoor) return false;
|
||||
for (const cell of doorMesh.userData.cells) {
|
||||
const dx = Math.abs(playerX - cell.x);
|
||||
const dy = Math.abs(playerY - cell.y);
|
||||
if ((dx === 1 && dy === 0) || (dx === 0 && dy === 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
115
src/view/render/EffectsRenderer.js
Normal file
115
src/view/render/EffectsRenderer.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import * as THREE from 'three';
|
||||
import { ParticleManager } from '../ParticleManager.js';
|
||||
|
||||
export class EffectsRenderer {
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
this.particleManager = new ParticleManager(scene);
|
||||
|
||||
this.floatingTextGroup = new THREE.Group();
|
||||
this.scene.add(this.floatingTextGroup);
|
||||
|
||||
this.lastTime = 0;
|
||||
}
|
||||
|
||||
update(time) {
|
||||
if (!this.lastTime) this.lastTime = time;
|
||||
const delta = (time - this.lastTime) / 1000;
|
||||
this.lastTime = time;
|
||||
|
||||
if (this.particleManager) {
|
||||
this.particleManager.update(delta);
|
||||
}
|
||||
|
||||
// Update Floating Texts
|
||||
const now = time;
|
||||
for (let i = this.floatingTextGroup.children.length - 1; i >= 0; i--) {
|
||||
const sprite = this.floatingTextGroup.children[i];
|
||||
const elapsed = now - sprite.userData.startTime;
|
||||
const progress = elapsed / sprite.userData.duration;
|
||||
|
||||
if (progress >= 1) {
|
||||
this.floatingTextGroup.remove(sprite);
|
||||
} else {
|
||||
// Float Up
|
||||
sprite.position.y = sprite.userData.startY + (progress * 1.5);
|
||||
// Fade Out in last half
|
||||
if (progress > 0.5) {
|
||||
sprite.material.opacity = 1 - ((progress - 0.5) * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
triggerVisualEffect(type, x, y) {
|
||||
if (this.particleManager) {
|
||||
if (type === 'fireball') {
|
||||
this.particleManager.spawnFireballExplosion(x, -y);
|
||||
} else if (type === 'heal') {
|
||||
this.particleManager.spawnHealEffect(x, -y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
triggerProjectile(startX, startY, endX, endY, onHitCallback) {
|
||||
if (this.particleManager) {
|
||||
this.particleManager.spawnProjectile(startX, -startY, endX, -endY, onHitCallback);
|
||||
} else {
|
||||
if (onHitCallback) onHitCallback();
|
||||
}
|
||||
}
|
||||
|
||||
showFloatingText(x, y, text, color = "#ffffff") {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 256;
|
||||
canvas.height = 128;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
ctx.font = "bold 60px Arial";
|
||||
ctx.textAlign = "center";
|
||||
ctx.lineWidth = 4;
|
||||
ctx.strokeStyle = "black";
|
||||
ctx.strokeText(text, 128, 64);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(text, 128, 64);
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
const material = new THREE.SpriteMaterial({ map: texture, transparent: true });
|
||||
const sprite = new THREE.Sprite(material);
|
||||
|
||||
sprite.position.set(x, 2.0, -y);
|
||||
sprite.position.x += (Math.random() - 0.5) * 0.2;
|
||||
sprite.scale.set(2, 1, 1);
|
||||
|
||||
sprite.userData = {
|
||||
startTime: performance.now(),
|
||||
duration: 2000,
|
||||
startY: sprite.position.y
|
||||
};
|
||||
|
||||
this.floatingTextGroup.add(sprite);
|
||||
}
|
||||
|
||||
showCombatFeedback(x, y, damage, isHit, defenseText = 'Block', getEntityAtCallback) {
|
||||
// Trigger shake via entity found at position
|
||||
if (isHit && damage > 0 && getEntityAtCallback) {
|
||||
const entityId = getEntityAtCallback(x, y);
|
||||
// We return entity ID so the caller can trigger damage effect on EntityRenderer
|
||||
// But EffectsRenderer handles the TEXT part.
|
||||
}
|
||||
|
||||
if (isHit) {
|
||||
if (damage > 0) {
|
||||
this.showFloatingText(x, y, `💥 -${damage}`, '#ff0000');
|
||||
} else {
|
||||
this.showFloatingText(x, y, `🛡️ ${defenseText}`, '#ffff00');
|
||||
}
|
||||
} else {
|
||||
this.showFloatingText(x, y, `💨 Miss`, '#aaaaaa');
|
||||
}
|
||||
|
||||
// Return info for EntityRenderer interaction if needed?
|
||||
// Actually, GameRenderer facade typically handles the split:
|
||||
// gameRenderer.showCombatFeedback calls effectsRenderer.showFloatingText AND entityRenderer.triggerDamageEffect
|
||||
}
|
||||
}
|
||||
377
src/view/render/EntityRenderer.js
Normal file
377
src/view/render/EntityRenderer.js
Normal file
@@ -0,0 +1,377 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class EntityRenderer {
|
||||
constructor(scene, getTextureCallback) {
|
||||
console.log("EntityRenderer: Initializing (V2.1)");
|
||||
this.scene = scene;
|
||||
this.getTexture = getTextureCallback;
|
||||
this.entities = new Map();
|
||||
|
||||
// Callback for hero movement finish
|
||||
this.onHeroFinishedMove = null;
|
||||
this.pathGroup = null; // Will be injected
|
||||
|
||||
// Tokens
|
||||
this.tokensGroup = new THREE.Group();
|
||||
this.scene.add(this.tokensGroup);
|
||||
this.tokens = new Map();
|
||||
|
||||
this.lastTime = 0;
|
||||
}
|
||||
|
||||
addEntity(entity) {
|
||||
if (this.entities.has(entity.id)) return;
|
||||
|
||||
const w = 1.04;
|
||||
const h = 1.56;
|
||||
const geometry = new THREE.PlaneGeometry(w, h);
|
||||
|
||||
this.getTexture(entity.texturePath, (texture) => {
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
alphaTest: 0.1
|
||||
});
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
|
||||
mesh.userData = {
|
||||
pathQueue: [],
|
||||
isMoving: false,
|
||||
startPos: null,
|
||||
targetPos: null,
|
||||
startTime: 0
|
||||
};
|
||||
|
||||
mesh.position.set(entity.x, h / 2, -entity.y);
|
||||
|
||||
// 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 });
|
||||
const ring = new THREE.Mesh(ringGeom, ringMat);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.y = -h / 2 + 0.05;
|
||||
ring.visible = false;
|
||||
ring.name = "SelectionRing";
|
||||
mesh.add(ring);
|
||||
|
||||
this.scene.add(mesh);
|
||||
this.entities.set(entity.id, mesh);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Token Management ---
|
||||
showTokens(heroes, monsters) {
|
||||
this.hideTokens(); // Clear existing
|
||||
if (this.tokensGroup) this.tokensGroup.visible = true;
|
||||
|
||||
const createToken = (entity, type, subType) => {
|
||||
const geometry = new THREE.CircleGeometry(0.35, 32);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: (type === 'hero') ? 0x00BFFF : 0xDC143C, // Fallback color
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 1.0
|
||||
});
|
||||
const token = new THREE.Mesh(geometry, material);
|
||||
token.rotation.x = -Math.PI / 2;
|
||||
|
||||
// Sync with 3D entity if it exists
|
||||
const mesh3D = this.entities.get(entity.id);
|
||||
if (mesh3D) {
|
||||
token.position.set(mesh3D.position.x, 0.05, mesh3D.position.z);
|
||||
} else {
|
||||
token.position.set(entity.x, 0.05, -entity.y);
|
||||
}
|
||||
|
||||
this.tokensGroup.add(token);
|
||||
this.tokens.set(entity.id, token);
|
||||
|
||||
// White Border Ring
|
||||
const borderGeo = new THREE.RingGeometry(0.35, 0.38, 32);
|
||||
const borderMat = new THREE.MeshBasicMaterial({ color: 0xFFFFFF, side: THREE.DoubleSide });
|
||||
const border = new THREE.Mesh(borderGeo, borderMat);
|
||||
border.position.z = 0.001;
|
||||
token.add(border);
|
||||
|
||||
// Load Image
|
||||
let path = '';
|
||||
const filename = subType; // Assuming subtype is filename/key
|
||||
|
||||
if (type === 'hero') {
|
||||
path = `/assets/images/dungeon1/tokens/heroes/${filename}.png`;
|
||||
} else {
|
||||
path = `/assets/images/dungeon1/tokens/enemies/${filename}.png`;
|
||||
}
|
||||
|
||||
this.getTexture(path, (texture) => {
|
||||
token.material.map = texture;
|
||||
token.material.color.setHex(0xFFFFFF);
|
||||
token.material.needsUpdate = true;
|
||||
}, undefined, (err) => {
|
||||
console.warn(`[EntityRenderer] Token texture missing: ${path} `);
|
||||
});
|
||||
};
|
||||
|
||||
if (heroes) heroes.forEach(h => createToken(h, 'hero', h.key));
|
||||
if (monsters) monsters.forEach(m => {
|
||||
if (!m.isDead) createToken(m, 'monster', m.key);
|
||||
});
|
||||
}
|
||||
|
||||
hideTokens() {
|
||||
if (this.tokensGroup) {
|
||||
this.tokensGroup.clear();
|
||||
this.tokensGroup.visible = false;
|
||||
}
|
||||
if (this.tokens) this.tokens.clear();
|
||||
}
|
||||
|
||||
moveEntityAlongPath(entity, path) {
|
||||
const mesh = this.entities.get(entity.id);
|
||||
if (mesh) {
|
||||
mesh.userData.pathQueue = [...path];
|
||||
}
|
||||
}
|
||||
|
||||
updateEntityPosition(entity) {
|
||||
const mesh = this.entities.get(entity.id);
|
||||
if (mesh) {
|
||||
if (mesh.userData.isMoving || mesh.userData.pathQueue.length > 0) return;
|
||||
mesh.position.set(entity.x, 1.56 / 2, -entity.y);
|
||||
|
||||
if (this.tokens) {
|
||||
const token = this.tokens.get(entity.id);
|
||||
if (token) {
|
||||
token.position.set(entity.x, 0.05, -entity.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleEntitySelection(entityId, isSelected) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (mesh) {
|
||||
const ring = mesh.getObjectByName("SelectionRing");
|
||||
if (ring) ring.visible = isSelected;
|
||||
}
|
||||
}
|
||||
|
||||
setEntityActive(entityId, isActive) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (!mesh) return;
|
||||
|
||||
const oldRing = mesh.getObjectByName("ActiveRing");
|
||||
if (oldRing) mesh.remove(oldRing);
|
||||
|
||||
if (isActive) {
|
||||
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x00ff00,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 0.8
|
||||
});
|
||||
|
||||
const ring = new THREE.Mesh(ringGeom, ringMat);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.y = -1.56 / 2 + 0.05;
|
||||
ring.name = "ActiveRing";
|
||||
mesh.add(ring);
|
||||
}
|
||||
}
|
||||
|
||||
setEntityTarget(entityId, isTarget) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (!mesh) return;
|
||||
|
||||
const oldRing = mesh.getObjectByName("TargetRing");
|
||||
if (oldRing) mesh.remove(oldRing);
|
||||
|
||||
if (isTarget) {
|
||||
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x00AADD,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 0.8
|
||||
});
|
||||
const ring = new THREE.Mesh(ringGeom, ringMat);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.y = -1.56 / 2 + 0.06;
|
||||
ring.name = "TargetRing";
|
||||
mesh.add(ring);
|
||||
}
|
||||
}
|
||||
|
||||
clearAllActiveRings() {
|
||||
this.entities.forEach(mesh => {
|
||||
const ring = mesh.getObjectByName("ActiveRing");
|
||||
if (ring) mesh.remove(ring);
|
||||
const ring2 = mesh.getObjectByName("SelectionRing");
|
||||
if (ring2) ring2.visible = false;
|
||||
const ring3 = mesh.getObjectByName("TargetRing");
|
||||
if (ring3) mesh.remove(ring3);
|
||||
});
|
||||
}
|
||||
|
||||
triggerDamageEffect(entityId) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (!mesh) return;
|
||||
|
||||
mesh.traverse((child) => {
|
||||
if (child.material && child.material.map) {
|
||||
if (!child.userData.originalColor) {
|
||||
child.userData.originalColor = child.material.color.clone();
|
||||
}
|
||||
child.material.color.setHex(0xff0000);
|
||||
setTimeout(() => {
|
||||
if (child.material) child.material.color.copy(child.userData.originalColor);
|
||||
}, 150);
|
||||
}
|
||||
});
|
||||
|
||||
const originalPos = mesh.position.clone();
|
||||
const startTime = performance.now();
|
||||
const duration = 800;
|
||||
|
||||
mesh.userData.shake = {
|
||||
startTime: startTime,
|
||||
duration: duration,
|
||||
magnitude: 0.1,
|
||||
originalPos: originalPos
|
||||
};
|
||||
}
|
||||
|
||||
triggerDeathAnimation(entityId) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (!mesh) return;
|
||||
|
||||
const startTime = performance.now();
|
||||
const duration = 1500;
|
||||
|
||||
mesh.userData.death = {
|
||||
startTime: startTime,
|
||||
duration: duration,
|
||||
initialOpacity: 1.0
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
if (mesh && mesh.parent) {
|
||||
mesh.parent.remove(mesh);
|
||||
}
|
||||
this.entities.delete(entityId);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
setPathGroup(group) {
|
||||
this.pathGroup = group;
|
||||
}
|
||||
|
||||
updateAnimations(time) {
|
||||
let isAnyMoving = false;
|
||||
|
||||
this.entities.forEach((mesh, id) => {
|
||||
const data = mesh.userData;
|
||||
|
||||
if (!data.isMoving && data.pathQueue.length > 0) {
|
||||
const nextStep = data.pathQueue.shift();
|
||||
data.isMoving = true;
|
||||
data.startTime = time;
|
||||
data.startPos = mesh.position.clone();
|
||||
data.targetPos = new THREE.Vector3(nextStep.x, mesh.position.y, -nextStep.y);
|
||||
}
|
||||
|
||||
if (data.isMoving || data.pathQueue.length > 0) {
|
||||
isAnyMoving = true;
|
||||
}
|
||||
|
||||
if (data.isMoving) {
|
||||
const duration = 300;
|
||||
const elapsed = time - data.startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
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);
|
||||
|
||||
if (this.tokens) {
|
||||
const token = this.tokens.get(id);
|
||||
if (token) {
|
||||
token.position.x = mesh.position.x;
|
||||
token.position.z = mesh.position.z;
|
||||
}
|
||||
}
|
||||
|
||||
const jumpHeight = 0.5;
|
||||
const baseHeight = 1.56 / 2;
|
||||
mesh.position.y = baseHeight + Math.sin(progress * Math.PI) * jumpHeight;
|
||||
|
||||
if (progress >= 1) {
|
||||
data.isMoving = false;
|
||||
mesh.position.y = baseHeight;
|
||||
|
||||
// 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 (id === 'p1' && this.onHeroFinishedMove) {
|
||||
this.onHeroFinishedMove(mesh.position.x, -mesh.position.z);
|
||||
}
|
||||
}
|
||||
} else if (data.shake) {
|
||||
const elapsed = time - data.shake.startTime;
|
||||
if (elapsed < data.shake.duration) {
|
||||
const progress = elapsed / data.shake.duration;
|
||||
const mag = data.shake.magnitude * (1 - progress);
|
||||
const offsetX = (Math.random() - 0.5) * mag * 2;
|
||||
const offsetZ = (Math.random() - 0.5) * mag * 2;
|
||||
mesh.position.x = data.shake.originalPos.x + offsetX;
|
||||
mesh.position.z = data.shake.originalPos.z + offsetZ;
|
||||
} else {
|
||||
mesh.position.copy(data.shake.originalPos);
|
||||
delete data.shake;
|
||||
}
|
||||
} else if (data.death) {
|
||||
const elapsed = time - data.death.startTime;
|
||||
const progress = Math.min(elapsed / data.death.duration, 1);
|
||||
const opacity = data.death.initialOpacity * (1 - progress);
|
||||
|
||||
mesh.traverse((child) => {
|
||||
if (child.material) {
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach(mat => { mat.transparent = true; mat.opacity = opacity; });
|
||||
} else {
|
||||
child.material.transparent = true; child.material.opacity = opacity;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (data.death.initialY === undefined) data.death.initialY = mesh.position.y;
|
||||
mesh.position.y = data.death.initialY - (progress * 0.5);
|
||||
|
||||
if (progress >= 1) {
|
||||
delete data.death;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Global Sound Logic for steps
|
||||
if (window.SOUND_MANAGER) {
|
||||
if (isAnyMoving) {
|
||||
window.SOUND_MANAGER.startLoop('footsteps');
|
||||
} else {
|
||||
window.SOUND_MANAGER.stopLoop('footsteps');
|
||||
}
|
||||
}
|
||||
|
||||
return isAnyMoving;
|
||||
}
|
||||
}
|
||||
397
src/view/render/InteractionRenderer.js
Normal file
397
src/view/render/InteractionRenderer.js
Normal file
@@ -0,0 +1,397 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class InteractionRenderer {
|
||||
constructor(scene, renderer, camera, interactionPlane, getTextureCallback) {
|
||||
this.scene = scene;
|
||||
this.renderer = renderer;
|
||||
this.camera = camera;
|
||||
this.interactionPlane = interactionPlane;
|
||||
this.getTexture = getTextureCallback;
|
||||
|
||||
this.raycaster = new THREE.Raycaster();
|
||||
this.mouse = new THREE.Vector2();
|
||||
|
||||
this.highlightGroup = new THREE.Group();
|
||||
this.scene.add(this.highlightGroup);
|
||||
|
||||
this.previewGroup = new THREE.Group();
|
||||
this.scene.add(this.previewGroup);
|
||||
|
||||
this.projectionGroup = new THREE.Group();
|
||||
this.scene.add(this.projectionGroup);
|
||||
|
||||
this.spellPreviewGroup = new THREE.Group();
|
||||
this.scene.add(this.spellPreviewGroup);
|
||||
|
||||
this.rangedGroup = new THREE.Group();
|
||||
this.scene.add(this.rangedGroup);
|
||||
|
||||
this.exitHighlightGroup = new THREE.Group();
|
||||
this.scene.add(this.exitHighlightGroup);
|
||||
|
||||
this.pathGroup = new THREE.Group();
|
||||
this.scene.add(this.pathGroup);
|
||||
}
|
||||
|
||||
setupInteraction(cameraGetter, onClick, onRightClick, onHover = null, getExitGroupCallback = null) {
|
||||
const getMousePos = (event) => {
|
||||
const rect = this.renderer.domElement.getBoundingClientRect();
|
||||
return {
|
||||
x: ((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
y: -((event.clientY - rect.top) / rect.height) * 2 + 1
|
||||
};
|
||||
};
|
||||
|
||||
const handleHover = (event) => {
|
||||
if (!onHover) return;
|
||||
this.mouse.set(getMousePos(event).x, getMousePos(event).y);
|
||||
this.raycaster.setFromCamera(this.mouse, cameraGetter());
|
||||
const intersects = this.raycaster.intersectObject(this.interactionPlane);
|
||||
if (intersects.length > 0) {
|
||||
const p = intersects[0].point;
|
||||
const x = Math.round(p.x);
|
||||
const y = Math.round(-p.z);
|
||||
onHover(x, y);
|
||||
}
|
||||
};
|
||||
|
||||
this.renderer.domElement.addEventListener('mousemove', handleHover);
|
||||
|
||||
this.renderer.domElement.addEventListener('click', (event) => {
|
||||
this.mouse.set(getMousePos(event).x, getMousePos(event).y);
|
||||
this.raycaster.setFromCamera(this.mouse, cameraGetter());
|
||||
|
||||
// First, check if we clicked on a door mesh
|
||||
if (getExitGroupCallback) {
|
||||
const exitGroup = getExitGroupCallback();
|
||||
if (exitGroup) {
|
||||
const doorIntersects = this.raycaster.intersectObjects(exitGroup.children, false);
|
||||
if (doorIntersects.length > 0) {
|
||||
const doorMesh = doorIntersects[0].object;
|
||||
// Only capture click if it is a door AND it is NOT open
|
||||
if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
|
||||
// Clicked on a CLOSED door! Call onClick with a special door object
|
||||
onClick(null, null, doorMesh);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no door clicked, proceed with normal cell click
|
||||
const intersects = this.raycaster.intersectObject(this.interactionPlane);
|
||||
|
||||
if (intersects.length > 0) {
|
||||
const p = intersects[0].point;
|
||||
const x = Math.round(p.x);
|
||||
const y = Math.round(-p.z);
|
||||
onClick(x, y, null);
|
||||
}
|
||||
});
|
||||
|
||||
this.renderer.domElement.addEventListener('contextmenu', (event) => {
|
||||
event.preventDefault();
|
||||
this.mouse.set(getMousePos(event).x, getMousePos(event).y);
|
||||
this.raycaster.setFromCamera(this.mouse, cameraGetter());
|
||||
const intersects = this.raycaster.intersectObject(this.interactionPlane);
|
||||
|
||||
if (intersects.length > 0) {
|
||||
const p = intersects[0].point;
|
||||
const x = Math.round(p.x);
|
||||
const y = Math.round(-p.z);
|
||||
onRightClick(x, y);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
highlightCells(cells) {
|
||||
this.highlightGroup.clear();
|
||||
if (!cells || cells.length === 0) return;
|
||||
|
||||
cells.forEach((cell, index) => {
|
||||
// 1. Create Canvas with Number
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = "rgba(255, 255, 0, 0.5)";
|
||||
ctx.fillRect(0, 0, 128, 128);
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = "rgba(255, 255, 0, 1)";
|
||||
ctx.lineWidth = 4;
|
||||
ctx.strokeRect(0, 0, 128, 128);
|
||||
|
||||
// Text (Step Number)
|
||||
ctx.font = "bold 60px Arial";
|
||||
ctx.fillStyle = "black";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText((index + 1).toString(), 64, 64);
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(0.9, 0.9);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.rotation.x = -Math.PI / 2;
|
||||
mesh.position.set(cell.x, 0.05, -cell.y);
|
||||
|
||||
this.highlightGroup.add(mesh);
|
||||
});
|
||||
}
|
||||
|
||||
showAreaPreview(cells, color = 0xffffff) {
|
||||
this.spellPreviewGroup.clear();
|
||||
if (!cells) return;
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(0.9, 0.9);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: color,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
cells.forEach(cell => {
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.rotation.x = -Math.PI / 2;
|
||||
mesh.position.set(cell.x, 0.06, -cell.y);
|
||||
this.spellPreviewGroup.add(mesh);
|
||||
});
|
||||
}
|
||||
|
||||
hideAreaPreview() {
|
||||
this.spellPreviewGroup.clear();
|
||||
}
|
||||
|
||||
// ========== PATH VISUALIZATION (PRESERVED) ==========
|
||||
updatePathVisualization(path) {
|
||||
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,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
const plane = new THREE.Mesh(geometry, material);
|
||||
plane.position.set(step.x, 0.02, -step.y);
|
||||
plane.rotation.x = -Math.PI / 2;
|
||||
|
||||
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);
|
||||
return tex;
|
||||
}
|
||||
|
||||
// Manual Placement
|
||||
showPlacementPreview(preview) {
|
||||
if (!preview) {
|
||||
this.previewGroup.clear();
|
||||
this.projectionGroup.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.previewGroup.clear();
|
||||
this.projectionGroup.clear();
|
||||
|
||||
const { card, cells, isValid, x, y, rotation } = preview;
|
||||
|
||||
// 1. FLOATING TILE (Y = 3)
|
||||
if (card.textures && card.textures.length > 0) {
|
||||
this.getTexture(card.textures[0], (texture) => {
|
||||
const currentVariant = card.variants[rotation];
|
||||
const rotWidth = currentVariant.width;
|
||||
const rotHeight = currentVariant.height;
|
||||
const cx = x + (rotWidth - 1) / 2;
|
||||
const cy = y + (rotHeight - 1) / 2;
|
||||
|
||||
const baseWidth = card.variants.N.width;
|
||||
const baseHeight = card.variants.N.height;
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(baseWidth, baseHeight);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
const floatingTile = new THREE.Mesh(geometry, material);
|
||||
floatingTile.rotation.x = -Math.PI / 2;
|
||||
|
||||
const rotMap = { 'N': 0, 'E': 1, 'S': 2, 'W': 3 };
|
||||
const r = rotMap[rotation] !== undefined ? rotMap[rotation] : 0;
|
||||
floatingTile.rotation.z = -r * (Math.PI / 2);
|
||||
|
||||
floatingTile.position.set(cx, 3, -cy);
|
||||
this.previewGroup.add(floatingTile);
|
||||
});
|
||||
}
|
||||
|
||||
// 2. GROUND PROJECTION
|
||||
const baseColor = isValid ? 0x00ff00 : 0xff0000;
|
||||
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 (exitKeys.has(key)) {
|
||||
color = 0x0000ff;
|
||||
}
|
||||
const geometry = new THREE.PlaneGeometry(0.95, 0.95);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: color,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
const projection = new THREE.Mesh(geometry, material);
|
||||
projection.rotation.x = -Math.PI / 2;
|
||||
projection.position.set(cell.x, 0.02, -cell.y);
|
||||
this.projectionGroup.add(projection);
|
||||
});
|
||||
}
|
||||
|
||||
hidePlacementPreview() {
|
||||
this.previewGroup.clear();
|
||||
this.projectionGroup.clear();
|
||||
}
|
||||
|
||||
showRangedTargeting(hero, monster, losResult) {
|
||||
this.rangedGroup.clear();
|
||||
if (!hero || !monster || !losResult) return;
|
||||
|
||||
// 1. Orange Fluorescence Ring on Monster
|
||||
const ringGeo = new THREE.RingGeometry(0.35, 0.45, 32);
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xFFA500,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 0.8
|
||||
});
|
||||
const ring = new THREE.Mesh(ringGeo, ringMat);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.set(monster.x, 0.05, -monster.y);
|
||||
this.rangedGroup.add(ring);
|
||||
|
||||
// 2. Dashed Line logic
|
||||
const points = [];
|
||||
points.push(new THREE.Vector3(hero.x, 0.8, -hero.y));
|
||||
points.push(new THREE.Vector3(monster.x, 0.8, -monster.y));
|
||||
|
||||
const lineGeo = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const lineMat = new THREE.LineDashedMaterial({
|
||||
color: losResult.clear ? 0x00FF00 : 0xFF0000,
|
||||
dashSize: 0.2,
|
||||
gapSize: 0.1,
|
||||
});
|
||||
|
||||
const line = new THREE.Line(lineGeo, lineMat);
|
||||
line.computeLineDistances();
|
||||
this.rangedGroup.add(line);
|
||||
|
||||
// 3. Blocker Visualization
|
||||
if (!losResult.clear && losResult.blocker) {
|
||||
const b = losResult.blocker;
|
||||
if (b.type === 'hero' || b.type === 'monster') {
|
||||
const blockRingGeo = new THREE.RingGeometry(0.4, 0.5, 32);
|
||||
const blockRingMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xFF0000,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 1.0,
|
||||
depthTest: false
|
||||
});
|
||||
const blockRing = new THREE.Mesh(blockRingGeo, blockRingMat);
|
||||
blockRing.rotation.x = -Math.PI / 2;
|
||||
const bx = b.entity ? b.entity.x : b.x;
|
||||
const by = b.entity ? b.entity.y : b.y;
|
||||
blockRing.position.set(bx, 0.1, -by);
|
||||
this.rangedGroup.add(blockRing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enableDoorSelection(enabled, exitGroup) {
|
||||
if (enabled) {
|
||||
this.exitHighlightGroup.clear();
|
||||
if (exitGroup) {
|
||||
exitGroup.children.forEach(doorMesh => {
|
||||
if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
|
||||
const ringGeom = new THREE.RingGeometry(1.2, 1.4, 32);
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x00ff00,
|
||||
side: THREE.DoubleSide,
|
||||
transparent: true,
|
||||
opacity: 0.6
|
||||
});
|
||||
const ring = new THREE.Mesh(ringGeom, ringMat);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.copy(doorMesh.position);
|
||||
ring.position.y = 0.05;
|
||||
|
||||
// Store reference to door for click handling
|
||||
doorMesh.userData.isExit = true;
|
||||
const firstCell = doorMesh.userData.cells[0];
|
||||
const dirMap = { 0: 'N', 1: 'E', 2: 'S', 3: 'W' };
|
||||
doorMesh.userData.exitData = {
|
||||
x: firstCell.x,
|
||||
y: firstCell.y,
|
||||
direction: dirMap[doorMesh.userData.direction] || 'N'
|
||||
};
|
||||
|
||||
this.exitHighlightGroup.add(ring);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.exitHighlightGroup.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/view/render/SceneManager.js
Normal file
79
src/view/render/SceneManager.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class SceneManager {
|
||||
constructor(containerId) {
|
||||
this.container = document.getElementById(containerId) || document.body;
|
||||
|
||||
// Fix: Use window dimensions if container has 0 height/width (Robustness legacy fix)
|
||||
this.width = this.container.clientWidth || window.innerWidth;
|
||||
this.height = this.container.clientHeight || window.innerHeight;
|
||||
|
||||
// Scene Setup
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x111111); // Dark dungeon bg
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(45, this.width / this.height, 0.1, 1000);
|
||||
|
||||
// Renderer
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight); // Original code used window dimensions
|
||||
this.renderer.shadowMap.enabled = true;
|
||||
|
||||
// Clear container to avoid duplicates
|
||||
this.container.innerHTML = '';
|
||||
this.container.appendChild(this.renderer.domElement);
|
||||
|
||||
// Debug Properties
|
||||
this.scene.add(new THREE.AxesHelper(10)); // Red=X, Green=Y, Blue=Z
|
||||
|
||||
// Grid Helper
|
||||
const gridHelper = new THREE.GridHelper(100, 100, 0x444444, 0x222222);
|
||||
this.scene.add(gridHelper);
|
||||
|
||||
// Interaction Plane
|
||||
this.interactionPlane = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(1000, 1000),
|
||||
new THREE.MeshBasicMaterial({ visible: false })
|
||||
);
|
||||
this.interactionPlane.rotation.x = -Math.PI / 2;
|
||||
this.scene.add(this.interactionPlane);
|
||||
|
||||
// Lights
|
||||
this.setupLights();
|
||||
|
||||
// Resize Handler
|
||||
window.addEventListener('resize', this.onWindowResize.bind(this));
|
||||
}
|
||||
|
||||
setupLights() {
|
||||
// Ambient Light
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
|
||||
this.scene.add(ambientLight);
|
||||
|
||||
// Directional Light
|
||||
const dirLight = new THREE.DirectionalLight(0xffffff, 0.7);
|
||||
dirLight.position.set(50, 100, 50);
|
||||
dirLight.castShadow = true;
|
||||
this.scene.add(dirLight);
|
||||
}
|
||||
|
||||
onWindowResize() {
|
||||
this.width = this.container.clientWidth || window.innerWidth;
|
||||
this.height = this.container.clientHeight || window.innerHeight;
|
||||
|
||||
if (this.camera) {
|
||||
this.camera.aspect = this.width / this.height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
}
|
||||
if (this.renderer) {
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
}
|
||||
}
|
||||
|
||||
render(camera) {
|
||||
const cam = camera || this.camera;
|
||||
if (this.renderer && this.scene && cam) {
|
||||
this.renderer.render(this.scene, cam);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,51 @@ export class UnitCardManager {
|
||||
card.appendChild(bowBtn);
|
||||
}
|
||||
|
||||
// Break Away Button (Destrabarse)
|
||||
const isPinned = this.game.isEntityPinned(hero) && !hero.hasEscapedPin;
|
||||
const canTryBreak = isPinned && hero.currentMoves > 0 && !hero.hasAttacked; // Can only try if hasn't acted yet?
|
||||
// Rules say: "can attempt to escape... if achieved... moves as normal".
|
||||
// If fails "must stay and fight".
|
||||
|
||||
if (isPinned) {
|
||||
const breakBtn = document.createElement('button');
|
||||
const target = hero.stats.pin_target || 6;
|
||||
breakBtn.textContent = `🏃 DESTRABARSE (${target}+)`;
|
||||
Object.assign(breakBtn.style, {
|
||||
width: '100%', padding: '8px', marginTop: '8px',
|
||||
color: '#fff', border: '1px solid #FFA500', borderRadius: '4px',
|
||||
fontFamily: '"Cinzel", serif',
|
||||
cursor: canTryBreak ? 'pointer' : 'not-allowed',
|
||||
backgroundColor: canTryBreak ? '#FF8C00' : '#555'
|
||||
});
|
||||
|
||||
if (!canTryBreak) {
|
||||
if (hero.hasAttacked) breakBtn.title = "Ya has atacado.";
|
||||
else if (hero.currentMoves <= 0) breakBtn.title = "No tienes movimiento.";
|
||||
} else {
|
||||
breakBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const result = this.game.attemptBreakAway(hero);
|
||||
|
||||
// Show result
|
||||
const color = result.success ? '#00ff00' : '#ff0000';
|
||||
const msg = result.success ? "¡Escapada con éxito!" : "¡Fallo! Debes luchar.";
|
||||
|
||||
if (this.callbacks.showModal) {
|
||||
this.callbacks.showModal(
|
||||
result.success ? '¡Destrabado!' : '¡Atrapado!',
|
||||
`Resultado del dado: <b style="color:${color}">${result.roll}</b> (Necesitabas ${result.target}+)<br>${msg}`
|
||||
);
|
||||
}
|
||||
|
||||
// Update UI (Refresh card to show movement unlocked or locked)
|
||||
this.updateHeroCard(hero.id);
|
||||
if (this.callbacks.refresh) this.callbacks.refresh(); // Or just let update handle it
|
||||
};
|
||||
}
|
||||
card.appendChild(breakBtn);
|
||||
}
|
||||
|
||||
// Inventory
|
||||
const invBtn = document.createElement('button');
|
||||
invBtn.textContent = '🎒 INVENTARIO';
|
||||
|
||||
Reference in New Issue
Block a user