From df3f892eb2e54a38f573b02e6ac6c41240c1c4c8 Mon Sep 17 00:00:00 2001 From: Marti Vich Date: Wed, 7 Jan 2026 20:16:55 +0100 Subject: [PATCH] feat: Add floating combat text and damage flash feedback --- src/main.js | 28 ++++++++++++ src/view/GameRenderer.js | 95 ++++++++++++++++++++++++++++++---------- 2 files changed, 101 insertions(+), 22 deletions(-) diff --git a/src/main.js b/src/main.js index 85c5673..6eb60a3 100644 --- a/src/main.js +++ b/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) => { diff --git a/src/view/GameRenderer.js b/src/view/GameRenderer.js index 15355dd..23ae0b3 100644 --- a/src/view/GameRenderer.js +++ b/src/view/GameRenderer.js @@ -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) {