feat: magic system visuals, audio sfx, and ui polish
This commit is contained in:
@@ -1,13 +1,20 @@
|
||||
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;
|
||||
|
||||
// 1. Scene
|
||||
// Scene Setup
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x1a1a1a);
|
||||
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);
|
||||
@@ -49,6 +56,10 @@ export class GameRenderer {
|
||||
|
||||
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();
|
||||
@@ -70,7 +81,7 @@ export class GameRenderer {
|
||||
this.scene.add(dirLight);
|
||||
}
|
||||
|
||||
setupInteraction(cameraGetter, onClick, onRightClick) {
|
||||
setupInteraction(cameraGetter, onClick, onRightClick, onHover = null) {
|
||||
const getMousePos = (event) => {
|
||||
const rect = this.renderer.domElement.getBoundingClientRect();
|
||||
return {
|
||||
@@ -79,6 +90,21 @@ export class GameRenderer {
|
||||
};
|
||||
};
|
||||
|
||||
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());
|
||||
@@ -167,6 +193,30 @@ export class GameRenderer {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -292,6 +342,25 @@ export class GameRenderer {
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -329,6 +398,33 @@ export class GameRenderer {
|
||||
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;
|
||||
@@ -355,6 +451,7 @@ export class GameRenderer {
|
||||
}, duration);
|
||||
}
|
||||
|
||||
|
||||
moveEntityAlongPath(entity, path) {
|
||||
const mesh = this.entities.get(entity.id);
|
||||
if (mesh) {
|
||||
@@ -381,6 +478,15 @@ export class GameRenderer {
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -553,13 +659,13 @@ export class GameRenderer {
|
||||
this.exitGroup.children.forEach(child => {
|
||||
if (child.userData.isDoor) {
|
||||
child.userData.cells.forEach(cell => {
|
||||
existingDoorCells.add(`${cell.x},${cell.y}`);
|
||||
existingDoorCells.add(`${cell.x},${cell.y} `);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out exits that already have doors
|
||||
const newExits = exits.filter(ex => !existingDoorCells.has(`${ex.x},${ex.y}`));
|
||||
const newExits = exits.filter(ex => !existingDoorCells.has(`${ex.x},${ex.y} `));
|
||||
|
||||
if (newExits.length === 0) {
|
||||
|
||||
@@ -598,7 +704,7 @@ export class GameRenderer {
|
||||
};
|
||||
|
||||
newExits.forEach((ex, i) => {
|
||||
const key = `${ex.x},${ex.y}`;
|
||||
const key = `${ex.x},${ex.y} `;
|
||||
const exDir = normalizeDir(ex.direction);
|
||||
|
||||
if (processed.has(key)) {
|
||||
@@ -608,7 +714,7 @@ export class GameRenderer {
|
||||
let partner = null;
|
||||
for (let j = i + 1; j < newExits.length; j++) {
|
||||
const other = newExits[j];
|
||||
const otherKey = `${other.x},${other.y}`;
|
||||
const otherKey = `${other.x},${other.y} `;
|
||||
const otherDir = normalizeDir(other.direction);
|
||||
|
||||
if (processed.has(otherKey)) continue;
|
||||
@@ -635,7 +741,7 @@ export class GameRenderer {
|
||||
if (partner) {
|
||||
doors.push([ex, partner]);
|
||||
processed.add(key);
|
||||
processed.add(`${partner.x},${partner.y}`);
|
||||
processed.add(`${partner.x},${partner.y} `);
|
||||
} else {
|
||||
doors.push([ex]);
|
||||
processed.add(key);
|
||||
@@ -691,7 +797,7 @@ export class GameRenderer {
|
||||
direction: dirMap[dir] || 'N'
|
||||
}
|
||||
};
|
||||
mesh.name = `door_${idx}`;
|
||||
mesh.name = `door_${idx} `;
|
||||
|
||||
this.exitGroup.add(mesh);
|
||||
});
|
||||
@@ -749,7 +855,7 @@ export class GameRenderer {
|
||||
},
|
||||
undefined, // onProgress
|
||||
(err) => {
|
||||
console.error(`[GameRenderer] Failed to load texture: ${path}`, err);
|
||||
console.error(`[GameRenderer] Failed to load texture: ${path} `, err);
|
||||
const callbacks = this._pendingTextureRequests.get(path);
|
||||
if (callbacks) {
|
||||
this._pendingTextureRequests.delete(path);
|
||||
@@ -837,7 +943,7 @@ export class GameRenderer {
|
||||
|
||||
});
|
||||
} else {
|
||||
console.warn(`[GameRenderer] details missing for texture render. def: ${!!tileDef}, inst: ${!!tileInstance}`);
|
||||
console.warn(`[GameRenderer] details missing for texture render.def: ${!!tileDef}, inst: ${!!tileInstance} `);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1111,12 +1217,12 @@ export class GameRenderer {
|
||||
preview.variant.exits.forEach(ex => {
|
||||
const gx = x + ex.x;
|
||||
const gy = y + ex.y;
|
||||
exitKeys.add(`${gx},${gy}`);
|
||||
exitKeys.add(`${gx},${gy} `);
|
||||
});
|
||||
}
|
||||
|
||||
cells.forEach(cell => {
|
||||
const key = `${cell.x},${cell.y}`;
|
||||
const key = `${cell.x},${cell.y} `;
|
||||
let color = baseColor;
|
||||
|
||||
// If this cell is an exit, color it Blue
|
||||
@@ -1257,9 +1363,9 @@ export class GameRenderer {
|
||||
const filename = subType;
|
||||
|
||||
if (type === 'hero') {
|
||||
path = `/assets/images/dungeon1/tokens/heroes/${filename}.png`;
|
||||
path = `/ assets / images / dungeon1 / tokens / heroes / ${filename}.png`;
|
||||
} else {
|
||||
path = `/assets/images/dungeon1/tokens/enemies/${filename}.png`;
|
||||
path = `/ assets / images / dungeon1 / tokens / enemies / ${filename}.png`;
|
||||
}
|
||||
|
||||
this.getTexture(path, (texture) => {
|
||||
@@ -1267,7 +1373,7 @@ export class GameRenderer {
|
||||
token.material.color.setHex(0xFFFFFF); // Reset to white to show texture
|
||||
token.material.needsUpdate = true;
|
||||
}, undefined, (err) => {
|
||||
console.warn(`[GameRenderer] Token texture missing: ${path}`);
|
||||
console.warn(`[GameRenderer] Token texture missing: ${path} `);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user