feat: magic system visuals, audio sfx, and ui polish

This commit is contained in:
2026-01-07 22:42:34 +01:00
parent df3f892eb2
commit f2f399c296
15 changed files with 841 additions and 107 deletions

View File

@@ -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} `);
});
};