From 9e7e1fb0be979ce27d57eb980acd97b11be600a5 Mon Sep 17 00:00:00 2001 From: YfengJ <166808804+YfengJ@users.noreply.github.com> Date: Tue, 30 Jun 2026 04:44:38 +0800 Subject: [PATCH] Add Civ Lite combat feedback --- demo/civ-lite/feedback-model.js | 75 +++++++++++++++++++++ demo/civ-lite/feedback-model.test.mjs | 55 +++++++++++++++ demo/civ-lite/game.js | 96 ++++++++++++++++++++++++--- 3 files changed, 217 insertions(+), 9 deletions(-) create mode 100644 demo/civ-lite/feedback-model.js create mode 100644 demo/civ-lite/feedback-model.test.mjs diff --git a/demo/civ-lite/feedback-model.js b/demo/civ-lite/feedback-model.js new file mode 100644 index 0000000..dd3fe35 --- /dev/null +++ b/demo/civ-lite/feedback-model.js @@ -0,0 +1,75 @@ +const FLOATING_TEXT_LIFE = 0.9; +const FLOATING_TEXT_SPEED = -32; + +const TEXT_COLORS = { + damage: '#ffcf5a', + ko: '#ff5a73', + capture: '#69f0ae', +}; + +export function createFloatingText({ x, y, text, kind = 'damage' }) { + return { + x, + y, + text, + kind, + color: TEXT_COLORS[kind] || '#ffffff', + life: FLOATING_TEXT_LIFE, + maxLife: FLOATING_TEXT_LIFE, + vy: FLOATING_TEXT_SPEED, + }; +} + +export function createScreenShake(intensity, duration) { + return { + baseIntensity: intensity, + intensity, + duration, + time: duration, + }; +} + +export function createCombatFeedback({ x, y, damage, destroyed = false }) { + const hitDamage = Math.max(0, Math.round(damage)); + const texts = [ + createFloatingText({ x, y: y - 12, text: `-${hitDamage}`, kind: 'damage' }), + ]; + + if (destroyed) { + texts.push(createFloatingText({ x, y: y - 28, text: 'KO', kind: 'ko' })); + } + + return { + texts, + particleCount: destroyed ? 26 : 14, + shake: destroyed ? createScreenShake(9, 0.26) : createScreenShake(5, 0.18), + }; +} + +export function advanceFeedback(current, dt) { + const texts = current.texts + .map(text => ({ + ...text, + y: text.y + text.vy * dt, + life: Math.max(0, Number((text.life - dt).toFixed(3))), + })) + .filter(text => text.life > 0); + + const shake = advanceShake(current.shake, dt); + + return { texts, shake }; +} + +function advanceShake(shake, dt) { + if (!shake || shake.time <= 0 || shake.duration <= 0) { + return createScreenShake(0, 0); + } + + const time = Math.max(0, Number((shake.time - dt).toFixed(3))); + const progress = shake.duration > 0 ? time / shake.duration : 0; + return { + ...shake, + time, + intensity: Number((shake.baseIntensity * progress).toFixed(3)), + }; +} diff --git a/demo/civ-lite/feedback-model.test.mjs b/demo/civ-lite/feedback-model.test.mjs new file mode 100644 index 0000000..21cae09 --- /dev/null +++ b/demo/civ-lite/feedback-model.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + advanceFeedback, + createCombatFeedback, + createFloatingText, + createScreenShake, +} from './feedback-model.js'; + +test('createFloatingText builds a short-lived world-space damage label', () => { + const text = createFloatingText({ x: 120, y: 80, text: '-24', kind: 'damage' }); + + assert.equal(text.text, '-24'); + assert.equal(text.x, 120); + assert.equal(text.y, 80); + assert.equal(text.kind, 'damage'); + assert.equal(text.life, text.maxLife); + assert.ok(text.vy < 0); +}); + +test('createCombatFeedback emits damage text, hit particles, and shake', () => { + const feedback = createCombatFeedback({ x: 96, y: 144, damage: 18, destroyed: false }); + + assert.equal(feedback.texts.length, 1); + assert.equal(feedback.texts[0].text, '-18'); + assert.equal(feedback.particleCount, 14); + assert.deepEqual(feedback.shake, createScreenShake(5, 0.18)); +}); + +test('destroyed combat feedback is stronger and labels the defeat', () => { + const feedback = createCombatFeedback({ x: 96, y: 144, damage: 31, destroyed: true }); + + assert.equal(feedback.texts.length, 2); + assert.equal(feedback.texts[1].text, 'KO'); + assert.equal(feedback.particleCount, 26); + assert.deepEqual(feedback.shake, createScreenShake(9, 0.26)); +}); + +test('advanceFeedback moves labels upward and expires old entries', () => { + const current = { + texts: [ + createFloatingText({ x: 0, y: 20, text: '-5', kind: 'damage' }), + { ...createFloatingText({ x: 0, y: 40, text: 'old', kind: 'ko' }), life: 0.05 }, + ], + shake: createScreenShake(6, 0.2), + }; + const next = advanceFeedback(current, 0.1); + + assert.equal(next.texts.length, 1); + assert.ok(next.texts[0].y < 20); + assert.ok(next.texts[0].life < next.texts[0].maxLife); + assert.equal(next.shake.time, 0.1); + assert.equal(next.shake.intensity, 3); +}); diff --git a/demo/civ-lite/game.js b/demo/civ-lite/game.js index 2428a1f..37dad69 100644 --- a/demo/civ-lite/game.js +++ b/demo/civ-lite/game.js @@ -4,6 +4,12 @@ Inspired by Freeciv-web layers, C7 terrain system, and Unciv tile groups. ───────────────────────────────────────────────────── */ +import { + advanceFeedback, + createCombatFeedback, + createFloatingText, + createScreenShake, +} from './feedback-model.js'; import { buildSpriteAtlas } from './sprites.js'; // ─── Constants ────────────────────────────────────── @@ -107,6 +113,8 @@ const S = { nextId: 3, // Visual state particles: [], + floatingTexts: [], + screenShake: createScreenShake(0, 0), waterPhase: 0, selectionPhase: 0, hoverTile: null, @@ -217,19 +225,21 @@ function combat(attacker, defender) { const defTerrain = S.map[defender.y][defender.x]; const defBonus = TERRAIN_DEFENSE[defTerrain] || 0; const dmg = Math.max(5, attacker.atk - defender.def * (1 + defBonus) * 0.5 + Math.random() * 10); - defender.hp -= Math.round(dmg); + const defenderDamage = Math.round(dmg); + defender.hp -= defenderDamage; + addCombatFeedback(defender, defenderDamage, defender.hp <= 0); // Counter-attack if (defender.hp > 0) { const counterDmg = Math.max(2, defender.atk * 0.4 - attacker.def * 0.3 + Math.random() * 5); - attacker.hp -= Math.round(counterDmg); - log(`Counter! ${attacker.type} takes ${Math.round(counterDmg)} dmg`, 'combat'); + const attackerDamage = Math.round(counterDmg); + attacker.hp -= attackerDamage; + addCombatFeedback(attacker, attackerDamage, attacker.hp <= 0); + log(`Counter! ${attacker.type} takes ${attackerDamage} dmg`, 'combat'); } - spawnParticles(defender.x * TILE + TILE / 2, defender.y * TILE + TILE / 2, 12); - log(`${attacker.owner}'s ${attacker.type} hits for ${Math.round(dmg)} dmg`, 'combat'); + log(`${attacker.owner}'s ${attacker.type} hits for ${defenderDamage} dmg`, 'combat'); if (defender.hp <= 0) { S.units = S.units.filter(u => u !== defender); log(`${defender.owner}'s ${defender.type} destroyed!`, 'combat'); - spawnParticles(defender.x * TILE + TILE / 2, defender.y * TILE + TILE / 2, 20); checkWin(); } if (attacker.hp <= 0) { @@ -283,6 +293,41 @@ function updateParticles() { S.particles = S.particles.filter(p => p.life > 0); } +function addCombatFeedback(unit, damage, destroyed) { + const x = unit.x * TILE + TILE / 2; + const y = unit.y * TILE + TILE / 2; + const feedback = createCombatFeedback({ x, y, damage, destroyed }); + S.floatingTexts.push(...feedback.texts); + S.screenShake = feedback.shake; + spawnParticles(x, y, feedback.particleCount); +} + +function addCaptureFeedback(city) { + const x = city.x * TILE + TILE / 2; + const y = city.y * TILE + TILE / 2; + S.floatingTexts.push(createFloatingText({ x, y: y - 18, text: 'Captured!', kind: 'capture' })); + S.screenShake = createScreenShake(6, 0.2); + spawnParticles(x, y, 18); +} + +function updateFeedback(dt) { + const next = advanceFeedback({ texts: S.floatingTexts, shake: S.screenShake }, dt); + S.floatingTexts = next.texts; + S.screenShake = next.shake; +} + +function getShakeOffset(now) { + if (!S.screenShake || S.screenShake.time <= 0 || S.screenShake.intensity <= 0) { + return { x: 0, y: 0 }; + } + + const phase = now * 0.05; + return { + x: Math.sin(phase * 2.1) * S.screenShake.intensity, + y: Math.cos(phase * 1.6) * S.screenShake.intensity, + }; +} + // ─── City production ──────────────────────────────── function gatherResources(owner) { const cities = S.cities.filter(c => c.owner === owner); @@ -387,7 +432,11 @@ function botTurn() { bot.movLeft = 0; // Check city capture const cap = S.cities.find(c => c.owner === 'player' && c.x === bot.x && c.y === bot.y); - if (cap) { cap.owner = 'bot'; log(`Bot captured ${cap.name}!`, 'combat'); } + if (cap) { + cap.owner = 'bot'; + addCaptureFeedback(cap); + log(`Bot captured ${cap.name}!`, 'combat'); + } continue; } @@ -414,7 +463,11 @@ function botTurn() { bot.movLeft = movLeft; // Capture city const cap = S.cities.find(c => c.owner === 'player' && c.x === bot.x && c.y === bot.y); - if (cap) { cap.owner = 'bot'; log(`Bot captured ${cap.name}!`, 'combat'); } + if (cap) { + cap.owner = 'bot'; + addCaptureFeedback(cap); + log(`Bot captured ${cap.name}!`, 'combat'); + } } } @@ -711,6 +764,22 @@ function drawLayerParticles() { ctx.globalAlpha = 1; } +function drawLayerFloatingTexts() { + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + for (const text of S.floatingTexts) { + const alpha = Math.max(0, Math.min(1, text.life / text.maxLife)); + ctx.globalAlpha = alpha; + ctx.font = `bold ${14 / S.zoom}px system-ui`; + ctx.lineWidth = 3 / S.zoom; + ctx.strokeStyle = 'rgba(0,0,0,0.55)'; + ctx.strokeText(text.text, text.x, text.y); + ctx.fillStyle = text.color; + ctx.fillText(text.text, text.x, text.y); + } + ctx.globalAlpha = 1; +} + // ─── Minimap ──────────────────────────────────────── function drawMinimap() { const mw = miniC.width, mh = miniC.height; @@ -969,7 +1038,12 @@ function handleClick(tx, ty) { refreshVision(); // City capture const cap = S.cities.find(c => c.owner === 'bot' && c.x === sel.x && c.y === sel.y); - if (cap) { cap.owner = 'player'; log(`Captured ${cap.name}!`, 'build'); checkWin(); } + if (cap) { + cap.owner = 'player'; + addCaptureFeedback(cap); + log(`Captured ${cap.name}!`, 'build'); + checkWin(); + } }); } } @@ -1028,6 +1102,7 @@ function gameLoop(now) { S.waterPhase += dt * 2; S.selectionPhase += dt * 3; updateParticles(); + updateFeedback(dt); // Clear ctx.fillStyle = '#070b14'; @@ -1035,6 +1110,8 @@ function gameLoop(now) { // Render all layers with zoom transform ctx.save(); + const shake = getShakeOffset(now); + ctx.translate(shake.x, shake.y); ctx.scale(S.zoom, S.zoom); ctx.translate(-S.camX, -S.camY); drawLayerTerrain(); @@ -1046,6 +1123,7 @@ function gameLoop(now) { drawLayerFog(); drawLayerHover(); drawLayerParticles(); + drawLayerFloatingTexts(); ctx.restore(); // Minimap (screen-space, no zoom)