1511 lines
54 KiB
JavaScript
1511 lines
54 KiB
JavaScript
import * as THREE from 'three';
|
|
import { DIRECTIONS } from '../engine/dungeon/Constants.js';
|
|
import { ParticleManager } from './ParticleManager.js';
|
|
|
|
export class GameRenderer {
|
|
constructor(containerId) {
|
|
this.container = document.getElementById(containerId) || document.body;
|
|
this.width = this.container.clientWidth;
|
|
this.height = this.container.clientHeight;
|
|
|
|
// Scene Setup
|
|
this.scene = new THREE.Scene();
|
|
this.scene.background = new THREE.Color(0x111111); // Dark dungeon bg
|
|
|
|
this.particleManager = new ParticleManager(this.scene); // Init Particles
|
|
|
|
this.camera = new THREE.PerspectiveCamera(45, this.width / this.height, 0.1, 1000);
|
|
// 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.rangedGroup = new THREE.Group();
|
|
this.scene.add(this.rangedGroup);
|
|
|
|
this.tokensGroup = new THREE.Group();
|
|
this.scene.add(this.tokensGroup);
|
|
|
|
this.spellPreviewGroup = new THREE.Group();
|
|
this.scene.add(this.spellPreviewGroup);
|
|
|
|
this.tokens = new Map();
|
|
|
|
this.entities = new Map();
|
|
}
|
|
|
|
setupLights() {
|
|
// Ambient Light (Base visibility)
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
|
|
this.scene.add(ambientLight);
|
|
|
|
// Group for floating texts
|
|
this.floatingTextGroup = new THREE.Group();
|
|
this.scene.add(this.floatingTextGroup);
|
|
|
|
// 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, onHover = 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 (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);
|
|
});
|
|
}
|
|
|
|
showAreaPreview(cells, color = 0xffffff) {
|
|
this.spellPreviewGroup.clear(); // Ensure cleared first
|
|
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); // Slightly above floor/highlights
|
|
this.spellPreviewGroup.add(mesh);
|
|
});
|
|
}
|
|
|
|
hideAreaPreview() {
|
|
this.spellPreviewGroup.clear();
|
|
}
|
|
|
|
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. Flash Effect (White Flash)
|
|
mesh.traverse((child) => {
|
|
if (child.material && child.material.map) { // Texture mesh
|
|
// Store original color if not stored
|
|
if (!child.userData.originalColor) {
|
|
child.userData.originalColor = child.material.color.clone();
|
|
}
|
|
// Set to red/white flash
|
|
child.material.color.setHex(0xff0000);
|
|
setTimeout(() => {
|
|
if (child.material) child.material.color.copy(child.userData.originalColor);
|
|
}, 150);
|
|
}
|
|
});
|
|
|
|
// 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
|
|
};
|
|
}
|
|
|
|
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) {
|
|
// Map Grid Y to World -Z
|
|
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; // Rectangular
|
|
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);
|
|
|
|
// Position slightly above head (standard height ~1.5)
|
|
sprite.position.set(x, 2.0, -y);
|
|
// Small initial random offset for stacking readability
|
|
sprite.position.x += (Math.random() - 0.5) * 0.2;
|
|
|
|
// Scale down to world units
|
|
sprite.scale.set(2, 1, 1);
|
|
|
|
sprite.userData = {
|
|
startTime: performance.now(),
|
|
duration: 2000, // 2 seconds life
|
|
startY: sprite.position.y
|
|
};
|
|
|
|
this.floatingTextGroup.add(sprite);
|
|
}
|
|
|
|
showCombatFeedback(x, y, damage, isHit, defenseText = 'Block') {
|
|
const entityKey = `${x},${y}`; // Approximate lookup if needed, but we pass coords.
|
|
// Actually to trigger shake we need entity ID.
|
|
// We can find entity at X,Y?
|
|
let entityId = null;
|
|
for (const [id, mesh] of this.entities.entries()) {
|
|
// Check approximate position
|
|
if (Math.abs(mesh.position.x - x) < 0.1 && Math.abs(mesh.position.z - (-y)) < 0.1) {
|
|
entityId = id;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (isHit) {
|
|
if (damage > 0) {
|
|
// HIT and DAMAGE
|
|
this.showFloatingText(x, y, `💥 -${damage}`, '#ff0000');
|
|
if (entityId) this.triggerDamageEffect(entityId);
|
|
} else {
|
|
// HIT but NO DAMAGE (Blocked)
|
|
this.showFloatingText(x, y, `🛡️ ${defenseText}`, '#ffff00');
|
|
}
|
|
} else {
|
|
// MISS
|
|
this.showFloatingText(x, y, `💨 Miss`, '#aaaaaa');
|
|
}
|
|
}
|
|
triggerDeathAnimation(entityId) {
|
|
const mesh = this.entities.get(entityId);
|
|
if (!mesh) return;
|
|
|
|
console.log(`[GameRenderer] Triggering death animation for ${entityId}`);
|
|
|
|
// Start fade-out animation
|
|
const startTime = performance.now();
|
|
const duration = 1500; // 1.5 seconds fade out
|
|
|
|
mesh.userData.death = {
|
|
startTime: startTime,
|
|
duration: duration,
|
|
initialOpacity: 1.0
|
|
};
|
|
|
|
// Remove entity from map after animation completes
|
|
setTimeout(() => {
|
|
if (mesh && mesh.parent) {
|
|
mesh.parent.remove(mesh);
|
|
}
|
|
this.entities.delete(entityId);
|
|
console.log(`[GameRenderer] Removed entity ${entityId} from scene`);
|
|
}, duration);
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
// Sync Token
|
|
if (this.tokens) {
|
|
const token = this.tokens.get(entity.id);
|
|
if (token) {
|
|
token.position.set(entity.x, 0.05, -entity.y);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
updateAnimations(time) {
|
|
// Calculate Delta (Approx)
|
|
if (!this.lastTime) this.lastTime = time;
|
|
const delta = (time - this.lastTime) / 1000;
|
|
this.lastTime = time;
|
|
|
|
if (this.particleManager) {
|
|
this.particleManager.update(delta);
|
|
}
|
|
|
|
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();
|
|
// Target: x, y (grid) -> x, z (world)
|
|
data.targetPos = new THREE.Vector3(nextStep.x, mesh.position.y, -nextStep.y);
|
|
}
|
|
|
|
// Check if this entity is contributing to movement sound
|
|
if (data.isMoving || data.pathQueue.length > 0) {
|
|
isAnyMoving = true;
|
|
}
|
|
|
|
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);
|
|
|
|
// Sync Token
|
|
if (this.tokens) {
|
|
const token = this.tokens.get(id);
|
|
if (token) {
|
|
token.position.x = mesh.position.x;
|
|
token.position.z = mesh.position.z;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
} else if (data.death) {
|
|
// HANDLE DEATH FADE-OUT
|
|
const elapsed = time - data.death.startTime;
|
|
const progress = Math.min(elapsed / data.death.duration, 1);
|
|
|
|
// Fade out opacity
|
|
const opacity = data.death.initialOpacity * (1 - progress);
|
|
|
|
// Apply opacity to all materials in the mesh
|
|
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;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Also fade down (sink into ground)
|
|
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;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// IF Finished Sequence (Queue empty)
|
|
if (data.pathQueue.length === 0 && !data.isMoving) { // Ensure strict finished state
|
|
// Check if it's the player (id 'p1') -- NOTE: ID might be hero_barbarian etc.
|
|
// Using generic callback
|
|
if (id === 'p1' && this.onHeroFinishedMove) { // Legacy check?
|
|
// Grid Coords from World Coords (X, -Z)
|
|
this.onHeroFinishedMove(mesh.position.x, -mesh.position.z);
|
|
}
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle Footsteps Audio Globally
|
|
if (window.SOUND_MANAGER) {
|
|
if (isAnyMoving) {
|
|
window.SOUND_MANAGER.startLoop('footsteps');
|
|
} else {
|
|
window.SOUND_MANAGER.stopLoop('footsteps');
|
|
}
|
|
}
|
|
}
|
|
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);
|
|
|
|
plane.position.set(cx, 0.01, -cy);
|
|
plane.receiveShadow = true;
|
|
|
|
// Store Metadata for FOW
|
|
plane.userData.tileId = tileInstance.id;
|
|
plane.userData.cells = cells;
|
|
|
|
if (!this.dungeonGroup) {
|
|
this.dungeonGroup = new THREE.Group();
|
|
this.scene.add(this.dungeonGroup);
|
|
}
|
|
this.dungeonGroup.add(plane);
|
|
|
|
});
|
|
} else {
|
|
console.warn(`[GameRenderer] details missing for texture render.def: ${!!tileDef}, inst: ${!!tileInstance} `);
|
|
}
|
|
}
|
|
|
|
updateFogOfWar(visibleTileIds) {
|
|
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 (this.entities) {
|
|
this.entities.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
|
|
|
|
// Check distance to tile center?
|
|
// Tile has cx, cy.
|
|
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
|
|
|
|
// Tile size?
|
|
// We don't know exact bounds here, but we can guess.
|
|
// If distance < 4 roughly?
|
|
if (dx < 4 && dy < 4) {
|
|
door.visible = true;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
setEntityTarget(entityId, isTarget) {
|
|
const mesh = this.entities.get(entityId);
|
|
if (!mesh) return;
|
|
|
|
// Remove existing target ring
|
|
const oldRing = mesh.getObjectByName("TargetRing");
|
|
if (oldRing) mesh.remove(oldRing);
|
|
|
|
if (isTarget) {
|
|
// Blue Ring logic
|
|
const ringGeom = new THREE.RingGeometry(0.3, 0.4, 32);
|
|
const ringMat = new THREE.MeshBasicMaterial({
|
|
color: 0x00AADD, // Light Blue
|
|
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.06; // Slightly above floor/selection
|
|
|
|
ring.name = "TargetRing";
|
|
mesh.add(ring);
|
|
}
|
|
}
|
|
|
|
clearAllActiveRings() {
|
|
if (!this.entities) return;
|
|
this.entities.forEach(mesh => {
|
|
const ring = mesh.getObjectByName("ActiveRing"); // Green ring
|
|
if (ring) mesh.remove(ring);
|
|
const ring2 = mesh.getObjectByName("SelectionRing"); // Yellow ring
|
|
if (ring2) ring2.visible = false;
|
|
|
|
// Also clear TargetRing if any
|
|
const ring3 = mesh.getObjectByName("TargetRing");
|
|
if (ring3) mesh.remove(ring3);
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
clearRangedTargeting() {
|
|
if (this.rangedGroup) {
|
|
while (this.rangedGroup.children.length > 0) {
|
|
const child = this.rangedGroup.children[0];
|
|
this.rangedGroup.remove(child);
|
|
if (child.geometry) child.geometry.dispose();
|
|
if (child.material) {
|
|
if (Array.isArray(child.material)) child.material.forEach(m => m.dispose());
|
|
else child.material.dispose();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
showRangedTargeting(hero, monster, losResult) {
|
|
this.clearRangedTargeting();
|
|
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 (Center to Center at approx waist height)
|
|
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 (Red Ring)
|
|
if (!losResult.clear && losResult.blocker) {
|
|
const b = losResult.blocker;
|
|
// If blocker is Entity (Hero/Monster), show bright red ring
|
|
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 // Always visible on top
|
|
});
|
|
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);
|
|
}
|
|
// Walls are implicit (Line just turns red and stops/passes through)
|
|
}
|
|
}
|
|
showTokens(heroes, monsters) {
|
|
this.hideTokens(); // Clear existing (makes invisible)
|
|
if (this.tokensGroup) this.tokensGroup.visible = true; // Now force visible
|
|
|
|
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;
|
|
|
|
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 = '';
|
|
// Ensure filename is safe (though keys usually are)
|
|
const filename = subType;
|
|
|
|
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); // Reset to white to show texture
|
|
token.material.needsUpdate = true;
|
|
}, undefined, (err) => {
|
|
console.warn(`[GameRenderer] 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();
|
|
}
|
|
}
|