From 19bccee848275ec31fda3b65a824a673304d8f1f Mon Sep 17 00:00:00 2001 From: YfengJ <166808804+YfengJ@users.noreply.github.com> Date: Tue, 30 Jun 2026 04:29:29 +0800 Subject: [PATCH 1/2] Add Civ Lite city management --- demo/civ-lite/city-model.js | 148 ++++++++++++++++++++++++++++++ demo/civ-lite/city-model.test.mjs | 94 +++++++++++++++++++ demo/civ-lite/game.js | 124 ++++++++++++++++++++----- demo/civ-lite/index.html | 7 ++ demo/civ-lite/styles.css | 96 +++++++++++++++++++ 5 files changed, 447 insertions(+), 22 deletions(-) create mode 100644 demo/civ-lite/city-model.js create mode 100644 demo/civ-lite/city-model.test.mjs diff --git a/demo/civ-lite/city-model.js b/demo/civ-lite/city-model.js new file mode 100644 index 0000000..356e0b0 --- /dev/null +++ b/demo/civ-lite/city-model.js @@ -0,0 +1,148 @@ +export const CITY_BUILDINGS = { + granary: { + name: 'Granary', + cost: 12, + yields: { food: 2, prod: 0, sci: 0 }, + amenities: 0, + defense: 0, + }, + library: { + name: 'Library', + cost: 16, + yields: { food: 0, prod: 0, sci: 2 }, + amenities: 0, + defense: 0, + }, + walls: { + name: 'Walls', + cost: 14, + yields: { food: 0, prod: 1, sci: 0 }, + amenities: 0, + defense: 15, + }, + market: { + name: 'Market', + cost: 14, + yields: { food: 0, prod: 0, sci: 0 }, + amenities: 1, + defense: 0, + }, +}; + +export function createCity(city) { + return { + food: 0, + prod: 0, + pop: 1, + buildings: [], + amenities: 0, + ...city, + buildings: [...(city.buildings || [])], + }; +} + +export function calculateCityEconomy(city, map, terrainYield, empireCityCount = 1) { + const normalized = createCity(city); + const workedTiles = getWorkedTiles(normalized, map, terrainYield); + const baseYields = sumYields(workedTiles.map(tile => tile.yields)); + const buildingYields = getBuildingYields(normalized); + const rawYields = addYields(baseYields, buildingYields); + const happiness = calculateHappiness(normalized, empireCityCount); + const penalty = happiness < 0 ? Math.min(0.3, Math.abs(happiness) * 0.1) : 0; + return { + workedTiles, + baseYields, + buildingYields, + totalYields: applyPenalty(rawYields, penalty), + happiness, + foodRequired: getFoodRequired(normalized), + defenseBonus: normalized.buildings.reduce((sum, key) => sum + (CITY_BUILDINGS[key]?.defense || 0), 0), + }; +} + +export function progressCityTurn(city, economy) { + const next = createCity(city); + next.food += economy.totalYields.food; + next.prod += economy.totalYields.prod; + if (next.food >= economy.foodRequired) { + next.pop += 1; + next.food = 0; + } + return next; +} + +export function buildBuilding(city, key) { + const building = CITY_BUILDINGS[key]; + const next = createCity(city); + if (!building) return { ok: false, reason: 'unknown-building', city: next }; + if (next.buildings.includes(key)) return { ok: false, reason: 'already-built', city: next }; + if (next.prod < building.cost) return { ok: false, reason: 'not-enough-production', city: next }; + next.prod -= building.cost; + next.buildings.push(key); + return { ok: true, city: next }; +} + +export function getFoodRequired(city) { + return 8 * Math.max(1, city.pop); +} + +function getWorkedTiles(city, map, terrainYield) { + const center = getTile(city.x, city.y, map, terrainYield); + if (!center) return []; + const candidates = []; + for (let y = city.y - 1; y <= city.y + 1; y++) { + for (let x = city.x - 1; x <= city.x + 1; x++) { + if (x === city.x && y === city.y) continue; + const tile = getTile(x, y, map, terrainYield); + if (tile && tile.terrain !== 'water') candidates.push(tile); + } + } + candidates.sort((a, b) => tileScore(b) - tileScore(a)); + return [center, ...candidates.slice(0, Math.max(0, city.pop))]; +} + +function getTile(x, y, map, terrainYield) { + if (!map[y] || !map[y][x]) return null; + const terrain = map[y][x]; + return { x, y, terrain, yields: { ...terrainYield[terrain] } }; +} + +function tileScore(tile) { + return tile.yields.prod * 2 + tile.yields.food + tile.yields.sci; +} + +function sumYields(rows) { + return rows.reduce((sum, yields) => addYields(sum, yields), { food: 0, prod: 0, sci: 0 }); +} + +function addYields(a, b) { + return { + food: a.food + b.food, + prod: a.prod + b.prod, + sci: a.sci + b.sci, + }; +} + +function getBuildingYields(city) { + return city.buildings.reduce((sum, key) => addYields(sum, CITY_BUILDINGS[key]?.yields || { food: 0, prod: 0, sci: 0 }), { + food: 0, + prod: 0, + sci: 0, + }); +} + +function calculateHappiness(city, empireCityCount) { + const buildingAmenities = city.buildings.reduce((sum, key) => sum + (CITY_BUILDINGS[key]?.amenities || 0), 0); + const populationPressure = Math.max(0, city.pop - 3); + const expansionPressure = Math.max(0, empireCityCount - 2); + return 3 + city.amenities + buildingAmenities - populationPressure - expansionPressure; +} + +function applyPenalty(yields, penalty) { + if (penalty <= 0) return yields; + return { + food: Math.max(0, Math.floor(yields.food * (1 - penalty))), + prod: Math.max(0, Math.floor(yields.prod * (1 - penalty))), + sci: Math.max(0, Math.floor(yields.sci * (1 - penalty))), + }; +} diff --git a/demo/civ-lite/city-model.test.mjs b/demo/civ-lite/city-model.test.mjs new file mode 100644 index 0000000..ab78c98 --- /dev/null +++ b/demo/civ-lite/city-model.test.mjs @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + buildBuilding, + calculateCityEconomy, + CITY_BUILDINGS, + createCity, + progressCityTurn, +} from './city-model.js'; + +const terrainYield = { + plains: { food: 2, prod: 1, sci: 0 }, + forest: { food: 1, prod: 2, sci: 0 }, + hill: { food: 0, prod: 2, sci: 1 }, + water: { food: 3, prod: 0, sci: 0 }, + desert: { food: 0, prod: 1, sci: 1 }, +}; + +const map = [ + ['plains', 'forest', 'hill'], + ['plains', 'plains', 'desert'], + ['water', 'forest', 'hill'], +]; + +test('calculateCityEconomy works the city center plus best population tiles', () => { + const city = createCity({ owner: 'player', x: 1, y: 1, name: 'Athens', pop: 2 }); + const economy = calculateCityEconomy(city, map, terrainYield, 1); + + assert.equal(economy.workedTiles.length, 3); + assert.deepEqual(economy.baseYields, { food: 3, prod: 5, sci: 1 }); + assert.deepEqual(economy.totalYields, { food: 3, prod: 5, sci: 1 }); +}); + +test('building effects improve city yields and defense', () => { + const city = createCity({ + owner: 'player', + x: 1, + y: 1, + name: 'Athens', + pop: 1, + buildings: ['granary', 'library', 'walls'], + }); + const economy = calculateCityEconomy(city, map, terrainYield, 1); + + assert.deepEqual(economy.buildingYields, { food: 2, prod: 1, sci: 2 }); + assert.equal(economy.defenseBonus, CITY_BUILDINGS.walls.defense); + assert.deepEqual(economy.totalYields, { food: 5, prod: 4, sci: 2 }); +}); + +test('happiness accounts for population pressure, expansion, and amenities', () => { + const strained = createCity({ + owner: 'player', + x: 1, + y: 1, + name: 'Athens', + pop: 6, + amenities: 0, + }); + const supported = createCity({ + owner: 'player', + x: 1, + y: 1, + name: 'Athens', + pop: 6, + amenities: 2, + buildings: ['market'], + }); + + assert.equal(calculateCityEconomy(strained, map, terrainYield, 4).happiness, -2); + assert.equal(calculateCityEconomy(supported, map, terrainYield, 4).happiness, 1); +}); + +test('progressCityTurn accumulates food and grows when food reaches threshold', () => { + const city = createCity({ owner: 'player', x: 1, y: 1, name: 'Athens', pop: 1, food: 5, prod: 0 }); + const economy = calculateCityEconomy(city, map, terrainYield, 1); + const next = progressCityTurn(city, economy); + + assert.equal(next.pop, 2); + assert.equal(next.food, 0); + assert.equal(next.prod, 3); +}); + +test('buildBuilding spends production and prevents duplicate buildings', () => { + const city = createCity({ owner: 'player', x: 1, y: 1, name: 'Athens', prod: 12 }); + const built = buildBuilding(city, 'granary'); + const duplicate = buildBuilding(built.city, 'granary'); + + assert.equal(built.ok, true); + assert.deepEqual(built.city.buildings, ['granary']); + assert.equal(built.city.prod, 0); + assert.equal(duplicate.ok, false); + assert.equal(duplicate.reason, 'already-built'); +}); diff --git a/demo/civ-lite/game.js b/demo/civ-lite/game.js index 2428a1f..2ab4b65 100644 --- a/demo/civ-lite/game.js +++ b/demo/civ-lite/game.js @@ -5,6 +5,13 @@ and Unciv tile groups. ───────────────────────────────────────────────────── */ import { buildSpriteAtlas } from './sprites.js'; +import { + buildBuilding, + calculateCityEconomy, + CITY_BUILDINGS, + createCity, + progressCityTurn, +} from './city-model.js'; // ─── Constants ────────────────────────────────────── const MAP_W = 24, MAP_H = 16, TILE = 48; @@ -35,6 +42,7 @@ const dom = { food: document.getElementById('food'), prod: document.getElementById('prod'), science: document.getElementById('science'), + cityDet: document.getElementById('cityDetails'), unitDet: document.getElementById('unitDetails'), logBox: document.getElementById('logContent'), }; @@ -95,8 +103,8 @@ const S = { { id: 2, owner: 'bot', x: MAP_W - 3, y: MAP_H - 3, hp: 100, atk: 28, def: 18, mov: 2, movLeft: 2, type: 'warrior' }, ], cities: [ - { 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 }, + createCity({ owner: 'player', x: 2, y: 2, name: 'Athens' }), + createCity({ owner: 'bot', x: MAP_W - 3, y: MAP_H - 3, name: 'Babylon' }), ], turn: 1, phase: 'player', // player | bot | animating | gameover @@ -148,6 +156,7 @@ function refreshVision() { } refreshVision(); +updateCityPanel(); // ─── Pathfinding (A*) ─────────────────────────────── function heuristic(a, b) { return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); } @@ -287,29 +296,23 @@ function updateParticles() { function gatherResources(owner) { const cities = S.cities.filter(c => c.owner === owner); let totalFood = 0, totalProd = 0, totalSci = 0; + const empireCityCount = cities.length; for (const city of cities) { - // Gather yields from surrounding tiles - for (let dy = -1; dy <= 1; dy++) - for (let dx = -1; dx <= 1; dx++) { - const nx = city.x + dx, ny = city.y + dy; - if (nx >= 0 && nx < MAP_W && ny >= 0 && ny < MAP_H) { - const y = TERRAIN_YIELD[S.map[ny][nx]]; - totalFood += y.food; - totalProd += y.prod; - totalSci += y.sci; - } - } - city.food += totalFood; - city.prod += totalProd; + const economy = calculateCityEconomy(city, S.map, TERRAIN_YIELD, empireCityCount); + const beforePop = city.pop; + Object.assign(city, progressCityTurn(city, economy)); + totalFood += economy.totalYields.food; + totalProd += economy.totalYields.prod; + totalSci += economy.totalYields.sci; // City growth - if (city.food >= 8 * city.pop) { - city.pop++; - city.food = 0; + if (city.pop > beforePop) { log(`${city.name} grows to pop ${city.pop}!`, 'build'); } - // Auto-recruit - if (city.prod >= 12) { + + // Bot auto-recruitment keeps the prototype opponent active; player + // production is banked for city buildings until #16 adds a queue. + if (owner === 'bot' && city.prod >= 12) { city.prod -= 12; const id = S.nextId++; let spawnX = city.x, spawnY = city.y; @@ -327,6 +330,7 @@ function gatherResources(owner) { if (owner === 'player') revealAround(spawnX, spawnY); } } + updateCityPanel(); return { food: totalFood, prod: totalProd, sci: totalSci }; } @@ -605,6 +609,14 @@ function drawLayerCities() { ctx.fillStyle = '#ffd700'; ctx.font = `bold ${8 / S.zoom}px system-ui`; ctx.fillText(city.pop, px + 18, py - 15); + + if (city.buildings?.length) { + ctx.fillStyle = '#1b1f2a'; + ctx.fillRect(px - 20, py + 32, 40, 10); + ctx.fillStyle = city.owner === 'player' ? '#3fb950' : '#d29922'; + ctx.font = `bold ${7 / S.zoom}px system-ui`; + ctx.fillText(`${city.buildings.length} bld`, px, py + 40); + } } } @@ -790,6 +802,55 @@ function updateUnitPanel() { } } +// ─── City management panel ────────────────────────── +function updateCityPanel() { + const city = S.cities.find(c => c.owner === 'player'); + if (!city) { + dom.cityDet.innerHTML = '

No player city remains

'; + return; + } + const playerCityCount = S.cities.filter(c => c.owner === 'player').length; + const economy = calculateCityEconomy(city, S.map, TERRAIN_YIELD, playerCityCount); + const happinessClass = economy.happiness >= 1 ? 'happy-good' : economy.happiness === 0 ? 'happy-neutral' : 'happy-bad'; + const built = city.buildings.length ? city.buildings.map(key => CITY_BUILDINGS[key]?.name || key).join(', ') : 'None'; + const workedTiles = economy.workedTiles + .map(tile => `${tile.terrain} (${tile.yields.food}/${tile.yields.prod}/${tile.yields.sci})`) + .join(', '); + const buttons = Object.entries(CITY_BUILDINGS).map(([key, building]) => { + const isBuilt = city.buildings.includes(key); + const affordable = city.prod >= building.cost; + const disabled = isBuilt || !affordable ? ' disabled' : ''; + const label = isBuilt ? `${building.name} built` : `Build ${building.name} (${building.cost})`; + const hint = formatBuildingHint(building); + return ``; + }).join(''); + + dom.cityDet.innerHTML = ` +
+
${city.name}Pop ${city.pop}
+
+ Food${city.food}/${economy.foodRequired} + Prod bank${city.prod} + Happiness${economy.happiness} + Defense+${economy.defenseBonus} +
+
+${economy.totalYields.food} 🌾 +${economy.totalYields.prod} ⚒️ +${economy.totalYields.sci} 🔬 / turn
+

Buildings: ${built}

+

Worked: ${workedTiles}

+
${buttons}
+
`; +} + +function formatBuildingHint(building) { + const parts = []; + if (building.yields.food) parts.push(`+${building.yields.food} food`); + if (building.yields.prod) parts.push(`+${building.yields.prod} prod`); + if (building.yields.sci) parts.push(`+${building.yields.sci} science`); + if (building.amenities) parts.push(`+${building.amenities} amenity`); + if (building.defense) parts.push(`+${building.defense} defense`); + return parts.join(', '); +} + // ─── Tooltip ──────────────────────────────────────── function showTooltip(x, y, tileX, tileY) { if (tileX < 0 || tileX >= MAP_W || tileY < 0 || tileY >= MAP_H) { hideTooltip(); return; } @@ -797,13 +858,18 @@ 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 workedBy = S.cities + .filter(c => c.owner === 'player') + .find(c => calculateCityEconomy(c, S.map, TERRAIN_YIELD, S.cities.filter(city => city.owner === c.owner).length) + .workedTiles.some(tile => tile.x === tileX && tile.y === tileY)); tooltip.innerHTML = `
${terrain}${def ? ' (+' + def + '% def)' : ''}
🌾${yields.food} ⚒️${yields.prod} 🔬${yields.sci} -
`; + + ${workedBy ? `
Worked by ${workedBy.name}
` : ''}`; tooltip.style.display = 'block'; tooltip.style.left = (x + 16) + 'px'; tooltip.style.top = (y + 16) + 'px'; @@ -969,7 +1035,7 @@ 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'; Object.assign(cap, createCity(cap)); log(`Captured ${cap.name}!`, 'build'); updateCityPanel(); checkWin(); } }); } } @@ -1017,6 +1083,20 @@ document.getElementById('btnCenter').addEventListener('click', () => { const u = S.selected || S.units.find(u => u.owner === 'player'); if (u) centerOn(u.x, u.y); }); +dom.cityDet.addEventListener('click', e => { + const btn = e.target.closest('[data-building]'); + if (!btn) return; + const city = S.cities.find(c => c.owner === 'player'); + if (!city) return; + const result = buildBuilding(city, btn.dataset.building); + if (result.ok) { + Object.assign(city, result.city); + log(`${city.name} built ${CITY_BUILDINGS[btn.dataset.building].name}.`, 'build'); + } else { + log(`Cannot build ${CITY_BUILDINGS[btn.dataset.building]?.name || 'building'}: ${result.reason}.`, 'combat'); + } + updateCityPanel(); +}); // ─── Main loop ────────────────────────────────────── let lastTime = 0; diff --git a/demo/civ-lite/index.html b/demo/civ-lite/index.html index 163f984..caead79 100644 --- a/demo/civ-lite/index.html +++ b/demo/civ-lite/index.html @@ -24,6 +24,13 @@

Empire

🔬 Science 0
+
+

City Management

+
+

City details loading

+
+
+

Selected Unit

diff --git a/demo/civ-lite/styles.css b/demo/civ-lite/styles.css index 249777a..220bd09 100644 --- a/demo/civ-lite/styles.css +++ b/demo/civ-lite/styles.css @@ -108,6 +108,7 @@ main { #sidebar section { padding: 12px 14px; border-bottom: 1px solid var(--border); + flex-shrink: 0; } #sidebar h3 { @@ -137,6 +138,95 @@ main { .res-icon { font-size: 0.9rem; } +/* ───── City Management ──────────────────────────── */ +#cityInfo { + min-height: 148px; +} + +#cityDetails .placeholder { + color: var(--dim); + font-size: 0.78rem; + font-style: italic; +} + +.city-card { + display: grid; + gap: 8px; + font-size: 0.78rem; +} + +.city-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.city-title strong { + font-size: 0.9rem; +} + +.city-title span { + color: var(--gold); + font-weight: 700; +} + +.city-stat-grid { + display: grid; + grid-template-columns: 1fr auto; + gap: 3px 8px; +} + +.city-stat-grid span, +.city-meta { + color: var(--dim); +} + +.city-stat-grid strong { + color: var(--text); +} + +.happy-good { color: var(--accent2) !important; } +.happy-neutral { color: var(--gold) !important; } +.happy-bad { color: var(--danger) !important; } + +.city-yields { + padding: 6px 8px; + border: 1px solid rgba(48,54,61,0.7); + border-radius: 6px; + color: var(--text); + background: rgba(88,166,255,0.07); + line-height: 1.3; +} + +.city-meta { + line-height: 1.35; +} + +.building-actions { + display: grid; + gap: 6px; +} + +.building-actions button { + display: grid; + gap: 2px; + text-align: left; +} + +.building-actions button span { + color: var(--dim); + font-size: 0.68rem; + font-weight: 500; + line-height: 1.25; +} + +.building-actions button:disabled { + cursor: not-allowed; + opacity: 0.55; + box-shadow: none; +} + /* ───── Unit Info ────────────────────────────────── */ #unitInfo { min-height: 72px; @@ -299,6 +389,12 @@ button:active { gap: 2px; } +#tooltip .tt-worked { + margin-top: 5px; + color: var(--gold); + font-size: 0.68rem; +} + /* ───── Scrollbar ────────────────────────────────── */ ::-webkit-scrollbar { width: 5px; } ::-webkit-scrollbar-track { background: transparent; } From 5b8ec6133e398f21c3555d190c7b632656358dce Mon Sep 17 00:00:00 2001 From: YfengJ <166808804+YfengJ@users.noreply.github.com> Date: Tue, 30 Jun 2026 04:31:58 +0800 Subject: [PATCH 2/2] Clear city management Sonar warnings --- demo/civ-lite/city-model.js | 8 ++++---- demo/civ-lite/game.js | 13 ++++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/demo/civ-lite/city-model.js b/demo/civ-lite/city-model.js index 356e0b0..eb51217 100644 --- a/demo/civ-lite/city-model.js +++ b/demo/civ-lite/city-model.js @@ -30,14 +30,14 @@ export const CITY_BUILDINGS = { }; export function createCity(city) { + const { buildings = [], ...rest } = city; return { food: 0, prod: 0, pop: 1, - buildings: [], amenities: 0, - ...city, - buildings: [...(city.buildings || [])], + ...rest, + buildings: [...buildings], }; } @@ -102,7 +102,7 @@ function getWorkedTiles(city, map, terrainYield) { } function getTile(x, y, map, terrainYield) { - if (!map[y] || !map[y][x]) return null; + if (!map[y]?.[x]) return null; const terrain = map[y][x]; return { x, y, terrain, yields: { ...terrainYield[terrain] } }; } diff --git a/demo/civ-lite/game.js b/demo/civ-lite/game.js index 2ab4b65..005fe65 100644 --- a/demo/civ-lite/game.js +++ b/demo/civ-lite/game.js @@ -811,7 +811,12 @@ function updateCityPanel() { } const playerCityCount = S.cities.filter(c => c.owner === 'player').length; const economy = calculateCityEconomy(city, S.map, TERRAIN_YIELD, playerCityCount); - const happinessClass = economy.happiness >= 1 ? 'happy-good' : economy.happiness === 0 ? 'happy-neutral' : 'happy-bad'; + let happinessClass = 'happy-bad'; + if (economy.happiness >= 1) { + happinessClass = 'happy-good'; + } else if (economy.happiness === 0) { + happinessClass = 'happy-neutral'; + } const built = city.buildings.length ? city.buildings.map(key => CITY_BUILDINGS[key]?.name || key).join(', ') : 'None'; const workedTiles = economy.workedTiles .map(tile => `${tile.terrain} (${tile.yields.food}/${tile.yields.prod}/${tile.yields.sci})`) @@ -862,14 +867,16 @@ function showTooltip(x, y, tileX, tileY) { .filter(c => c.owner === 'player') .find(c => calculateCityEconomy(c, S.map, TERRAIN_YIELD, S.cities.filter(city => city.owner === c.owner).length) .workedTiles.some(tile => tile.x === tileX && tile.y === tileY)); + const defenseLabel = def ? ` (+${def}% def)` : ''; + const workedLabel = workedBy ? `
Worked by ${workedBy.name}
` : ''; tooltip.innerHTML = ` -
${terrain}${def ? ' (+' + def + '% def)' : ''}
+
${terrain}${defenseLabel}
🌾${yields.food} ⚒️${yields.prod} 🔬${yields.sci}
- ${workedBy ? `
Worked by ${workedBy.name}
` : ''}`; + ${workedLabel}`; tooltip.style.display = 'block'; tooltip.style.left = (x + 16) + 'px'; tooltip.style.top = (y + 16) + 'px';