feat: Add floating combat text and damage flash feedback
This commit is contained in:
28
src/main.js
28
src/main.js
@@ -103,6 +103,34 @@ game.turnManager.on('phase_changed', (phase) => {
|
||||
|
||||
game.onCombatResult = (log) => {
|
||||
ui.showCombatLog(log);
|
||||
|
||||
// 1. Show Attack Roll on Attacker
|
||||
// Find Attacker pos
|
||||
const attacker = game.heroes.find(h => h.id === log.attackerId) || game.monsters.find(m => m.id === log.attackerId);
|
||||
if (attacker) {
|
||||
const rollColor = log.hitSuccess ? '#00ff00' : '#888888'; // Green vs Gray
|
||||
renderer.showFloatingText(attacker.x, attacker.y, `🎲 ${log.hitRoll}`, rollColor);
|
||||
}
|
||||
|
||||
// 2. Show Damage on Defender
|
||||
const defender = game.heroes.find(h => h.id === log.defenderId) || game.monsters.find(m => m.id === log.defenderId);
|
||||
if (defender) {
|
||||
setTimeout(() => { // Slight delay for cause-effect
|
||||
if (log.hitSuccess) {
|
||||
if (log.woundsCaused > 0) {
|
||||
// HIT and WOUND
|
||||
renderer.triggerDamageEffect(log.defenderId);
|
||||
renderer.showFloatingText(defender.x, defender.y, `💥 -${log.woundsCaused}`, '#ff0000');
|
||||
} else {
|
||||
// BLOCKED (Hit but Toughness saved)
|
||||
renderer.showFloatingText(defender.x, defender.y, `🛡️ Block`, '#ffff00');
|
||||
}
|
||||
} else {
|
||||
// MISS
|
||||
renderer.showFloatingText(defender.x, defender.y, `💨 Miss`, '#aaaaaa');
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
game.onEntityMove = (entity, path) => {
|
||||
|
||||
@@ -59,6 +59,10 @@ export class GameRenderer {
|
||||
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);
|
||||
@@ -260,29 +264,20 @@ export class GameRenderer {
|
||||
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
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
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();
|
||||
@@ -297,6 +292,43 @@ export class GameRenderer {
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
triggerDeathAnimation(entityId) {
|
||||
const mesh = this.entities.get(entityId);
|
||||
if (!mesh) return;
|
||||
@@ -474,6 +506,25 @@ export class GameRenderer {
|
||||
}
|
||||
});
|
||||
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user