From 9067311a6083151aad2e89903dd554a403d4b989 Mon Sep 17 00:00:00 2001
From: YfengJ <166808804+YfengJ@users.noreply.github.com>
Date: Tue, 30 Jun 2026 03:31:08 +0800
Subject: [PATCH 1/3] Add Civ Lite barbarian camps and events
---
demo/civ-lite/barbarian-model.js | 146 +++++++++++++
demo/civ-lite/barbarian-model.test.mjs | 113 ++++++++++
demo/civ-lite/game.js | 284 +++++++++++++++++++++++--
demo/civ-lite/index.html | 7 +
demo/civ-lite/styles.css | 48 +++++
5 files changed, 586 insertions(+), 12 deletions(-)
create mode 100644 demo/civ-lite/barbarian-model.js
create mode 100644 demo/civ-lite/barbarian-model.test.mjs
diff --git a/demo/civ-lite/barbarian-model.js b/demo/civ-lite/barbarian-model.js
new file mode 100644
index 0000000..7997bba
--- /dev/null
+++ b/demo/civ-lite/barbarian-model.js
@@ -0,0 +1,146 @@
+const EVENT_CADENCE = 4;
+
+const EVENT_TABLE = [
+ {
+ id: 'ancient_ruins',
+ title: 'Ancient ruins',
+ description: 'Scouts uncover a buried archive near your border.',
+ choices: [
+ { id: 'study', label: 'Study the tablets', effects: { science: 5, cityProd: 1 } },
+ { id: 'salvage', label: 'Salvage the stonework', effects: { prod: 4, cityProd: 2 } },
+ ],
+ },
+ {
+ id: 'bumper_harvest',
+ title: 'Bumper harvest',
+ description: 'A mild season gives your capital surplus grain.',
+ choices: [
+ { id: 'store', label: 'Store grain', effects: { food: 4, cityFood: 4 } },
+ { id: 'trade', label: 'Trade surplus', effects: { prod: 2, science: 2 } },
+ ],
+ },
+ {
+ id: 'frontier_plague',
+ title: 'Frontier sickness',
+ description: 'A caravan brings illness and rumors from the frontier.',
+ choices: [
+ { id: 'quarantine', label: 'Quarantine quickly', effects: { prod: -1, cityFood: 2 } },
+ { id: 'research', label: 'Fund healers', effects: { science: 4, food: -1 } },
+ ],
+ },
+];
+
+function hashSeed(seed) {
+ let hash = 2166136261;
+ const text = String(seed);
+ for (let i = 0; i < text.length; i++) {
+ hash ^= text.charCodeAt(i);
+ hash = Math.imul(hash, 16777619);
+ }
+ return hash >>> 0;
+}
+
+export function createSeededRng(seed) {
+ let state = hashSeed(seed) || 1;
+ return () => {
+ state = Math.imul(state, 1664525) + 1013904223;
+ return (state >>> 0) / 4294967296;
+ };
+}
+
+function distance(a, b) {
+ return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
+}
+
+function isSafeTile(tile, safeZones, minDistance) {
+ return safeZones.every(zone => distance(tile, zone) > minDistance);
+}
+
+export function planBarbarianCamps({
+ map,
+ seed,
+ count = 3,
+ safeZones = [],
+ minDistance = 3,
+}) {
+ const rng = createSeededRng(seed);
+ const candidates = [];
+
+ for (let y = 0; y < map.length; y++) {
+ for (let x = 0; x < map[y].length; x++) {
+ const tile = { x, y };
+ if (map[y][x] !== 'water' && isSafeTile(tile, safeZones, minDistance)) {
+ candidates.push({ ...tile, score: rng() });
+ }
+ }
+ }
+
+ return candidates
+ .sort((a, b) => a.score - b.score)
+ .slice(0, count)
+ .map((tile, index) => ({
+ id: `camp-${index + 1}`,
+ name: `Camp ${index + 1}`,
+ x: tile.x,
+ y: tile.y,
+ hp: 40,
+ lastSpawnTurn: 1,
+ cleared: false,
+ }));
+}
+
+export function shouldSpawnFromCamp(camp, turn, cadence = 3) {
+ return !camp.cleared && turn - camp.lastSpawnTurn >= cadence;
+}
+
+export function createBarbarianUnit(camp, id, turn) {
+ return {
+ id,
+ owner: 'barbarian',
+ x: camp.x,
+ y: camp.y,
+ hp: 70,
+ atk: 22,
+ def: 10,
+ mov: 1,
+ movLeft: 1,
+ type: 'raider',
+ campId: camp.id,
+ spawnedTurn: turn,
+ };
+}
+
+export function buildRandomEvent({ seed, turn, cadence = EVENT_CADENCE }) {
+ if (turn <= 1 || turn % cadence !== 0) return null;
+ const rng = createSeededRng(`${seed}:${turn}`);
+ const event = EVENT_TABLE[Math.floor(rng() * EVENT_TABLE.length)];
+
+ return {
+ id: event.id,
+ title: event.title,
+ description: event.description,
+ turn,
+ choices: event.choices.map(choice => ({
+ id: choice.id,
+ label: choice.label,
+ effects: { ...choice.effects },
+ })),
+ };
+}
+
+export function applyEventChoice(empire, event, choiceId) {
+ const choice = event.choices.find(item => item.id === choiceId);
+ if (!choice) return { ...empire };
+
+ const next = { ...empire };
+ for (const [key, value] of Object.entries(choice.effects)) {
+ next[key] = (next[key] ?? 0) + value;
+ }
+ return next;
+}
+
+export function formatEventEffects(effects) {
+ return Object.entries(effects)
+ .map(([key, value]) => `${value > 0 ? '+' : ''}${value} ${key}`)
+ .join(', ');
+}
diff --git a/demo/civ-lite/barbarian-model.test.mjs b/demo/civ-lite/barbarian-model.test.mjs
new file mode 100644
index 0000000..149b60b
--- /dev/null
+++ b/demo/civ-lite/barbarian-model.test.mjs
@@ -0,0 +1,113 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+import {
+ applyEventChoice,
+ buildRandomEvent,
+ createBarbarianUnit,
+ planBarbarianCamps,
+ shouldSpawnFromCamp,
+} from './barbarian-model.js';
+
+const map = [
+ ['plains', 'forest', 'hill', 'plains', 'desert', 'plains'],
+ ['plains', 'water', 'plains', 'forest', 'hill', 'plains'],
+ ['hill', 'plains', 'desert', 'plains', 'forest', 'plains'],
+ ['plains', 'forest', 'plains', 'hill', 'plains', 'desert'],
+ ['desert', 'plains', 'hill', 'plains', 'water', 'plains'],
+ ['plains', 'hill', 'forest', 'plains', 'desert', 'plains'],
+];
+
+test('places deterministic barbarian camps on valid land away from capitals', () => {
+ const first = planBarbarianCamps({
+ map,
+ seed: 'barbarians-21',
+ count: 3,
+ safeZones: [{ x: 0, y: 0 }, { x: 5, y: 5 }],
+ });
+ const again = planBarbarianCamps({
+ map,
+ seed: 'barbarians-21',
+ count: 3,
+ safeZones: [{ x: 0, y: 0 }, { x: 5, y: 5 }],
+ });
+
+ assert.deepEqual(first, again);
+ assert.equal(first.length, 3);
+ for (const camp of first) {
+ assert.notEqual(map[camp.y][camp.x], 'water');
+ assert.ok(Math.abs(camp.x - 0) + Math.abs(camp.y - 0) > 3);
+ assert.ok(Math.abs(camp.x - 5) + Math.abs(camp.y - 5) > 3);
+ assert.match(camp.name, /^Camp /);
+ }
+});
+
+test('creates reproducible hostile units and throttles camp spawns by cadence', () => {
+ const camp = { id: 'camp-1', x: 3, y: 2, lastSpawnTurn: 1 };
+
+ assert.equal(shouldSpawnFromCamp(camp, 2, 3), false);
+ assert.equal(shouldSpawnFromCamp(camp, 4, 3), true);
+
+ const unit = createBarbarianUnit(camp, 12, 5);
+ assert.deepEqual(unit, {
+ id: 12,
+ owner: 'barbarian',
+ x: 3,
+ y: 2,
+ hp: 70,
+ atk: 22,
+ def: 10,
+ mov: 1,
+ movLeft: 1,
+ type: 'raider',
+ campId: 'camp-1',
+ spawnedTurn: 5,
+ });
+});
+
+test('selects deterministic random events only on event turns', () => {
+ assert.equal(buildRandomEvent({ seed: 'events', turn: 3 }), null);
+
+ const event = buildRandomEvent({ seed: 'events', turn: 4 });
+ const again = buildRandomEvent({ seed: 'events', turn: 4 });
+
+ assert.deepEqual(event, again);
+ assert.ok(event.id);
+ assert.equal(event.turn, 4);
+ assert.ok(event.choices.length >= 2);
+ assert.ok(event.choices.every(choice => choice.id && choice.label));
+});
+
+test('applies event choices without mutating the original empire snapshot', () => {
+ const empire = {
+ food: 2,
+ prod: 3,
+ science: 1,
+ cityFood: 4,
+ cityProd: 5,
+ };
+ const event = {
+ id: 'ancient_ruins',
+ choices: [
+ { id: 'study', effects: { science: 5, cityProd: 1 } },
+ { id: 'salvage', effects: { prod: 4 } },
+ ],
+ };
+
+ const result = applyEventChoice(empire, event, 'study');
+
+ assert.deepEqual(empire, {
+ food: 2,
+ prod: 3,
+ science: 1,
+ cityFood: 4,
+ cityProd: 5,
+ });
+ assert.deepEqual(result, {
+ food: 2,
+ prod: 3,
+ science: 6,
+ cityFood: 4,
+ cityProd: 6,
+ });
+});
diff --git a/demo/civ-lite/game.js b/demo/civ-lite/game.js
index 2428a1f..ea4f3c9 100644
--- a/demo/civ-lite/game.js
+++ b/demo/civ-lite/game.js
@@ -5,6 +5,14 @@
and Unciv tile groups.
───────────────────────────────────────────────────── */
import { buildSpriteAtlas } from './sprites.js';
+import {
+ applyEventChoice,
+ buildRandomEvent,
+ createBarbarianUnit,
+ formatEventEffects,
+ planBarbarianCamps,
+ shouldSpawnFromCamp,
+} from './barbarian-model.js';
// ─── Constants ──────────────────────────────────────
const MAP_W = 24, MAP_H = 16, TILE = 48;
@@ -21,6 +29,7 @@ const TERRAIN_YIELD = {
desert: { food: 0, prod: 1, sci: 1 },
};
const TERRAIN_DEFENSE = { plains: 0, forest: 0.25, hill: 0.35, water: 0, desert: 0 };
+const GAME_SEED = 'civ-lite-barbarian-events-21';
// ─── DOM refs ───────────────────────────────────────
const canvas = document.getElementById('gameCanvas');
@@ -36,6 +45,7 @@ const dom = {
prod: document.getElementById('prod'),
science: document.getElementById('science'),
unitDet: document.getElementById('unitDetails'),
+ eventPanel: document.getElementById('eventPanel'),
logBox: document.getElementById('logContent'),
};
@@ -98,6 +108,10 @@ const S = {
{ owner: 'player', x: 2, y: 2, name: 'Athens', food: 0, prod: 0, pop: 1 },
{ owner: 'bot', x: MAP_W - 3, y: MAP_H - 3, name: 'Babylon', food: 0, prod: 0, pop: 1 },
],
+ camps: [],
+ pendingEvent: null,
+ eventHistory: new Set(),
+ empire: { food: 0, prod: 0, science: 0 },
turn: 1,
phase: 'player', // player | bot | animating | gameover
selected: null,
@@ -126,6 +140,55 @@ for (let y = 0; y < MAP_H; y++) {
}
}
+function updateEmpireHud() {
+ dom.food.textContent = Math.max(0, S.empire.food);
+ dom.prod.textContent = Math.max(0, S.empire.prod);
+ dom.science.textContent = Math.max(0, S.empire.science);
+}
+
+function isHostileTo(unit, other) {
+ if (!unit || !other) return false;
+ if (unit.owner === other.owner) return false;
+ if (unit.owner === 'barbarian' || other.owner === 'barbarian') return true;
+ return unit.owner === 'player' || other.owner === 'player';
+}
+
+function findUnitAt(x, y, predicate = () => true) {
+ return S.units.find(u => u.x === x && u.y === y && predicate(u));
+}
+
+function findActiveCampAt(x, y) {
+ return S.camps.find(camp => !camp.cleared && camp.x === x && camp.y === y);
+}
+
+function clearCampIfPresent(x, y, attacker) {
+ const camp = findActiveCampAt(x, y);
+ if (!camp) return;
+
+ camp.cleared = true;
+ if (attacker.owner === 'player') {
+ S.empire.prod += 4;
+ S.empire.science += 2;
+ updateEmpireHud();
+ log(`${camp.name} cleared: +4 prod, +2 science`, 'build');
+ } else if (attacker.owner === 'bot') {
+ log(`Bot cleared ${camp.name}.`, 'combat');
+ }
+}
+
+function initializeNeutralCamps() {
+ S.camps = planBarbarianCamps({
+ map: S.map,
+ seed: GAME_SEED,
+ count: 3,
+ safeZones: S.cities.map(city => ({ x: city.x, y: city.y })),
+ });
+
+ for (const camp of S.camps) {
+ S.units.push(createBarbarianUnit(camp, S.nextId++, S.turn));
+ }
+}
+
// ─── Fog helpers ────────────────────────────────────
function revealAround(x, y, r = SIGHT) {
for (let dy = -r; dy <= r; dy++)
@@ -147,6 +210,7 @@ function refreshVision() {
S.cities.filter(c => c.owner === 'player').forEach(c => revealAround(c.x, c.y));
}
+initializeNeutralCamps();
refreshVision();
// ─── Pathfinding (A*) ───────────────────────────────
@@ -229,6 +293,7 @@ function combat(attacker, defender) {
if (defender.hp <= 0) {
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();
}
@@ -330,6 +395,63 @@ function gatherResources(owner) {
return { food: totalFood, prod: totalProd, sci: totalSci };
}
+function renderEventPanel() {
+ if (!dom.eventPanel) return;
+ const event = S.pendingEvent;
+ if (!event) {
+ dom.eventPanel.innerHTML = '
No active event
';
+ return;
+ }
+
+ dom.eventPanel.innerHTML = `
+ ${event.title}
+ ${event.description}
+
+ ${event.choices.map(choice => `
+ `).join('')}
+
`;
+}
+
+function maybeQueueRandomEvent() {
+ if (S.pendingEvent) return;
+ if (S.eventHistory.has(S.turn)) return;
+ const event = buildRandomEvent({ seed: GAME_SEED, turn: S.turn });
+ if (!event) return;
+
+ S.pendingEvent = event;
+ S.eventHistory.add(S.turn);
+ log(`Event: ${event.title}`, 'event');
+ renderEventPanel();
+}
+
+function resolveEventChoice(choiceId) {
+ if (!S.pendingEvent) return;
+ const city = S.cities.find(c => c.owner === 'player');
+ const current = {
+ ...S.empire,
+ cityFood: city?.food ?? 0,
+ cityProd: city?.prod ?? 0,
+ };
+ const next = applyEventChoice(current, S.pendingEvent, choiceId);
+
+ S.empire.food = Math.max(0, next.food ?? 0);
+ S.empire.prod = Math.max(0, next.prod ?? 0);
+ S.empire.science = Math.max(0, next.science ?? 0);
+ if (city) {
+ city.food = Math.max(0, next.cityFood ?? city.food);
+ city.prod = Math.max(0, next.cityProd ?? city.prod);
+ }
+
+ const choice = S.pendingEvent.choices.find(item => item.id === choiceId);
+ log(`${S.pendingEvent.title}: ${choice?.label ?? 'resolved'}`, 'event');
+ S.pendingEvent = null;
+ updateEmpireHud();
+ renderEventPanel();
+}
+
// ─── Turn management ────────────────────────────────
function endPlayerTurn() {
if (S.phase !== 'player') return;
@@ -342,9 +464,10 @@ function endPlayerTurn() {
updateUnitPanel();
const res = gatherResources('player');
- dom.food.textContent = res.food;
- dom.prod.textContent = res.prod;
- dom.science.textContent = res.sci;
+ S.empire.food += res.food;
+ S.empire.prod += res.prod;
+ S.empire.science += res.sci;
+ updateEmpireHud();
setTimeout(botTurn, 400);
}
@@ -358,6 +481,8 @@ function startPlayerTurn() {
S.units.filter(u => u.owner === 'player').forEach(u => { u.movLeft = u.mov; });
refreshVision();
log(`─── Turn ${S.turn} ───`);
+ maybeQueueRandomEvent();
+ renderEventPanel();
}
// ─── Bot AI ─────────────────────────────────────────
@@ -380,7 +505,7 @@ function botTurn() {
if (!bestTarget) continue;
// Check if adjacent to a player unit (attack)
- const adj = S.units.find(u => u.owner === 'player' &&
+ const adj = S.units.find(u => isHostileTo(bot, u) &&
Math.abs(u.x - bot.x) + Math.abs(u.y - bot.y) === 1);
if (adj) {
combat(bot, adj);
@@ -400,8 +525,9 @@ function botTurn() {
if (cost > movLeft) break;
// Check blocking
if (S.units.find(u => u.x === path[i].x && u.y === path[i].y && u !== bot)) {
- if (S.units.find(u => u.x === path[i].x && u.y === path[i].y && u.owner === 'player')) {
- combat(bot, S.units.find(u => u.x === path[i].x && u.y === path[i].y && u.owner === 'player'));
+ const blocker = S.units.find(u => u.x === path[i].x && u.y === path[i].y && u !== bot);
+ if (blocker && isHostileTo(bot, blocker)) {
+ combat(bot, blocker);
movLeft = 0;
}
break;
@@ -418,9 +544,85 @@ function botTurn() {
}
}
+ if (S.phase !== 'gameover') barbarianTurn();
if (S.phase !== 'gameover') startPlayerTurn();
}
+function spawnFromCamps() {
+ for (const camp of S.camps) {
+ if (!shouldSpawnFromCamp(camp, S.turn)) continue;
+ if (findUnitAt(camp.x, camp.y, unit => unit.owner === 'barbarian')) continue;
+
+ const unit = createBarbarianUnit(camp, S.nextId++, S.turn);
+ S.units.push(unit);
+ camp.lastSpawnTurn = S.turn;
+ log(`${camp.name} sends out a raider.`, 'event');
+ }
+}
+
+function findNearestBarbarianTarget(unit) {
+ const targets = [
+ ...S.units
+ .filter(other => isHostileTo(unit, other))
+ .map(other => ({ x: other.x, y: other.y, kind: 'unit', ref: other })),
+ ...S.cities
+ .filter(city => city.owner !== 'barbarian')
+ .map(city => ({ x: city.x, y: city.y, kind: 'city', ref: city })),
+ ];
+
+ let best = null;
+ let bestDist = Infinity;
+ for (const target of targets) {
+ const d = Math.abs(unit.x - target.x) + Math.abs(unit.y - target.y);
+ if (d < bestDist) {
+ best = target;
+ bestDist = d;
+ }
+ }
+ return best;
+}
+
+function raidCity(unit, city) {
+ if (unit.owner !== 'barbarian') return;
+ city.food = Math.max(0, city.food - 3);
+ city.prod = Math.max(0, city.prod - 3);
+ log(`Barbarians raid ${city.name}, burning stored food and production.`, 'event');
+}
+
+function barbarianTurn() {
+ spawnFromCamps();
+ const raiders = S.units.filter(u => u.owner === 'barbarian');
+
+ for (const raider of raiders) {
+ raider.movLeft = raider.mov;
+ const adjacent = S.units.find(unit => isHostileTo(raider, unit) &&
+ Math.abs(unit.x - raider.x) + Math.abs(unit.y - raider.y) === 1);
+ if (adjacent) {
+ combat(raider, adjacent);
+ raider.movLeft = 0;
+ continue;
+ }
+
+ const target = findNearestBarbarianTarget(raider);
+ if (!target) continue;
+
+ const path = findPath(raider.x, raider.y, target.x, target.y);
+ if (path.length > 1) {
+ const next = path[1];
+ const blocker = findUnitAt(next.x, next.y, unit => unit !== raider);
+ if (blocker && isHostileTo(raider, blocker)) {
+ combat(raider, blocker);
+ } else if (!blocker) {
+ raider.x = next.x;
+ raider.y = next.y;
+ }
+ }
+
+ const city = S.cities.find(c => c.x === raider.x && c.y === raider.y);
+ if (city) raidCity(raider, city);
+ }
+}
+
// ─── Animation helper ───────────────────────────────
function animateMove(unit, path, cb) {
if (path.length < 2) { cb(); return; }
@@ -608,19 +810,62 @@ function drawLayerCities() {
}
}
+// Layer 4.5: Barbarian camps
+function drawLayerCamps() {
+ for (const camp of S.camps) {
+ if (camp.cleared || S.fog[camp.y][camp.x] === 0) continue;
+ const px = camp.x * TILE + TILE / 2;
+ const py = camp.y * TILE + TILE / 2;
+
+ ctx.save();
+ ctx.translate(px, py);
+ ctx.fillStyle = 'rgba(210,153,34,0.25)';
+ ctx.beginPath();
+ ctx.arc(0, 0, 18, 0, Math.PI * 2);
+ ctx.fill();
+
+ ctx.fillStyle = '#8f4f24';
+ ctx.beginPath();
+ ctx.moveTo(-16, 13);
+ ctx.lineTo(0, -16);
+ ctx.lineTo(16, 13);
+ ctx.closePath();
+ ctx.fill();
+ ctx.strokeStyle = '#ffd166';
+ ctx.lineWidth = 2 / S.zoom;
+ ctx.stroke();
+
+ ctx.fillStyle = '#1b0f08';
+ ctx.fillRect(-5, 2, 10, 11);
+ ctx.restore();
+ }
+}
+
// Layer 5: Units
function drawLayerUnits() {
for (const unit of S.units) {
// Skip animated unit at its stored position
if (S.animating && S.animating.unit === unit) continue;
if (S.fog[unit.y][unit.x] === 0) continue;
- if (unit.owner === 'bot' && S.fog[unit.y][unit.x] < 2) continue;
+ if (unit.owner !== 'player' && S.fog[unit.y][unit.x] < 2) continue;
const sprite = unit.owner === 'player' ? ATLAS.units.player : ATLAS.units.bot;
const px = unit.x * TILE;
const py = unit.y * TILE;
ctx.drawImage(sprite, px, py, TILE, TILE);
+ if (unit.owner === 'barbarian') {
+ ctx.strokeStyle = '#d29922';
+ ctx.lineWidth = 2 / S.zoom;
+ ctx.beginPath();
+ ctx.arc(px + TILE / 2, py + TILE / 2, 17, 0, Math.PI * 2);
+ ctx.stroke();
+ ctx.fillStyle = '#ffd166';
+ ctx.font = `bold ${9 / S.zoom}px system-ui`;
+ ctx.textAlign = 'center';
+ ctx.fillText('B', px + TILE / 2, py + 9);
+ }
+
// HP bar
drawHPBar(px + 4, py + TILE - 6, TILE - 8, 3, unit.hp / 100);
@@ -739,13 +984,18 @@ function drawMinimap() {
}
// Units & cities on minimap
+ for (const camp of S.camps) {
+ if (camp.cleared || S.fog[camp.y][camp.x] === 0) continue;
+ miniCtx.fillStyle = '#d29922';
+ miniCtx.fillRect(camp.x * tw, camp.y * th, tw * 1.5, th * 1.5);
+ }
for (const city of S.cities) {
miniCtx.fillStyle = city.owner === 'player' ? '#44aaff' : '#ff4444';
miniCtx.fillRect(city.x * tw, city.y * th, tw * 2, th * 2);
}
for (const unit of S.units) {
- if (unit.owner === 'bot' && S.fog[unit.y][unit.x] < 2) continue;
- miniCtx.fillStyle = unit.owner === 'player' ? '#5af0ff' : '#ff6666';
+ if (unit.owner !== 'player' && S.fog[unit.y][unit.x] < 2) continue;
+ miniCtx.fillStyle = unit.owner === 'player' ? '#5af0ff' : unit.owner === 'barbarian' ? '#d29922' : '#ff6666';
miniCtx.beginPath();
miniCtx.arc(unit.x * tw + tw / 2, unit.y * th + th / 2, Math.max(tw, th) * 0.6, 0, Math.PI * 2);
miniCtx.fill();
@@ -797,13 +1047,15 @@ function showTooltip(x, y, tileX, tileY) {
const terrain = S.map[tileY][tileX];
const yields = TERRAIN_YIELD[terrain];
const def = Math.round(TERRAIN_DEFENSE[terrain] * 100);
+ const camp = findActiveCampAt(tileX, tileY);
tooltip.innerHTML = `
${terrain}${def ? ' (+' + def + '% def)' : ''}
🌾${yields.food}
⚒️${yields.prod}
🔬${yields.sci}
-
`;
+
+ ${camp ? `${camp.name}: clear for +4 prod, +2 science
` : ''}`;
tooltip.style.display = 'block';
tooltip.style.left = (x + 16) + 'px';
tooltip.style.top = (y + 16) + 'px';
@@ -912,7 +1164,7 @@ function handleClick(tx, ty) {
// Move / attack with selected unit
if (S.selected && S.selected.movLeft > 0) {
- const enemy = S.units.find(u => u.owner === 'bot' && u.x === tx && u.y === ty);
+ const enemy = S.units.find(u => isHostileTo(S.selected, u) && u.x === tx && u.y === ty);
if (enemy && Math.abs(enemy.x - S.selected.x) + Math.abs(enemy.y - S.selected.y) === 1) {
combat(S.selected, enemy);
S.selected.movLeft = 0;
@@ -933,7 +1185,7 @@ function handleClick(tx, ty) {
if (S.units.find(u => u.x === path[i].x && u.y === path[i].y && u !== S.selected)) {
// If enemy, attack
const blocker = S.units.find(u => u.x === path[i].x && u.y === path[i].y);
- if (blocker && blocker.owner === 'bot') {
+ if (blocker && isHostileTo(S.selected, blocker)) {
// Move to previous tile then attack
const movePath = path.slice(0, i);
if (movePath.length > 1) {
@@ -1018,6 +1270,11 @@ document.getElementById('btnCenter').addEventListener('click', () => {
if (u) centerOn(u.x, u.y);
});
+dom.eventPanel?.addEventListener('click', e => {
+ const button = e.target.closest?.('.event-choice');
+ if (button) resolveEventChoice(button.dataset.choice);
+});
+
// ─── Main loop ──────────────────────────────────────
let lastTime = 0;
function gameLoop(now) {
@@ -1042,6 +1299,7 @@ function gameLoop(now) {
drawLayerReachable();
drawLayerPath();
drawLayerCities();
+ drawLayerCamps();
drawLayerUnits();
drawLayerFog();
drawLayerHover();
@@ -1090,5 +1348,7 @@ function gameLoop(now) {
log('Loading sprites…');
ATLAS = await buildSpriteAtlas();
log('Sprites loaded — game starting!', 'good');
+ updateEmpireHud();
+ renderEventPanel();
requestAnimationFrame(gameLoop);
})();
diff --git a/demo/civ-lite/index.html b/demo/civ-lite/index.html
index 163f984..5f67c83 100644
--- a/demo/civ-lite/index.html
+++ b/demo/civ-lite/index.html
@@ -36,6 +36,13 @@ Selected Unit
+
+
Map
diff --git a/demo/civ-lite/styles.css b/demo/civ-lite/styles.css
index 249777a..38eb0fc 100644
--- a/demo/civ-lite/styles.css
+++ b/demo/civ-lite/styles.css
@@ -227,6 +227,47 @@ button:active {
box-shadow: 0 0 8px rgba(63,185,80,0.2);
}
+/* ───── Events ───────────────────────────────────── */
+#events .placeholder {
+ color: var(--dim);
+ font-size: 0.78rem;
+ font-style: italic;
+}
+
+#eventPanel {
+ display: grid;
+ gap: 7px;
+}
+
+.event-title {
+ color: var(--gold);
+ font-weight: 700;
+ font-size: 0.86rem;
+}
+
+#eventPanel p {
+ color: var(--text);
+ font-size: 0.76rem;
+ line-height: 1.35;
+}
+
+.event-actions {
+ display: grid;
+ gap: 6px;
+}
+
+.event-choice {
+ text-align: left;
+ padding: 7px 9px;
+}
+
+.event-choice small {
+ display: block;
+ color: var(--dim);
+ font-size: 0.68rem;
+ margin-top: 2px;
+}
+
/* ───── Minimap ──────────────────────────────────── */
#minimapBox { padding: 10px 14px; }
@@ -263,6 +304,7 @@ button:active {
#logContent .log-combat { color: var(--danger); }
#logContent .log-build { color: var(--accent2); }
#logContent .log-move { color: var(--accent); }
+#logContent .log-event { color: var(--gold); }
/* ───── Tooltip ──────────────────────────────────── */
#tooltip {
@@ -299,6 +341,12 @@ button:active {
gap: 2px;
}
+#tooltip .tt-camp {
+ color: var(--gold);
+ font-size: 0.7rem;
+ margin-top: 4px;
+}
+
/* ───── Scrollbar ────────────────────────────────── */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
From 96bfe0491a76f209479e25ca1a96ff4239c79dc0 Mon Sep 17 00:00:00 2001
From: YfengJ <166808804+YfengJ@users.noreply.github.com>
Date: Tue, 30 Jun 2026 03:34:05 +0800
Subject: [PATCH 2/3] Address Sonar feedback for barbarian events
---
demo/civ-lite/barbarian-model.js | 10 +++++-----
demo/civ-lite/game.js | 16 +++++++++++++---
2 files changed, 18 insertions(+), 8 deletions(-)
diff --git a/demo/civ-lite/barbarian-model.js b/demo/civ-lite/barbarian-model.js
index 7997bba..bb12f02 100644
--- a/demo/civ-lite/barbarian-model.js
+++ b/demo/civ-lite/barbarian-model.js
@@ -32,9 +32,8 @@ const EVENT_TABLE = [
function hashSeed(seed) {
let hash = 2166136261;
- const text = String(seed);
- for (let i = 0; i < text.length; i++) {
- hash ^= text.charCodeAt(i);
+ for (const char of String(seed)) {
+ hash ^= char.codePointAt(0);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
@@ -75,8 +74,9 @@ export function planBarbarianCamps({
}
}
- return candidates
- .sort((a, b) => a.score - b.score)
+ const sortedCandidates = candidates.sort((a, b) => a.score - b.score);
+
+ return sortedCandidates
.slice(0, count)
.map((tile, index) => ({
id: `camp-${index + 1}`,
diff --git a/demo/civ-lite/game.js b/demo/civ-lite/game.js
index ea4f3c9..15b14e0 100644
--- a/demo/civ-lite/game.js
+++ b/demo/civ-lite/game.js
@@ -995,7 +995,7 @@ function drawMinimap() {
}
for (const unit of S.units) {
if (unit.owner !== 'player' && S.fog[unit.y][unit.x] < 2) continue;
- miniCtx.fillStyle = unit.owner === 'player' ? '#5af0ff' : unit.owner === 'barbarian' ? '#d29922' : '#ff6666';
+ miniCtx.fillStyle = minimapUnitColor(unit.owner);
miniCtx.beginPath();
miniCtx.arc(unit.x * tw + tw / 2, unit.y * th + th / 2, Math.max(tw, th) * 0.6, 0, Math.PI * 2);
miniCtx.fill();
@@ -1012,6 +1012,12 @@ function drawMinimap() {
);
}
+function minimapUnitColor(owner) {
+ if (owner === 'player') return '#5af0ff';
+ if (owner === 'barbarian') return '#d29922';
+ return '#ff6666';
+}
+
// ─── Unit info panel ────────────────────────────────
function updateUnitPanel() {
const u = S.selected;
@@ -1048,14 +1054,18 @@ function showTooltip(x, y, tileX, tileY) {
const yields = TERRAIN_YIELD[terrain];
const def = Math.round(TERRAIN_DEFENSE[terrain] * 100);
const camp = findActiveCampAt(tileX, tileY);
+ const defenseText = def ? ` (+${def}% def)` : '';
+ const campText = camp
+ ? `${camp.name}: clear for +4 prod, +2 science
`
+ : '';
tooltip.innerHTML = `
- ${terrain}${def ? ' (+' + def + '% def)' : ''}
+ ${terrain}${defenseText}
🌾${yields.food}
⚒️${yields.prod}
🔬${yields.sci}
- ${camp ? `${camp.name}: clear for +4 prod, +2 science
` : ''}`;
+ ${campText}`;
tooltip.style.display = 'block';
tooltip.style.left = (x + 16) + 'px';
tooltip.style.top = (y + 16) + 'px';
From d74db6341f017f4561a467a922e12e69ce00a1ab Mon Sep 17 00:00:00 2001
From: YfengJ <166808804+YfengJ@users.noreply.github.com>
Date: Tue, 30 Jun 2026 03:35:59 +0800
Subject: [PATCH 3/3] Clear barbarian model Sonar warning
---
demo/civ-lite/barbarian-model.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/demo/civ-lite/barbarian-model.js b/demo/civ-lite/barbarian-model.js
index bb12f02..d8ee8dd 100644
--- a/demo/civ-lite/barbarian-model.js
+++ b/demo/civ-lite/barbarian-model.js
@@ -74,7 +74,8 @@ export function planBarbarianCamps({
}
}
- const sortedCandidates = candidates.sort((a, b) => a.score - b.score);
+ const sortedCandidates = [...candidates];
+ sortedCandidates.sort((a, b) => a.score - b.score);
return sortedCandidates
.slice(0, count)