diff --git a/demo/civ-lite/game.js b/demo/civ-lite/game.js index 7b0dea2..464abe6 100644 --- a/demo/civ-lite/game.js +++ b/demo/civ-lite/game.js @@ -38,6 +38,13 @@ import { getUnitProfile, } from './combat-model.js'; import { buildSpriteAtlas } from './sprites.js'; +import { + createProductionQueue, + enqueueProduction, + evaluateVictory, + movementPlanForPath, + progressProductionQueue, +} from './production-model.js'; import { buildBuilding, calculateCityEconomy, @@ -90,11 +97,16 @@ const EDGE_SCROLL_ZONE = 30, EDGE_SCROLL_SPEED = 600; // px from edge, px/sec const PAN_SPEED = 500; // WASD px/sec (world units) const SIGHT = 2; const UNIT_STATS = { - warrior: { atk: 30, def: 15, mov: 2 }, - archer: { atk: 24, def: 12, mov: 2 }, - swordsman: { atk: 38, def: 18, mov: 2 }, - horseman: { atk: 34, def: 14, mov: 3 }, + warrior: { hp: 100, atk: 30, def: 15, mov: 2 }, + archer: { hp: 90, atk: 24, def: 12, mov: 2 }, + swordsman: { hp: 110, atk: 38, def: 18, mov: 2 }, + horseman: { hp: 95, atk: 34, def: 14, mov: 3 }, + scout: { hp: 80, atk: 18, def: 10, mov: 3 }, }; +const PRODUCTION_OPTIONS = [ + { id: 'warrior', label: 'Warrior', cost: 12, unitType: 'warrior' }, + { id: 'scout', label: 'Scout', cost: 8, unitType: 'scout' }, +]; const GAME_SEED = 'civ-lite-barbarian-events-21'; // ─── DOM refs ─────────────────────────────────────── @@ -141,6 +153,7 @@ const dom = { masterVolume: document.getElementById('masterVolume'), musicVolume: document.getElementById('musicVolume'), sfxVolume: document.getElementById('sfxVolume'), + prodPanel: document.getElementById('productionPanel'), menu: document.getElementById('mainMenu'), gameOver: document.getElementById('gameOver'), tutorial: document.getElementById('tutorialPanel'), @@ -295,7 +308,10 @@ const S = { diplomacy: buildDiplomacy(DEFAULT_CIVS), map: initialCiv.map, units: initialCiv.units, - cities: initialCiv.cities.map((city) => createCity(city)), + cities: initialCiv.cities.map((city) => ({ + ...createCity(city), + productionQueue: createProductionQueue(PRODUCTION_OPTIONS), + })), resources: initialCiv.resources, resourceTiles: [], empireResources: { player: null, bot: null }, @@ -331,6 +347,10 @@ const S = { tileVariants: [], }; +for (const city of S.cities) { + if (city.owner === 'player') enqueueProduction(city.productionQueue, 'warrior'); +} + function resetVisibilityState() { S.fog = []; S.tileVariants = []; @@ -739,6 +759,37 @@ function getShakeOffset(now) { } // ─── City production ──────────────────────────────── +function spawnUnitFromProduction(owner, city, item) { + const stats = UNIT_STATS[item.unitType] || UNIT_STATS.warrior; + const id = S.nextId++; + let spawnX = city.x, spawnY = city.y; + for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,-1]]) { + const nx = city.x + dx, ny = city.y + dy; + if (nx >= 0 && nx < MAP_W && ny >= 0 && ny < MAP_H && + !isWaterTerrain(S.map[ny][nx].terrain) && + !S.units.find(u => u.x === nx && u.y === ny)) { + spawnX = nx; spawnY = ny; break; + } + } + S.units.push({ + id, + owner, + x: spawnX, + y: spawnY, + hp: stats.hp, + atk: stats.atk, + def: stats.def, + mov: stats.mov, + movLeft: stats.mov, + type: item.unitType, + xp: 0, + level: 1, + }); + if (owner === 'player') playAudioEvent(AUDIO_EVENTS.build); + log(`${city.name} completed ${item.label}!`, 'build'); + if (owner === 'player') revealAround(spawnX, spawnY); +} + function gatherResources(owner) { S.empireResources[owner] = collectEmpireResources({ owner, @@ -779,41 +830,14 @@ function gatherResources(owner) { log(`${city.name} grows to pop ${city.pop}!`, 'build'); } - // 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; - // Find empty adjacent tile - for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,-1]]) { - const nx = city.x + dx, ny = city.y + dy; - if (nx >= 0 && nx < MAP_W && ny >= 0 && ny < MAP_H && - !isWaterTerrain(S.map[ny][nx].terrain) && - !S.units.find(u => u.x === nx && u.y === ny)) { - spawnX = nx; spawnY = ny; break; - } - } - const type = chooseUnitToTrain(owner); - const profile = getUnitProfile({ type }); - S.units.push({ - id, - owner, - x: spawnX, - y: spawnY, - hp: 100, - atk: profile.strength, - def: profile.defense, - mov: 2, - movLeft: 2, - type, - xp: 0, - level: 1, - }); - if (owner === 'player') playAudioEvent(AUDIO_EVENTS.build); - log(`${city.name} trained a ${type}!`, 'build'); - if (owner === 'player') revealAround(spawnX, spawnY); + if (owner === 'bot' && city.productionQueue.items.length === 0) { + enqueueProduction(city.productionQueue, 'warrior'); } + + const result = progressProductionQueue(city.productionQueue, city.prod); + city.productionQueue = result.queue; + city.prod = city.productionQueue.progress; + result.completed.forEach(item => spawnUnitFromProduction(owner, city, item)); } S.resources[owner].food += totalFood; S.resources[owner].prod += totalProd; @@ -1119,6 +1143,8 @@ function endPlayerTurn() { updateEmpireHud(); completeResearch('player', res.science); updateHud(); + updateCityPanel(); + updateProductionPanel(); log(`Income: +${res.food} food, +${res.prod} prod, +${res.science} science, +${res.gold} gold`, 'build'); setTimeout(botTurn, 400); @@ -1135,6 +1161,8 @@ function startPlayerTurn() { claimTerritory(S.map, S.cities); updateHud(); updateTechPanel(); + updateCityPanel(); + updateProductionPanel(); log(`─── Turn ${S.turn} ───`); maybeQueueRandomEvent(); renderEventPanel(); @@ -1193,7 +1221,10 @@ function botTurn() { addCaptureFeedback(cap); claimTerritory(S.map, S.cities); updateHud(); + updateCityPanel(); + updateProductionPanel(); log(`Bot captured ${cap.name}!`, 'combat'); + checkWin(); } continue; } @@ -1219,7 +1250,6 @@ function botTurn() { movLeft -= cost; steps++; } - bot.movLeft = movLeft; // Capture city const cap = S.cities.find(c => c.owner === 'player' && c.x === bot.x && c.y === bot.y); if (cap) { @@ -1227,7 +1257,10 @@ function botTurn() { addCaptureFeedback(cap); claimTerritory(S.map, S.cities); updateHud(); + updateCityPanel(); + updateProductionPanel(); log(`Bot captured ${cap.name}!`, 'combat'); + checkWin(); } } } @@ -1927,6 +1960,46 @@ function updateUnitPanel() { } } +// ─── Production queue panel ───────────────────────── +function updateProductionPanel() { + if (!dom.prodPanel) return; + const city = S.cities.find(c => c.owner === 'player'); + if (!city) { + dom.prodPanel.innerHTML = '

No city remains

'; + return; + } + + const current = city.productionQueue.items[0]; + const pct = current ? Math.min(100, Math.round((city.productionQueue.progress / current.cost) * 100)) : 0; + const queueText = city.productionQueue.items.length > 1 + ? city.productionQueue.items.slice(1).map(item => item.label).join(' → ') + : 'No queued follow-up'; + + dom.prodPanel.innerHTML = ` +
+ ${city.name} + Pop ${city.pop} +
+
+ ${current ? current.label : 'Idle'} + ${current ? `${city.productionQueue.progress}/${current.cost}` : '0/0'} +
+
+
+
+
Next: ${queueText}
+
Win: capture Babylon or remove all bot pieces.
`; +} + +function queueCityProduction(itemId) { + if (S.phase === 'gameover') return; + const city = S.cities.find(c => c.owner === 'player'); + if (!city) return; + const item = enqueueProduction(city.productionQueue, itemId).items.at(-1); + log(`${city.name} queued ${item.label}`, 'build'); + updateProductionPanel(); +} + // ─── City management panel ────────────────────────── function updateCityPanel() { const city = S.cities.find(c => c.owner === 'player'); @@ -2208,6 +2281,7 @@ function handleClick(tx, ty) { claimTerritory(S.map, S.cities); updateHud(); updateCityPanel(); + updateProductionPanel(); playAudioEvent(AUDIO_EVENTS.build); log(`Captured ${cap.name}!`, 'build'); checkWin(); @@ -2280,6 +2354,8 @@ document.getElementById('btnCenter').addEventListener('click', () => { centerOn(u.x, u.y); } }); +document.getElementById('btnQueueWarrior')?.addEventListener('click', () => queueCityProduction('warrior')); +document.getElementById('btnQueueScout')?.addEventListener('click', () => queueCityProduction('scout')); dom.btnStartSetup?.addEventListener('click', () => applyGameSetup()); renderSetupRoster(); dom.cityDet.addEventListener('click', e => { @@ -2295,6 +2371,7 @@ dom.cityDet.addEventListener('click', e => { log(`Cannot build ${CITY_BUILDINGS[btn.dataset.building]?.name || 'building'}: ${result.reason}.`, 'combat'); } updateCityPanel(); + updateProductionPanel(); }); dom.tradeBtn.addEventListener('click', openTradeRoute); document.getElementById('btnHelp').addEventListener('click', () => setTutorial()); @@ -2392,5 +2469,7 @@ function gameLoop(now) { updateEmpireHud(); renderEventPanel(); updateTechPanel(); + updateCityPanel(); + updateProductionPanel(); requestAnimationFrame(gameLoop); })(); diff --git a/demo/civ-lite/index.html b/demo/civ-lite/index.html index 5c67676..3cafe85 100644 --- a/demo/civ-lite/index.html +++ b/demo/civ-lite/index.html @@ -93,6 +93,15 @@

City Management

+
+

City Production

+
+
+ + +
+
+

Context

diff --git a/demo/civ-lite/production-model.js b/demo/civ-lite/production-model.js new file mode 100644 index 0000000..7a24449 --- /dev/null +++ b/demo/civ-lite/production-model.js @@ -0,0 +1,106 @@ +export const DEFAULT_PRODUCTION_OPTIONS = [ + { id: 'warrior', label: 'Warrior', cost: 12, unitType: 'warrior' }, + { id: 'scout', label: 'Scout', cost: 8, unitType: 'scout' }, +]; + +export function createProductionQueue(options = DEFAULT_PRODUCTION_OPTIONS) { + return { + options: options.map(option => ({ ...option })), + items: [], + progress: 0, + }; +} + +export function enqueueProduction(queue, itemId) { + const option = queue.options.find(candidate => candidate.id === itemId); + if (!option) throw new Error(`Unknown production item: ${itemId}`); + queue.items.push({ ...option }); + return queue; +} + +export function progressProductionQueue(queue, production) { + let available = Math.max(0, production); + const completed = []; + let nextQueue = { + options: queue.options.map(option => ({ ...option })), + items: queue.items.map(item => ({ ...item })), + progress: queue.progress, + }; + + while (available > 0 && nextQueue.items.length > 0) { + const current = nextQueue.items[0]; + const remaining = current.cost - nextQueue.progress; + if (available < remaining) { + nextQueue.progress += available; + available = 0; + } else { + available -= remaining; + completed.push(current); + nextQueue.items.shift(); + nextQueue.progress = 0; + } + } + + return { queue: nextQueue, completed }; +} + +export function movementPlanForPath(path, options) { + const steps = []; + let movementLeft = Math.max(0, options.movementLeft ?? 0); + let stopReason = null; + + for (let i = 1; i < path.length; i++) { + const tile = path[i]; + if (options.isBlocked?.(tile)) { + stopReason = 'blocked'; + break; + } + + const cost = options.costForTile(tile); + if (!Number.isFinite(cost) || cost <= 0) { + stopReason = 'impassable'; + break; + } + + if (cost > movementLeft) { + stopReason = 'movement'; + break; + } + + steps.push({ ...tile }); + movementLeft -= cost; + } + + return { steps, movementLeft, stopReason }; +} + +export function evaluateVictory(state) { + const playerPieces = countOwnedPieces(state, 'player'); + const botPieces = countOwnedPieces(state, 'bot'); + const playerCities = state.cities.filter(city => city.owner === 'player').length; + const botCities = state.cities.filter(city => city.owner === 'bot').length; + + if (botPieces === 0 && playerPieces > 0) { + return { phase: 'gameover', winner: 'player', reason: 'conquest' }; + } + + if (playerPieces === 0 && botPieces > 0) { + return { phase: 'gameover', winner: 'bot', reason: 'conquest' }; + } + + if (botCities === 0 && playerCities > 0) { + return { phase: 'gameover', winner: 'player', reason: 'domination' }; + } + + if (playerCities === 0 && botCities > 0) { + return { phase: 'gameover', winner: 'bot', reason: 'domination' }; + } + + return { phase: 'active', winner: null, reason: null }; +} + +function countOwnedPieces(state, owner) { + const units = state.units.filter(unit => unit.owner === owner).length; + const cities = state.cities.filter(city => city.owner === owner).length; + return units + cities; +} diff --git a/demo/civ-lite/production-model.test.mjs b/demo/civ-lite/production-model.test.mjs new file mode 100644 index 0000000..6c4a264 --- /dev/null +++ b/demo/civ-lite/production-model.test.mjs @@ -0,0 +1,94 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + createProductionQueue, + enqueueProduction, + progressProductionQueue, + movementPlanForPath, + evaluateVictory, +} from './production-model.js'; + +test('production queue completes the front item and carries overflow into the next item', () => { + const queue = createProductionQueue([ + { id: 'warrior', label: 'Warrior', cost: 12, unitType: 'warrior' }, + { id: 'scout', label: 'Scout', cost: 8, unitType: 'scout' }, + ]); + + const updatedQueue = enqueueProduction(queue, 'warrior'); + enqueueProduction(updatedQueue, 'scout'); + + const result = progressProductionQueue(updatedQueue, 15); + + assert.equal(result.completed.length, 1); + assert.equal(result.completed[0].id, 'warrior'); + assert.equal(result.queue.items[0].id, 'scout'); + assert.equal(result.queue.progress, 3); +}); + +test('movement plan spends terrain costs without entering blocked or unaffordable tiles', () => { + const path = [ + { x: 1, y: 1 }, + { x: 2, y: 1 }, + { x: 3, y: 1 }, + { x: 4, y: 1 }, + ]; + const costs = new Map([ + ['2,1', 1], + ['3,1', 2], + ['4,1', 1], + ]); + const occupied = new Set(['4,1']); + + const plan = movementPlanForPath(path, { + movementLeft: 3, + costForTile: tile => costs.get(`${tile.x},${tile.y}`), + isBlocked: tile => occupied.has(`${tile.x},${tile.y}`), + }); + + assert.deepEqual(plan.steps, [{ x: 2, y: 1 }, { x: 3, y: 1 }]); + assert.equal(plan.movementLeft, 0); + assert.equal(plan.stopReason, 'blocked'); +}); + +test('victory evaluation reports conquest and domination states', () => { + assert.deepEqual( + evaluateVictory({ + units: [{ owner: 'player' }], + cities: [{ owner: 'player' }], + }), + { phase: 'gameover', winner: 'player', reason: 'conquest' }, + ); + + assert.deepEqual( + evaluateVictory({ + units: [{ owner: 'bot' }], + cities: [{ owner: 'bot' }], + }), + { phase: 'gameover', winner: 'bot', reason: 'conquest' }, + ); + + assert.deepEqual( + evaluateVictory({ + units: [{ owner: 'player' }, { owner: 'bot' }], + cities: [{ owner: 'player' }], + }), + { phase: 'gameover', winner: 'player', reason: 'domination' }, + ); + + assert.deepEqual( + evaluateVictory({ + units: [{ owner: 'player' }, { owner: 'bot' }], + cities: [{ owner: 'bot' }], + }), + { phase: 'gameover', winner: 'bot', reason: 'domination' }, + ); + + assert.deepEqual( + evaluateVictory({ + units: [{ owner: 'player' }, { owner: 'bot' }], + cities: [{ owner: 'player' }, { owner: 'bot' }], + }), + { phase: 'active', winner: null, reason: null }, + ); +}); diff --git a/demo/civ-lite/styles.css b/demo/civ-lite/styles.css index 13cdd5c..181dccf 100644 --- a/demo/civ-lite/styles.css +++ b/demo/civ-lite/styles.css @@ -333,6 +333,64 @@ main { .stat-val.hp-mid { color: var(--gold); } .stat-val.hp-low { color: var(--danger); } +/* ───── City Production ─────────────────────────── */ +#cityProduction { + display: grid; + gap: 6px; + font-size: 0.76rem; +} + +.city-row, +.queue-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.city-name, +.queue-name { + font-weight: 700; + color: var(--text); +} + +.queue-meta { + color: var(--dim); + white-space: nowrap; +} + +.queue-track { + width: 100%; + height: 6px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 999px; + background: #0b111b; +} + +.queue-fill { + height: 100%; + width: var(--pct, 0%); + background: linear-gradient(90deg, var(--accent), var(--accent2)); +} + +.queue-list { + color: var(--dim); + line-height: 1.35; +} + +.production-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + margin-top: 8px; +} + +.production-actions button { + padding: 6px 8px; + font-size: 0.76rem; +} + /* ───── Technology ───────────────────────────────── */ #technology { max-height: 260px;