Refactor V2: GameRenderer Modularization (Quirurgical Approach) - Success
This commit is contained in:
30
DEVLOG.md
30
DEVLOG.md
@@ -1,5 +1,35 @@
|
||||
# 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
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user