From a33e18e359aaf46217afe3ab52fd94c89671543e Mon Sep 17 00:00:00 2001 From: YfengJ <166808804+YfengJ@users.noreply.github.com> Date: Tue, 30 Jun 2026 03:57:25 +0800 Subject: [PATCH 1/2] Add Civ Lite resources and trade --- demo/civ-lite/game.js | 191 +++++++++++++++++++-- demo/civ-lite/index.html | 14 ++ demo/civ-lite/resource-model.js | 238 ++++++++++++++++++++++++++ demo/civ-lite/resource-model.test.mjs | 110 ++++++++++++ demo/civ-lite/styles.css | 45 +++++ 5 files changed, 581 insertions(+), 17 deletions(-) create mode 100644 demo/civ-lite/resource-model.js create mode 100644 demo/civ-lite/resource-model.test.mjs diff --git a/demo/civ-lite/game.js b/demo/civ-lite/game.js index 2428a1f..cf17928 100644 --- a/demo/civ-lite/game.js +++ b/demo/civ-lite/game.js @@ -5,6 +5,16 @@ and Unciv tile groups. ───────────────────────────────────────────────────── */ import { buildSpriteAtlas } from './sprites.js'; +import { + activeTradeYield, + advanceTradeRoutes, + collectEmpireResources, + createTradeRoute, + formatYield, + generateResourceMap, + resourceAt, + summarizeResourceList, +} from './resource-model.js'; // ─── Constants ────────────────────────────────────── const MAP_W = 24, MAP_H = 16, TILE = 48; @@ -35,6 +45,10 @@ const dom = { food: document.getElementById('food'), prod: document.getElementById('prod'), science: document.getElementById('science'), + strategic: document.getElementById('strategicList'), + luxury: document.getElementById('luxuryList'), + tradeStatus: document.getElementById('tradeStatus'), + tradeBtn: document.getElementById('btnTrade'), unitDet: document.getElementById('unitDetails'), logBox: document.getElementById('logContent'), }; @@ -98,6 +112,9 @@ 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 }, ], + resources: [], + empireResources: { player: null, bot: null }, + tradeRoutes: [], turn: 1, phase: 'player', // player | bot | animating | gameover selected: null, @@ -116,6 +133,13 @@ const S = { tileVariants: [], }; +S.resources = generateResourceMap({ + map: S.map, + cities: S.cities, + width: MAP_W, + height: MAP_H, +}); + // Init fog for (let y = 0; y < MAP_H; y++) { S.fog[y] = []; @@ -148,6 +172,75 @@ function refreshVision() { } refreshVision(); +refreshEmpireResourceState(); +updateResourcePanel(); + +// ─── Resource & trade helpers ────────────────────── +function refreshEmpireResourceState() { + S.empireResources.player = collectEmpireResources({ + owner: 'player', + cities: S.cities, + map: S.map, + resources: S.resources, + terrainYield: TERRAIN_YIELD, + }); + S.empireResources.bot = collectEmpireResources({ + owner: 'bot', + cities: S.cities, + map: S.map, + resources: S.resources, + terrainYield: TERRAIN_YIELD, + }); +} + +function updateResourcePanel() { + const playerState = S.empireResources.player; + if (!playerState) return; + + dom.strategic.textContent = summarizeResourceList(playerState.categories.strategic); + dom.luxury.textContent = summarizeResourceList(playerState.categories.luxury); + + const activeRoutes = S.tradeRoutes.filter(route => route.turnsLeft > 0); + if (activeRoutes.length) { + const route = activeRoutes[0]; + dom.tradeStatus.textContent = `${route.exportResource} for ${route.importResource}: +${formatYield(route.playerYield)} for ${route.turnsLeft} turns.`; + dom.tradeBtn.disabled = true; + return; + } + + const route = createTradeRoute({ + playerState: S.empireResources.player, + botState: S.empireResources.bot, + turn: S.turn, + }); + dom.tradeBtn.disabled = route === null; + dom.tradeStatus.textContent = route + ? `Available: export ${route.exportResource} for ${route.importResource}.` + : 'Need distinct controlled resources for trade.'; +} + +function openTradeRoute() { + refreshEmpireResourceState(); + if (S.tradeRoutes.some(route => route.turnsLeft > 0)) { + updateResourcePanel(); + return; + } + + const route = createTradeRoute({ + playerState: S.empireResources.player, + botState: S.empireResources.bot, + turn: S.turn, + }); + if (!route) { + log('No resource trade is available yet.', 'trade'); + updateResourcePanel(); + return; + } + + S.tradeRoutes.push(route); + log(`Trade route opened: ${route.exportResource} for ${route.importResource}.`, 'trade'); + updateResourcePanel(); +} // ─── Pathfinding (A*) ─────────────────────────────── function heuristic(a, b) { return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); } @@ -285,22 +378,31 @@ function updateParticles() { // ─── City production ──────────────────────────────── function gatherResources(owner) { - const cities = S.cities.filter(c => c.owner === owner); - let totalFood = 0, totalProd = 0, totalSci = 0; - 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 gathered = collectEmpireResources({ + owner, + cities: S.cities, + map: S.map, + resources: S.resources, + terrainYield: TERRAIN_YIELD, + }); + S.empireResources[owner] = gathered; + + let totalFood = gathered.yields.food; + let totalProd = gathered.yields.prod; + let totalSci = gathered.yields.sci; + const tradeYield = activeTradeYield(S.tradeRoutes, owner); + totalFood += tradeYield.food; + totalProd += tradeYield.prod; + totalSci += tradeYield.sci; + + for (const { city, yields } of gathered.cityYields) { + city.food += yields.food; + city.prod += yields.prod; + + if (city === gathered.cityYields[0]?.city) { + city.food += tradeYield.food; + city.prod += tradeYield.prod; + } // City growth if (city.food >= 8 * city.pop) { @@ -327,6 +429,7 @@ function gatherResources(owner) { if (owner === 'player') revealAround(spawnX, spawnY); } } + updateResourcePanel(); return { food: totalFood, prod: totalProd, sci: totalSci }; } @@ -363,6 +466,12 @@ function startPlayerTurn() { // ─── Bot AI ───────────────────────────────────────── function botTurn() { gatherResources('bot'); + const routesBefore = S.tradeRoutes.length; + S.tradeRoutes = advanceTradeRoutes(S.tradeRoutes); + if (routesBefore > 0 && S.tradeRoutes.length === 0) { + log('Trade route completed.', 'trade'); + } + updateResourcePanel(); const bots = S.units.filter(u => u.owner === 'bot'); for (const bot of bots) { @@ -531,6 +640,39 @@ function drawLayerTerrain() { } } +// Layer 0.5: Strategic and luxury resources +function drawLayerResources() { + for (const resource of S.resources) { + if (S.fog[resource.y][resource.x] === 0) continue; + const px = resource.x * TILE + TILE / 2; + const py = resource.y * TILE + TILE / 2; + const radius = resource.category === 'luxury' ? 7 : 6; + + ctx.fillStyle = 'rgba(0,0,0,0.45)'; + ctx.beginPath(); + ctx.arc(px + 1, py + 1, radius + 2, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = resource.marker; + ctx.beginPath(); + if (resource.category === 'luxury') { + ctx.moveTo(px, py - radius); + ctx.lineTo(px + radius, py); + ctx.lineTo(px, py + radius); + ctx.lineTo(px - radius, py); + ctx.closePath(); + } else { + ctx.arc(px, py, radius, 0, Math.PI * 2); + } + ctx.fill(); + + ctx.fillStyle = '#07111f'; + ctx.font = `bold ${9 / S.zoom}px system-ui`; + ctx.textAlign = 'center'; + ctx.fillText(resource.name.charAt(0), px, py + 3 / S.zoom); + } +} + // Layer 1: Grid overlay function drawLayerGrid() { const vw = viewW(), vh = viewH(); @@ -738,6 +880,14 @@ function drawMinimap() { } } + for (const resource of S.resources) { + if (S.fog[resource.y][resource.x] === 0) continue; + miniCtx.fillStyle = resource.marker; + miniCtx.beginPath(); + miniCtx.arc(resource.x * tw + tw / 2, resource.y * th + th / 2, Math.max(tw, th) * 0.35, 0, Math.PI * 2); + miniCtx.fill(); + } + // Units & cities on minimap for (const city of S.cities) { miniCtx.fillStyle = city.owner === 'player' ? '#44aaff' : '#ff4444'; @@ -797,13 +947,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 resource = resourceAt(S.resources, tileX, tileY); + const resourceLine = resource + ? `
${resource.name} (${resource.category}) +${formatYield(resource.yield)}
` + : ''; tooltip.innerHTML = `
${terrain}${def ? ' (+' + def + '% def)' : ''}
🌾${yields.food} ⚒️${yields.prod} 🔬${yields.sci} -
`; + + ${resourceLine}`; tooltip.style.display = 'block'; tooltip.style.left = (x + 16) + 'px'; tooltip.style.top = (y + 16) + 'px'; @@ -1017,6 +1172,7 @@ document.getElementById('btnCenter').addEventListener('click', () => { const u = S.selected || S.units.find(u => u.owner === 'player'); if (u) centerOn(u.x, u.y); }); +dom.tradeBtn.addEventListener('click', openTradeRoute); // ─── Main loop ────────────────────────────────────── let lastTime = 0; @@ -1038,6 +1194,7 @@ function gameLoop(now) { ctx.scale(S.zoom, S.zoom); ctx.translate(-S.camX, -S.camY); drawLayerTerrain(); + drawLayerResources(); drawLayerGrid(); drawLayerReachable(); drawLayerPath(); diff --git a/demo/civ-lite/index.html b/demo/civ-lite/index.html index 163f984..36d85bc 100644 --- a/demo/civ-lite/index.html +++ b/demo/civ-lite/index.html @@ -24,6 +24,20 @@

Empire

🔬 Science 0
+
+

Resources & Trade

+
+ Strategic + None controlled +
+
+ Luxury + None controlled +
+ +

Find resources near cities to trade.

+
+

Selected Unit

diff --git a/demo/civ-lite/resource-model.js b/demo/civ-lite/resource-model.js new file mode 100644 index 0000000..fd4c9fd --- /dev/null +++ b/demo/civ-lite/resource-model.js @@ -0,0 +1,238 @@ +export const RESOURCE_DEFS = { + iron: { + key: 'iron', + name: 'Iron', + category: 'strategic', + terrains: ['hill', 'desert'], + yield: { food: 0, prod: 2, sci: 0 }, + marker: '#b7c0c7', + }, + horses: { + key: 'horses', + name: 'Horses', + category: 'strategic', + terrains: ['plains', 'forest'], + yield: { food: 1, prod: 1, sci: 0 }, + marker: '#c58b4a', + }, + gems: { + key: 'gems', + name: 'Gems', + category: 'luxury', + terrains: ['hill', 'desert'], + yield: { food: 0, prod: 0, sci: 2 }, + marker: '#c084fc', + }, + spices: { + key: 'spices', + name: 'Spices', + category: 'luxury', + terrains: ['plains', 'forest', 'desert'], + yield: { food: 1, prod: 0, sci: 1 }, + marker: '#f59e0b', + }, +}; + +const DEFAULT_TARGET_COUNT = 16; + +export function generateResourceMap({ map, cities, width, height, targetCount = DEFAULT_TARGET_COUNT }) { + const resources = []; + const occupied = new Set(cities.map(city => keyFor(city.x, city.y))); + const addResource = (defKey, x, y, source = 'scatter') => { + const def = RESOURCE_DEFS[defKey]; + if (!def || !canPlaceResource({ map, width, height, occupied, def, x, y })) return false; + resources.push({ + id: `${def.key}-${resources.length + 1}`, + key: def.key, + name: def.name, + category: def.category, + x, + y, + yield: { ...def.yield }, + marker: def.marker, + source, + }); + occupied.add(keyFor(x, y)); + return true; + }; + + for (const city of cities) { + const nearbyStrategic = city.owner === 'player' ? 'iron' : 'horses'; + const nearbyLuxury = city.owner === 'player' ? 'gems' : 'spices'; + placeNearCity({ map, width, height, city, defKey: nearbyStrategic, occupied, addResource }); + placeNearCity({ map, width, height, city, defKey: nearbyLuxury, occupied, addResource }); + } + + const resourceKeys = Object.keys(RESOURCE_DEFS); + const candidates = []; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (occupied.has(keyFor(x, y)) || map[y][x] === 'water') continue; + const score = hashResource(x, y, width, height); + candidates.push({ x, y, score }); + } + } + candidates.sort((a, b) => a.score - b.score); + + for (const candidate of candidates) { + if (resources.length >= targetCount) break; + const start = candidate.score % resourceKeys.length; + for (let i = 0; i < resourceKeys.length; i++) { + if (addResource(resourceKeys[(start + i) % resourceKeys.length], candidate.x, candidate.y)) break; + } + } + + return resources; +} + +export function resourceAt(resources, x, y) { + return resources.find(resource => resource.x === x && resource.y === y) ?? null; +} + +export function collectEmpireResources({ owner, cities, map, resources, terrainYield, radius = 1 }) { + const ownerCities = cities.filter(city => city.owner === owner); + const controlled = []; + const seen = new Set(); + const cityYields = []; + const totals = { food: 0, prod: 0, sci: 0 }; + + for (const city of ownerCities) { + const cityTotal = { food: 0, prod: 0, sci: 0 }; + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const x = city.x + dx; + const y = city.y + dy; + if (!map[y]?.[x]) continue; + addYield(cityTotal, terrainYield[map[y][x]] ?? emptyYield()); + const resource = resourceAt(resources, x, y); + if (!resource) continue; + addYield(cityTotal, resource.yield); + if (!seen.has(resource.id)) { + controlled.push(resource); + seen.add(resource.id); + } + } + } + addYield(totals, cityTotal); + cityYields.push({ city, yields: cityTotal }); + } + + const stock = {}; + for (const resource of controlled) { + stock[resource.key] = (stock[resource.key] ?? 0) + 1; + } + + return { + owner, + yields: totals, + cityYields, + controlled, + stock, + categories: { + strategic: controlled.filter(resource => resource.category === 'strategic'), + luxury: controlled.filter(resource => resource.category === 'luxury'), + }, + }; +} + +export function createTradeRoute({ playerState, botState, turn }) { + const exportResource = firstTradable(playerState, botState); + const importResource = firstTradable(botState, playerState); + if (!exportResource || !importResource) return null; + + const exportBonus = exportResource.category === 'luxury' ? { food: 1, prod: 0, sci: 2 } : { food: 0, prod: 2, sci: 0 }; + const importBonus = importResource.category === 'luxury' ? { food: 1, prod: 0, sci: 2 } : { food: 0, prod: 2, sci: 0 }; + const playerYield = addYield(addYield(emptyYield(), exportBonus), importBonus); + + return { + id: `trade-${turn}-${exportResource.key}-${importResource.key}`, + from: 'player', + to: 'bot', + exportKey: exportResource.key, + importKey: importResource.key, + exportResource: exportResource.name, + importResource: importResource.name, + turnsLeft: 3, + playerYield, + botYield: { food: 1, prod: 1, sci: 1 }, + }; +} + +export function activeTradeYield(routes, owner) { + const totals = emptyYield(); + const field = owner === 'player' ? 'playerYield' : 'botYield'; + for (const route of routes) { + if (route.turnsLeft > 0) addYield(totals, route[field] ?? emptyYield()); + } + return totals; +} + +export function advanceTradeRoutes(routes) { + return routes + .map(route => ({ ...route, turnsLeft: route.turnsLeft - 1 })) + .filter(route => route.turnsLeft > 0); +} + +export function summarizeResourceList(resources) { + if (resources.length === 0) return 'None controlled'; + return resources.map(resource => `${resource.name} +${formatYield(resource.yield)}`).join(', '); +} + +export function formatYield(yieldValue) { + const parts = []; + if (yieldValue.food) parts.push(`${yieldValue.food}F`); + if (yieldValue.prod) parts.push(`${yieldValue.prod}P`); + if (yieldValue.sci) parts.push(`${yieldValue.sci}S`); + return parts.length ? parts.join(' ') : '0'; +} + +function placeNearCity({ map, width, height, city, defKey, occupied, addResource }) { + const offsets = [ + [1, 0], [-1, 0], [0, 1], [0, -1], + [1, 1], [-1, -1], [2, 0], [0, 2], + [-2, 0], [0, -2], [2, 1], [-1, 2], + ]; + const ranked = offsets + .map(([dx, dy], idx) => ({ x: city.x + dx, y: city.y + dy, idx })) + .filter(({ x, y }) => x >= 0 && x < width && y >= 0 && y < height && !occupied.has(keyFor(x, y))) + .sort((a, b) => { + const terrainA = map[a.y][a.x]; + const terrainB = map[b.y][b.x]; + const def = RESOURCE_DEFS[defKey]; + const scoreA = def.terrains.includes(terrainA) ? a.idx : a.idx + 100; + const scoreB = def.terrains.includes(terrainB) ? b.idx : b.idx + 100; + return scoreA - scoreB; + }); + for (const tile of ranked) { + if (addResource(defKey, tile.x, tile.y, 'capital-ring')) return; + } +} + +function canPlaceResource({ map, width, height, occupied, def, x, y }) { + if (x < 0 || x >= width || y < 0 || y >= height) return false; + if (occupied.has(keyFor(x, y))) return false; + return def.terrains.includes(map[y][x]); +} + +function firstTradable(primaryState, otherState) { + return primaryState.controlled.find(resource => (otherState.stock?.[resource.key] ?? 0) === 0) ?? null; +} + +function addYield(target, source) { + target.food += source.food ?? 0; + target.prod += source.prod ?? 0; + target.sci += source.sci ?? 0; + return target; +} + +function emptyYield() { + return { food: 0, prod: 0, sci: 0 }; +} + +function keyFor(x, y) { + return `${x},${y}`; +} + +function hashResource(x, y, width, height) { + return ((x + 11) * 73856093 ^ (y + 17) * 19349663 ^ width * 83492791 ^ height * 2654435761) >>> 0; +} diff --git a/demo/civ-lite/resource-model.test.mjs b/demo/civ-lite/resource-model.test.mjs new file mode 100644 index 0000000..b809128 --- /dev/null +++ b/demo/civ-lite/resource-model.test.mjs @@ -0,0 +1,110 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + activeTradeYield, + advanceTradeRoutes, + collectEmpireResources, + createTradeRoute, + generateResourceMap, + resourceAt, +} from './resource-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 }, +}; + +function makeMap(width = 8, height = 6) { + return Array.from({ length: height }, (_, y) => + Array.from({ length: width }, (_, x) => { + if (x === 0 || y === 0) return 'water'; + if ((x + y) % 5 === 0) return 'hill'; + if ((x + y) % 4 === 0) return 'forest'; + if ((x + y) % 3 === 0) return 'desert'; + return 'plains'; + }), + ); +} + +test('generateResourceMap places deterministic strategic and luxury resources on valid non-city tiles', () => { + const map = makeMap(); + const cities = [ + { owner: 'player', x: 2, y: 2, name: 'Athens' }, + { owner: 'bot', x: 6, y: 4, name: 'Babylon' }, + ]; + + const resourcesA = generateResourceMap({ map, cities, width: 8, height: 6 }); + const resourcesB = generateResourceMap({ map, cities, width: 8, height: 6 }); + + assert.deepEqual(resourcesA, resourcesB); + assert.ok(resourcesA.some(resource => resource.category === 'strategic')); + assert.ok(resourcesA.some(resource => resource.category === 'luxury')); + + const cityKeys = new Set(cities.map(city => `${city.x},${city.y}`)); + for (const resource of resourcesA) { + assert.notEqual(map[resource.y][resource.x], 'water'); + assert.equal(cityKeys.has(`${resource.x},${resource.y}`), false); + assert.ok(resource.yield.food >= 0); + assert.ok(resource.yield.prod >= 0); + assert.ok(resource.yield.sci >= 0); + } +}); + +test('collectEmpireResources adds controlled resource yields and stock counts', () => { + const map = makeMap(); + const cities = [{ owner: 'player', x: 2, y: 2, name: 'Athens' }]; + const resources = [ + { id: 'iron-1', key: 'iron', name: 'Iron', category: 'strategic', x: 3, y: 2, yield: { food: 0, prod: 2, sci: 0 } }, + { id: 'gems-1', key: 'gems', name: 'Gems', category: 'luxury', x: 1, y: 2, yield: { food: 0, prod: 0, sci: 2 } }, + { id: 'spices-1', key: 'spices', name: 'Spices', category: 'luxury', x: 6, y: 5, yield: { food: 1, prod: 0, sci: 1 } }, + ]; + + const collected = collectEmpireResources({ owner: 'player', cities, map, resources, terrainYield }); + + assert.equal(resourceAt(resources, 3, 2).name, 'Iron'); + assert.equal(collected.controlled.length, 2); + assert.equal(collected.stock.iron, 1); + assert.equal(collected.stock.gems, 1); + assert.equal(collected.stock.spices ?? 0, 0); + assert.ok(collected.yields.prod >= 2); + assert.ok(collected.yields.sci >= 2); + assert.equal(collected.categories.strategic.length, 1); + assert.equal(collected.categories.luxury.length, 1); +}); + +test('createTradeRoute exchanges distinct controlled resources for recurring yield', () => { + const playerState = { + controlled: [{ key: 'gems', name: 'Gems', category: 'luxury' }], + stock: { gems: 1 }, + }; + const botState = { + controlled: [{ key: 'iron', name: 'Iron', category: 'strategic' }], + stock: { iron: 1 }, + }; + + const route = createTradeRoute({ playerState, botState, turn: 4 }); + + assert.equal(route.from, 'player'); + assert.equal(route.to, 'bot'); + assert.equal(route.exportResource, 'Gems'); + assert.equal(route.importResource, 'Iron'); + assert.equal(route.turnsLeft, 3); + assert.deepEqual(activeTradeYield([route], 'player'), { food: 1, prod: 2, sci: 2 }); +}); + +test('advanceTradeRoutes expires completed trade routes', () => { + const routes = [ + { id: 'a', turnsLeft: 2, playerYield: { food: 1, prod: 1, sci: 0 }, botYield: { food: 0, prod: 1, sci: 1 } }, + { id: 'b', turnsLeft: 1, playerYield: { food: 0, prod: 0, sci: 2 }, botYield: { food: 1, prod: 0, sci: 0 } }, + ]; + + const next = advanceTradeRoutes(routes); + + assert.deepEqual(next, [ + { id: 'a', turnsLeft: 1, playerYield: { food: 1, prod: 1, sci: 0 }, botYield: { food: 0, prod: 1, sci: 1 } }, + ]); +}); diff --git a/demo/civ-lite/styles.css b/demo/civ-lite/styles.css index 249777a..87583fc 100644 --- a/demo/civ-lite/styles.css +++ b/demo/civ-lite/styles.css @@ -263,6 +263,45 @@ button:active { #logContent .log-combat { color: var(--danger); } #logContent .log-build { color: var(--accent2); } #logContent .log-move { color: var(--accent); } +#logContent .log-trade { color: var(--gold); } + +/* ───── Resources & Trade ────────────────────────── */ +.trade-group { + display: grid; + gap: 3px; + padding: 3px 0 6px; +} + +.trade-label { + color: var(--dim); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.8px; +} + +.trade-list { + color: var(--text); + font-size: 0.74rem; + line-height: 1.35; +} + +#btnTrade { + margin-top: 4px; + width: 100%; +} + +#btnTrade:disabled { + cursor: default; + opacity: 0.55; + box-shadow: none; +} + +.trade-status { + margin-top: 6px; + color: var(--dim); + font-size: 0.72rem; + line-height: 1.35; +} /* ───── Tooltip ──────────────────────────────────── */ #tooltip { @@ -299,6 +338,12 @@ button:active { gap: 2px; } +#tooltip .tt-resource { + margin-top: 4px; + color: var(--gold); + font-size: 0.7rem; +} + /* ───── Scrollbar ────────────────────────────────── */ ::-webkit-scrollbar { width: 5px; } ::-webkit-scrollbar-track { background: transparent; } From 4ba973897bbbfdc430447e54e40bcd94f073f86e Mon Sep 17 00:00:00 2001 From: YfengJ <166808804+YfengJ@users.noreply.github.com> Date: Tue, 30 Jun 2026 04:00:42 +0800 Subject: [PATCH 2/2] Reduce resource model complexity --- demo/civ-lite/resource-model.js | 179 ++++++++++++++++++++------------ 1 file changed, 112 insertions(+), 67 deletions(-) diff --git a/demo/civ-lite/resource-model.js b/demo/civ-lite/resource-model.js index fd4c9fd..a57e01c 100644 --- a/demo/civ-lite/resource-model.js +++ b/demo/civ-lite/resource-model.js @@ -38,101 +38,146 @@ const DEFAULT_TARGET_COUNT = 16; export function generateResourceMap({ map, cities, width, height, targetCount = DEFAULT_TARGET_COUNT }) { const resources = []; const occupied = new Set(cities.map(city => keyFor(city.x, city.y))); - const addResource = (defKey, x, y, source = 'scatter') => { + const addResource = createResourceAdder({ map, width, height, occupied, resources }); + placeCapitalRingResources({ map, cities, width, height, occupied, addResource }); + scatterResources({ map, width, height, occupied, resources, addResource, targetCount }); + return resources; +} + +export function resourceAt(resources, x, y) { + return resources.find(resource => resource.x === x && resource.y === y) ?? null; +} + +export function collectEmpireResources({ owner, cities, map, resources, terrainYield, radius = 1 }) { + const controlled = []; + const seen = new Set(); + const cityYields = cities + .filter(city => city.owner === owner) + .map(city => collectCityResources({ city, map, resources, terrainYield, radius, controlled, seen })); + const totals = cityYields.reduce((acc, entry) => addYield(acc, entry.yields), emptyYield()); + + return { + owner, + yields: totals, + cityYields, + controlled, + stock: stockByResource(controlled), + categories: { + strategic: controlled.filter(resource => resource.category === 'strategic'), + luxury: controlled.filter(resource => resource.category === 'luxury'), + }, + }; +} + +function createResourceAdder({ map, width, height, occupied, resources }) { + return (defKey, x, y, source = 'scatter') => { const def = RESOURCE_DEFS[defKey]; if (!def || !canPlaceResource({ map, width, height, occupied, def, x, y })) return false; - resources.push({ - id: `${def.key}-${resources.length + 1}`, - key: def.key, - name: def.name, - category: def.category, - x, - y, - yield: { ...def.yield }, - marker: def.marker, - source, - }); + resources.push(makeResource(def, x, y, resources.length + 1, source)); occupied.add(keyFor(x, y)); return true; }; +} +function makeResource(def, x, y, index, source) { + return { + id: `${def.key}-${index}`, + key: def.key, + name: def.name, + category: def.category, + x, + y, + yield: { ...def.yield }, + marker: def.marker, + source, + }; +} + +function placeCapitalRingResources({ map, cities, width, height, occupied, addResource }) { for (const city of cities) { - const nearbyStrategic = city.owner === 'player' ? 'iron' : 'horses'; - const nearbyLuxury = city.owner === 'player' ? 'gems' : 'spices'; - placeNearCity({ map, width, height, city, defKey: nearbyStrategic, occupied, addResource }); - placeNearCity({ map, width, height, city, defKey: nearbyLuxury, occupied, addResource }); + placeNearCity({ + map, + width, + height, + city, + defKey: city.owner === 'player' ? 'iron' : 'horses', + occupied, + addResource, + }); + placeNearCity({ + map, + width, + height, + city, + defKey: city.owner === 'player' ? 'gems' : 'spices', + occupied, + addResource, + }); } +} +function scatterResources({ map, width, height, occupied, resources, addResource, targetCount }) { const resourceKeys = Object.keys(RESOURCE_DEFS); + for (const candidate of resourceCandidates({ map, width, height, occupied })) { + if (resources.length >= targetCount) break; + addFirstCompatibleResource({ candidate, resourceKeys, addResource }); + } +} + +function resourceCandidates({ map, width, height, occupied }) { const candidates = []; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { - if (occupied.has(keyFor(x, y)) || map[y][x] === 'water') continue; - const score = hashResource(x, y, width, height); - candidates.push({ x, y, score }); + if (!isResourceCandidate(map, occupied, x, y)) continue; + candidates.push({ x, y, score: hashResource(x, y, width, height) }); } } - candidates.sort((a, b) => a.score - b.score); + return candidates.sort((a, b) => a.score - b.score); +} - for (const candidate of candidates) { - if (resources.length >= targetCount) break; - const start = candidate.score % resourceKeys.length; - for (let i = 0; i < resourceKeys.length; i++) { - if (addResource(resourceKeys[(start + i) % resourceKeys.length], candidate.x, candidate.y)) break; - } +function addFirstCompatibleResource({ candidate, resourceKeys, addResource }) { + const start = candidate.score % resourceKeys.length; + for (let i = 0; i < resourceKeys.length; i++) { + if (addResource(resourceKeys[(start + i) % resourceKeys.length], candidate.x, candidate.y)) return; } +} - return resources; +function isResourceCandidate(map, occupied, x, y) { + return !occupied.has(keyFor(x, y)) && map[y][x] !== 'water'; } -export function resourceAt(resources, x, y) { - return resources.find(resource => resource.x === x && resource.y === y) ?? null; +function collectCityResources({ city, map, resources, terrainYield, radius, controlled, seen }) { + const yields = emptyYield(); + forEachCityTile(city, radius, ({ x, y }) => { + if (!map[y]?.[x]) return; + addYield(yields, terrainYield[map[y][x]] ?? emptyYield()); + const resource = resourceAt(resources, x, y); + if (resource) trackControlledResource({ resource, yields, controlled, seen }); + }); + return { city, yields }; } -export function collectEmpireResources({ owner, cities, map, resources, terrainYield, radius = 1 }) { - const ownerCities = cities.filter(city => city.owner === owner); - const controlled = []; - const seen = new Set(); - const cityYields = []; - const totals = { food: 0, prod: 0, sci: 0 }; - - for (const city of ownerCities) { - const cityTotal = { food: 0, prod: 0, sci: 0 }; - for (let dy = -radius; dy <= radius; dy++) { - for (let dx = -radius; dx <= radius; dx++) { - const x = city.x + dx; - const y = city.y + dy; - if (!map[y]?.[x]) continue; - addYield(cityTotal, terrainYield[map[y][x]] ?? emptyYield()); - const resource = resourceAt(resources, x, y); - if (!resource) continue; - addYield(cityTotal, resource.yield); - if (!seen.has(resource.id)) { - controlled.push(resource); - seen.add(resource.id); - } - } +function forEachCityTile(city, radius, visit) { + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + visit({ x: city.x + dx, y: city.y + dy }); } - addYield(totals, cityTotal); - cityYields.push({ city, yields: cityTotal }); } +} + +function trackControlledResource({ resource, yields, controlled, seen }) { + addYield(yields, resource.yield); + if (seen.has(resource.id)) return; + controlled.push(resource); + seen.add(resource.id); +} +function stockByResource(resources) { const stock = {}; - for (const resource of controlled) { + for (const resource of resources) { stock[resource.key] = (stock[resource.key] ?? 0) + 1; } - - return { - owner, - yields: totals, - cityYields, - controlled, - stock, - categories: { - strategic: controlled.filter(resource => resource.category === 'strategic'), - luxury: controlled.filter(resource => resource.category === 'luxury'), - }, - }; + return stock; } export function createTradeRoute({ playerState, botState, turn }) {