Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions demo/civ-lite/feedback-model.js
Original file line number Diff line number Diff line change
@@ -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)),
};
}
55 changes: 55 additions & 0 deletions demo/civ-lite/feedback-model.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
70 changes: 68 additions & 2 deletions demo/civ-lite/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 { AUDIO_EVENTS, createAudioSystem } from './audio-model.js';
import {
CIV_COLORS,
Expand Down Expand Up @@ -276,6 +282,8 @@ const S = {
botPersonality: 'military',
// Visual state
particles: [],
floatingTexts: [],
screenShake: createScreenShake(0, 0),
waterPhase: 0,
selectionPhase: 0,
hoverTile: null,
Expand Down Expand Up @@ -557,10 +565,11 @@ function combat(attacker, defender) {
playAudioEvent(AUDIO_EVENTS.attack);

const result = applyCombatResult(attacker, defender, preview);
spawnParticles(defender.x * TILE + TILE / 2, defender.y * TILE + TILE / 2, 12);
addCombatFeedback(defender, preview.attackerDamage, result.defenderDestroyed);
log(`${attacker.owner}'s ${attacker.type} hits for ${preview.attackerDamage} dmg`, 'combat');

if (preview.defenderDamage > 0) {
addCombatFeedback(attacker, preview.defenderDamage, result.attackerDestroyed);
log(`Counter! ${attacker.type} takes ${preview.defenderDamage} dmg`, 'combat');
}
if (result.attackerPromoted) {
Expand All @@ -571,7 +580,6 @@ function combat(attacker, defender) {
S.units = S.units.filter(u => u !== defender);
log(`${defender.owner}'s ${defender.type} destroyed!`, 'combat');
if (defender.owner === 'barbarian') clearCampIfPresent(defender.x, defender.y, attacker);
spawnParticles(defender.x * TILE + TILE / 2, defender.y * TILE + TILE / 2, 20);
checkWin();
}
if (result.attackerDestroyed) {
Expand Down Expand Up @@ -621,6 +629,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) {
S.empireResources[owner] = collectEmpireResources({
Expand Down Expand Up @@ -948,6 +991,7 @@ function botTurn() {
const cap = S.cities.find(c => c.owner === 'player' && c.x === bot.x && c.y === bot.y);
if (cap) {
cap.owner = 'bot';
addCaptureFeedback(cap);
claimTerritory(S.map, S.cities);
updateHud();
log(`Bot captured ${cap.name}!`, 'combat');
Expand Down Expand Up @@ -981,6 +1025,7 @@ function botTurn() {
const cap = S.cities.find(c => c.owner === 'player' && c.x === bot.x && c.y === bot.y);
if (cap) {
cap.owner = 'bot';
addCaptureFeedback(cap);
claimTerritory(S.map, S.cities);
updateHud();
log(`Bot captured ${cap.name}!`, 'combat');
Expand Down Expand Up @@ -1527,6 +1572,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;
Expand Down Expand Up @@ -1939,6 +2000,7 @@ function handleClick(tx, ty) {
const cap = S.cities.find(c => c.owner === 'bot' && c.x === sel.x && c.y === sel.y);
if (cap) {
cap.owner = 'player';
addCaptureFeedback(cap);
Object.assign(cap, createCity(cap));
claimTerritory(S.map, S.cities);
updateHud();
Expand Down Expand Up @@ -2052,13 +2114,16 @@ function gameLoop(now) {
S.waterPhase += dt * 2;
S.selectionPhase += dt * 3;
updateParticles();
updateFeedback(dt);

// Clear
ctx.fillStyle = '#070b14';
ctx.fillRect(0, 0, canvas.width, canvas.height);

// 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();
Expand All @@ -2072,6 +2137,7 @@ function gameLoop(now) {
drawLayerFog();
drawLayerHover();
drawLayerParticles();
drawLayerFloatingTexts();
ctx.restore();

// Minimap (screen-space, no zoom)
Expand Down
Loading