Files
WarhammerQuest/src/view/GameRenderer.js
Marti Vich 3efbf8d5fb Implement advanced pathfinding and combat visual effects
- Add monster turn visual feedback (green ring on attacker, red ring on victim)
- Implement proper attack sequence with timing and animations
- Add room boundary and height level pathfinding system
- Monsters now respect room walls and can only pass through doors
- Add height level support (1-8) with stairs (9) for level transitions
- Fix attack validation to prevent attacks through walls
- Speed up hero movement animation (300ms per tile)
- Fix exploration phase message to not show on initial tile placement
- Disable hero movement during exploration phase (doors only)
2026-01-06 16:18:46 +01:00

1001 lines
35 KiB
JavaScript

import * as THREE from 'three';
export class GameRenderer {
constructor(containerId) {
this.container = document.getElementById(containerId) || document.body;
// 1. Scene
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x1a1a1a);
// 2. Renderer
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.shadowMap.enabled = true;
this.container.appendChild(this.renderer.domElement);
// 3. Default Lights
this.setupLights();
// Debug Properties
this.scene.add(new THREE.AxesHelper(10)); // Red=X, Green=Y, Blue=Z
// Grid Helper: Size 100, Divisions 100 (1 unit per cell)
const gridHelper = new THREE.GridHelper(100, 100, 0x444444, 0x222222);
this.scene.add(gridHelper);
// 4. Resize Handler
window.addEventListener('resize', this.onWindowResize.bind(this));
// 5. Textures
this.textureLoader = new THREE.TextureLoader();
this.textureCache = new Map();
// 6. Interaction
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
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);
this.selectionMesh = null;
this.highlightGroup = new THREE.Group();
this.scene.add(this.highlightGroup);
this.entities = new Map();
}
setupLights() {
// Ambient Light (Base visibility)
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
this.scene.add(ambientLight);
// Directional Light (Sun/Moon - creates shadows)
const dirLight = new THREE.DirectionalLight(0xffffff, 0.7);
dirLight.position.set(50, 100, 50);
dirLight.castShadow = true;
this.scene.add(dirLight);
}
setupInteraction(cameraGetter, onClick, onRightClick) {
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
};
};
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 (this.exitGroup) {
const doorIntersects = this.raycaster.intersectObjects(this.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);
});
}
addEntity(entity) {
if (this.entities.has(entity.id)) return;
// Standee: Larger Size (+30%)
// Old: 0.8 x 1.2 -> New: 1.04 x 1.56
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);
// Store target position for animation logic
mesh.userData = {
pathQueue: [],
isMoving: false,
startPos: null,
targetPos: null,
startTime: 0
};
mesh.position.set(entity.x, h / 2, -entity.y);
// Clear old children if re-adding (to prevent multiple rings)
for (let i = mesh.children.length - 1; i >= 0; i--) {
const child = mesh.children[i];
if (child.name === "SelectionRing") {
mesh.remove(child);
}
}
// Selection Circle
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
const ringMat = new THREE.MeshBasicMaterial({ color: 0xffff00, side: THREE.DoubleSide, transparent: true, opacity: 0.35 });
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);
});
}
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;
// Remove existing active ring if any
const oldRing = mesh.getObjectByName("ActiveRing");
if (oldRing) mesh.remove(oldRing);
if (isActive) {
// Phosphorescent Green Ring - MATCHING SIZE (0.3 - 0.4)
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
// Basic Material does not support emissive. Use color + opacity for "glow" feel.
const ringMat = new THREE.MeshBasicMaterial({
color: 0x00ff00, // Green
side: THREE.DoubleSide,
transparent: true,
opacity: 0.8
});
const ring = new THREE.Mesh(ringGeom, ringMat);
ring.rotation.x = -Math.PI / 2;
// Align with floor (relative to mesh center)
const h = 1.56;
ring.position.y = -h / 2 + 0.05;
ring.name = "ActiveRing";
mesh.add(ring);
}
}
triggerDamageEffect(entityId) {
const mesh = this.entities.get(entityId);
if (!mesh) return;
// 1. Red Halo (Temporary) - MATCHING ATTACKER SIZE (0.3 - 0.4)
const hitRingGeom = new THREE.RingGeometry(0.3, 0.4, 32);
const hitRingMat = new THREE.MeshBasicMaterial({
color: 0xff0000,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.9
});
const hitRing = new THREE.Mesh(hitRingGeom, hitRingMat);
hitRing.rotation.x = -Math.PI / 2;
// Align with floor
const h = 1.56;
hitRing.position.y = -h / 2 + 0.05;
hitRing.name = "HitRing";
mesh.add(hitRing);
// Remove Red Halo after 1200ms (matching the timing in MonsterAI)
setTimeout(() => {
if (mesh && hitRing) mesh.remove(hitRing);
}, 1200);
// 2. Shake Animation (800ms)
const originalPos = mesh.position.clone();
const startTime = performance.now();
const duration = 800; // ms
mesh.userData.shake = {
startTime: startTime,
duration: duration,
magnitude: 0.1,
originalPos: originalPos
};
}
moveEntityAlongPath(entity, path) {
const mesh = this.entities.get(entity.id);
if (mesh) {
mesh.userData.pathQueue = [...path];
this.highlightGroup.clear();
}
}
updateEntityPosition(entity) {
const mesh = this.entities.get(entity.id);
if (mesh) {
// Prevent snapping if animation is active
if (mesh.userData.isMoving || mesh.userData.pathQueue.length > 0) return;
mesh.position.set(entity.x, 1.56 / 2, -entity.y);
}
}
updateAnimations(time) {
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();
// Target: x, y (grid) -> x, z (world)
data.targetPos = new THREE.Vector3(nextStep.x, mesh.position.y, -nextStep.y);
}
if (data.isMoving) {
const duration = 300; // Hero movement speed (300ms per tile)
const elapsed = time - data.startTime;
const progress = Math.min(elapsed / duration, 1);
// Lerp X/Z
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);
// Hop (Botecito)
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; // Reset height
// 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);
}
}
}
}
} else if (data.shake) {
// HANDLE SHAKE
const elapsed = time - data.shake.startTime;
if (elapsed < data.shake.duration) {
const progress = elapsed / data.shake.duration;
// Dampen over time
const mag = data.shake.magnitude * (1 - progress);
// Random jitter
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 {
// Reset
mesh.position.copy(data.shake.originalPos);
delete data.shake;
}
}
// IF Finished Sequence (Queue empty)
if (data.pathQueue.length === 0) {
// Check if it's the player (id 'p1')
if (id === 'p1' && this.onHeroFinishedMove) {
// Grid Coords from World Coords (X, -Z)
this.onHeroFinishedMove(mesh.position.x, -mesh.position.z);
}
}
});
}
renderExits(exits) {
// Cancel any pending render
if (this._pendingExitRender) {
this._pendingExitRender = false;
}
// Create exitGroup if it doesn't exist
if (!this.exitGroup) {
this.exitGroup = new THREE.Group();
this.scene.add(this.exitGroup);
}
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;
// Store door data for interaction (new doors always start closed)
// Convert numeric direction to string for generator compatibility
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);
});
});
}
onWindowResize() {
if (this.camera) {
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
}
render(camera) {
if (camera) {
this.renderer.render(this.scene, camera);
}
}
// Optimized getTexture with pending request queue
getTexture(path, onLoad) {
// 1. Check Cache
if (this.textureCache.has(path)) {
const tex = this.textureCache.get(path);
if (onLoad) onLoad(tex);
return;
}
// 2. Check Pending Requests (Deduplication)
if (!this._pendingTextureRequests) this._pendingTextureRequests = new Map();
if (this._pendingTextureRequests.has(path)) {
this._pendingTextureRequests.get(path).push(onLoad);
return;
}
// 3. Start Load
this._pendingTextureRequests.set(path, [onLoad]);
this.textureLoader.load(
path,
(texture) => {
// Success
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestFilter;
texture.colorSpace = THREE.SRGBColorSpace;
this.textureCache.set(path, texture);
// Execute all waiting callbacks
const callbacks = this._pendingTextureRequests.get(path);
if (callbacks) {
callbacks.forEach(cb => { if (cb) cb(texture); });
this._pendingTextureRequests.delete(path);
}
},
undefined, // onProgress
(err) => {
console.error(`[GameRenderer] Failed to load texture: ${path}`, err);
const callbacks = this._pendingTextureRequests.get(path);
if (callbacks) {
this._pendingTextureRequests.delete(path);
}
}
);
}
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
// (The shape the grid sees: e.g. 4x2 for East)
const currentVariant = tileDef.variants[tileInstance.rotation];
if (!currentVariant) {
console.error(`[GameRenderer] 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
// Formula: anchor + (dimension - 1) / 2
// (Subtract 1 because width 1 is just offset 0)
const cx = tileInstance.x + (rotWidth - 1) / 2;
const cy = tileInstance.y + (rotHeight - 1) / 2;
// 3. Use BASE dimensions from NORTH variant for the Plane
// (Since we are rotating the plane itself, we start with the un-rotated image size)
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 is Up in this local frame before X-rotation? No, after X-rot)
// Actually, standard hierarchy: Rotate Z first?
// ThreeJS rotation order XYZ.
// We want to rotate around the Y axis of the world (which is Z of the plane before x-rotation?)
// Simplest: Rotate Z of the plane, which corresponds to world Y.
// Note: We use negative rotation because ThreeJS is CCW, but our grid might be different,
// but usually -r * PI/2 works for this setup.
plane.rotation.z = -r * (Math.PI / 2);
// Position at the calculated center
// Notice: World Z is -Grid Y
plane.position.set(cx, 0.01, -cy);
plane.receiveShadow = true;
this.scene.add(plane);
});
} else {
console.warn(`[GameRenderer] details missing for texture render. def: ${!!tileDef}, inst: ${!!tileInstance}`);
}
}
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;
});
}
getDoorAtPosition(x, y) {
if (!this.exitGroup) return null;
// Check all doors in exitGroup
for (const child of this.exitGroup.children) {
if (child.userData.isDoor) {
// Check if any of the door's cells match the position
for (const cell of child.userData.cells) {
if (cell.x === x && cell.y === y) {
return child;
}
}
}
}
return null;
}
// ========== PATH VISUALIZATION ==========
updatePathVisualization(path) {
if (!this.pathGroup) {
this.pathGroup = new THREE.Group();
this.scene.add(this.pathGroup);
}
this.pathGroup.clear();
if (!path || path.length === 0) return;
path.forEach((step, index) => {
const geometry = new THREE.PlaneGeometry(0.8, 0.8);
const texture = this.createNumberTexture(index + 1);
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: 0.8, // Texture itself has opacity
side: THREE.DoubleSide
});
const plane = new THREE.Mesh(geometry, material);
plane.position.set(step.x, 0.02, -step.y); // Slightly above floor
plane.rotation.x = -Math.PI / 2;
// Store step index to identify it later if needed
plane.userData.stepIndex = index;
this.pathGroup.add(plane);
});
}
createNumberTexture(number) {
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext('2d');
// Yellow background with 50% opacity
ctx.fillStyle = 'rgba(255, 255, 0, 0.5)';
ctx.fillRect(0, 0, 64, 64);
// Border
ctx.strokeStyle = '#EDA900';
ctx.lineWidth = 4;
ctx.strokeRect(0, 0, 64, 64);
// Text
ctx.font = 'bold 36px Arial';
ctx.fillStyle = 'black';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(number.toString(), 32, 32);
const tex = new THREE.CanvasTexture(canvas);
// tex.magFilter = THREE.NearestFilter; // Optional, might look pixelated
return tex;
}
isPlayerAdjacentToDoor(playerX, playerY, doorMesh) {
if (!doorMesh || !doorMesh.userData.isDoor) return false;
// Check if player is adjacent to any of the door's cells
for (const cell of doorMesh.userData.cells) {
const dx = Math.abs(playerX - cell.x);
const dy = Math.abs(playerY - cell.y);
// Adjacent means distance of 1 in one direction and 0 in the other
if ((dx === 1 && dy === 0) || (dx === 0 && dy === 1)) {
return true;
}
}
return false;
}
blockDoor(exitData) {
if (!this.exitGroup || !exitData) return;
// Find the door mesh
let targetDoor = null;
for (const child of this.exitGroup.children) {
if (child.userData.isDoor) {
// Check if this door corresponds to the exitData
// exitData has x,y of one of the cells
for (const cell of child.userData.cells) {
if (cell.x === exitData.x && cell.y === exitData.y) {
targetDoor = child;
break;
}
}
}
if (targetDoor) break;
}
if (targetDoor) {
this.getTexture('/assets/images/dungeon1/doors/door1_blocked.png', (texture) => {
targetDoor.material.map = texture;
targetDoor.material.needsUpdate = true;
targetDoor.userData.isBlocked = true;
targetDoor.userData.isOpen = false; // Ensure strictly not open
});
}
}
// ========== MANUAL PLACEMENT SYSTEM ==========
enableDoorSelection(enabled) {
this.doorSelectionEnabled = enabled;
if (enabled) {
// Highlight available exits
this.highlightAvailableExits();
} else {
// Remove highlights
if (this.exitHighlightGroup) {
this.exitHighlightGroup.clear();
}
}
}
highlightAvailableExits() {
if (!this.exitHighlightGroup) {
this.exitHighlightGroup = new THREE.Group();
this.scene.add(this.exitHighlightGroup);
}
this.exitHighlightGroup.clear();
// Highlight each exit door with a pulsing glow
if (this.exitGroup) {
this.exitGroup.children.forEach(doorMesh => {
if (doorMesh.userData.isDoor && !doorMesh.userData.isOpen) {
// Create highlight ring
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;
// Create proper exit data with all required fields
const firstCell = doorMesh.userData.cells[0];
// Convert numeric direction (0,1,2,3) to string ('N','E','S','W')
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);
}
});
}
}
showPlacementPreview(preview) {
if (!preview) {
this.hidePlacementPreview();
return;
}
// Create preview groups if they don't exist
if (!this.previewGroup) {
this.previewGroup = new THREE.Group();
this.scene.add(this.previewGroup);
}
if (!this.projectionGroup) {
this.projectionGroup = new THREE.Group();
this.scene.add(this.projectionGroup);
}
// Clear previous preview
this.previewGroup.clear();
this.projectionGroup.clear();
const { card, cells, isValid, x, y, rotation } = preview;
// Calculate bounds for tile - OLD LOGIC (Removed)
// Note: We ignore 'cells' for positioning the texture, but keep them for the Ground Projection (Green/Red squares)
// 1. FLOATING TILE (Y = 3)
if (card.textures && card.textures.length > 0) {
this.getTexture(card.textures[0], (texture) => {
// Get Current Rotation Variant for Dimensions
const currentVariant = card.variants[rotation];
const rotWidth = currentVariant.width;
const rotHeight = currentVariant.height;
// Calculate Center based on Anchor (x, y) and Dimensions
const cx = x + (rotWidth - 1) / 2;
const cy = y + (rotHeight - 1) / 2;
// Use BASE dimensions from NORTH variant
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;
// Apply Z rotation
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 (Green/Red/Blue)
const baseColor = isValid ? 0x00ff00 : 0xff0000;
// Calculate global exit positions
const exitKeys = new Set();
if (preview.variant && preview.variant.exits) {
preview.variant.exits.forEach(ex => {
const gx = x + ex.x;
const gy = y + ex.y;
exitKeys.add(`${gx},${gy}`);
});
}
cells.forEach(cell => {
const key = `${cell.x},${cell.y}`;
let color = baseColor;
// If this cell is an exit, color it Blue
if (exitKeys.has(key)) {
color = 0x0000ff; // Blue
}
const geometry = new THREE.PlaneGeometry(0.95, 0.95);
const material = new THREE.MeshBasicMaterial({
color: 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() {
if (this.previewGroup) {
this.previewGroup.clear();
}
if (this.projectionGroup) {
this.projectionGroup.clear();
}
}
}