diff --git a/demo/civ-lite/civ-model.js b/demo/civ-lite/civ-model.js new file mode 100644 index 0000000..acd79b4 --- /dev/null +++ b/demo/civ-lite/civ-model.js @@ -0,0 +1,277 @@ +export const TERRAIN_TYPES = [ + 'ocean', + 'coast', + 'plains', + 'grassland', + 'forest', + 'jungle', + 'hill', + 'mountain', + 'desert', + 'tundra', + 'snow', +]; + +export const TERRAIN_YIELDS = { + ocean: { food: 1, prod: 0, science: 0, gold: 1 }, + coast: { food: 2, prod: 0, science: 0, gold: 1 }, + plains: { food: 1, prod: 2, science: 0, gold: 0 }, + grassland: { food: 2, prod: 1, science: 0, gold: 0 }, + forest: { food: 1, prod: 2, science: 0, gold: 0 }, + jungle: { food: 2, prod: 1, science: 1, gold: 0 }, + hill: { food: 0, prod: 2, science: 1, gold: 0 }, + mountain: { food: 0, prod: 1, science: 2, gold: 0 }, + desert: { food: 0, prod: 1, science: 0, gold: 1 }, + tundra: { food: 1, prod: 1, science: 1, gold: 0 }, + snow: { food: 0, prod: 1, science: 0, gold: 0 }, +}; + +export const TERRAIN_DEFENSE = { + ocean: 0, + coast: 0, + plains: 0, + grassland: 0, + forest: 0.25, + jungle: 0.2, + hill: 0.35, + mountain: 0.5, + desert: 0, + tundra: 0.1, + snow: 0.05, +}; + +export const RESOURCE_TYPES = { + wheat: { label: 'Wheat', icon: 'W', yield: { food: 1, prod: 0, science: 0, gold: 0 } }, + cattle: { label: 'Cattle', icon: 'C', yield: { food: 1, prod: 1, science: 0, gold: 0 } }, + fish: { label: 'Fish', icon: 'F', yield: { food: 2, prod: 0, science: 0, gold: 0 } }, + iron: { label: 'Iron', icon: 'Fe', yield: { food: 0, prod: 2, science: 0, gold: 0 } }, + gold: { label: 'Gold', icon: 'Au', yield: { food: 0, prod: 0, science: 0, gold: 2 } }, + deer: { label: 'Deer', icon: 'D', yield: { food: 1, prod: 1, science: 0, gold: 0 } }, +}; + +const RESOURCE_BY_TERRAIN = { + ocean: ['fish'], + coast: ['fish'], + plains: ['wheat', 'cattle'], + grassland: ['wheat', 'cattle'], + forest: ['deer'], + jungle: ['deer'], + hill: ['iron', 'gold'], + mountain: ['iron', 'gold'], + desert: ['gold'], + tundra: ['deer', 'iron'], + snow: ['iron'], +}; + +export const CIV_COLORS = { + player: '#44aaff', + bot: '#ff5555', +}; + +const DEFAULT_RESOURCES = { food: 0, prod: 0, science: 0, gold: 0 }; + +function mixSeed(seed, x, y, salt = 0) { + let n = (seed ^ (x * 374761393) ^ (y * 668265263) ^ (salt * 1274126177)) >>> 0; + n = Math.imul(n ^ (n >>> 15), 2246822519) >>> 0; + n = Math.imul(n ^ (n >>> 13), 3266489917) >>> 0; + return (n ^ (n >>> 16)) >>> 0; +} + +function rand(seed, x, y, salt = 0) { + return mixSeed(seed, x, y, salt) / 4294967295; +} + +function pickTerrain(width, height, x, y, seed) { + const nx = width <= 1 ? 0 : x / (width - 1); + const ny = height <= 1 ? 0 : y / (height - 1); + const edge = Math.min(nx, ny, 1 - nx, 1 - ny); + const latitude = Math.abs(ny - 0.5) * 2; + const elevation = rand(seed, x, y, 1); + const moisture = rand(seed, x, y, 2); + + if (edge < 0.07 || elevation < 0.09) return 'ocean'; + if (edge < 0.12 || elevation < 0.16) return 'coast'; + if (latitude > 0.88) return elevation > 0.72 ? 'mountain' : 'snow'; + if (latitude > 0.72) return moisture > 0.45 ? 'tundra' : 'snow'; + if (elevation > 0.88) return 'mountain'; + if (elevation > 0.74) return 'hill'; + if (moisture < 0.18 && latitude < 0.65) return 'desert'; + if (moisture > 0.78 && latitude < 0.55) return 'jungle'; + if (moisture > 0.58) return 'forest'; + return moisture > 0.36 ? 'grassland' : 'plains'; +} + +function maybeResource(terrain, seed, x, y) { + const options = RESOURCE_BY_TERRAIN[terrain] || []; + if (options.length === 0) return null; + const chance = rand(seed, x, y, 8); + if (chance > 0.22) return null; + return options[Math.floor(rand(seed, x, y, 9) * options.length)]; +} + +function makeTile(terrain, seed, x, y) { + const resource = maybeResource(terrain, seed, x, y); + return { + x, + y, + terrain, + resource, + yields: getTileYield({ terrain, resource }), + owner: null, + variant: mixSeed(seed, x, y, 14) % 4, + }; +} + +function forceTerrainCoverage(map, seed) { + for (let i = 0; i < TERRAIN_TYPES.length; i++) { + const y = Math.min(map.length - 1, Math.floor(i / Math.max(1, map[0].length))); + const x = i % map[0].length; + map[y][x] = makeTile(TERRAIN_TYPES[i], seed, x, y); + } +} + +function setTile(map, x, y, terrain, seed) { + if (!map[y] || !map[y][x]) return; + map[y][x] = makeTile(terrain, seed, x, y); +} + +export function isWaterTerrain(terrain) { + return terrain === 'ocean' || terrain === 'coast'; +} + +export function getTileYield(tile) { + const base = TERRAIN_YIELDS[tile.terrain] || DEFAULT_RESOURCES; + const resource = tile.resource ? RESOURCE_TYPES[tile.resource]?.yield : null; + return { + food: base.food + (resource?.food || 0), + prod: base.prod + (resource?.prod || 0), + science: base.science + (resource?.science || 0), + gold: base.gold + (resource?.gold || 0), + }; +} + +export function generateCivMap(width = 24, height = 16, seed = 42) { + const map = []; + for (let y = 0; y < height; y++) { + const row = []; + for (let x = 0; x < width; x++) { + row.push(makeTile(pickTerrain(width, height, x, y, seed), seed, x, y)); + } + map.push(row); + } + + forceTerrainCoverage(map, seed); + for (const [cx, cy] of [[2, 2], [width - 3, height - 3]]) { + setTile(map, cx, cy, 'grassland', seed); + setTile(map, cx + 1, cy, 'plains', seed); + setTile(map, cx, cy + 1, 'forest', seed); + } + + return map; +} + +export function claimTerritory(map, cities, radius = 2) { + for (const row of map) for (const tile of row) tile.owner = null; + for (const city of cities) { + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const tile = map[city.y + dy]?.[city.x + dx]; + if (tile && Math.abs(dx) + Math.abs(dy) <= radius + 1) tile.owner = city.owner; + } + } + } +} + +export function createCivState({ width = 24, height = 16, seed = 42 } = {}) { + const map = generateCivMap(width, height, seed); + const cities = [ + { owner: 'player', x: 2, y: 2, name: 'Athens', food: 0, prod: 0, pop: 1, production: 'Warrior' }, + { owner: 'bot', x: width - 3, y: height - 3, name: 'Babylon', food: 0, prod: 0, pop: 1, production: 'Warrior' }, + ]; + claimTerritory(map, cities); + + return { + map, + units: [ + { id: 1, owner: 'player', x: 2, y: 2, hp: 100, atk: 30, def: 15, mov: 2, movLeft: 2, type: 'warrior', sight: 2 }, + { id: 2, owner: 'bot', x: width - 3, y: height - 3, hp: 100, atk: 28, def: 18, mov: 2, movLeft: 2, type: 'warrior', sight: 2 }, + ], + cities, + resources: { + player: { food: 0, prod: 0, science: 0, gold: 0 }, + bot: { food: 0, prod: 0, science: 0, gold: 0 }, + }, + }; +} + +export function computeVisibility({ width, height, previousFog = [], units = [], cities = [], owner }) { + const fog = Array.from({ length: height }, (_, y) => + Array.from({ length: width }, (_, x) => (previousFog[y]?.[x] ? 1 : 0)) + ); + + const reveal = (cx, cy, radius) => { + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + const nx = cx + dx; + const ny = cy + dy; + if (nx >= 0 && nx < width && ny >= 0 && ny < height && Math.abs(dx) + Math.abs(dy) <= radius + 1) { + fog[ny][nx] = 2; + } + } + } + }; + + for (const unit of units.filter((u) => u.owner === owner)) reveal(unit.x, unit.y, unit.sight ?? 2); + for (const city of cities.filter((c) => c.owner === owner)) reveal(city.x, city.y, city.sight ?? 2); + return fog; +} + +function addYield(total, tile) { + const yields = getTileYield(tile); + total.food += yields.food; + total.prod += yields.prod; + total.science += yields.science; + total.gold += yields.gold; +} + +export function summarizeCityYield(state, city) { + const total = { ...DEFAULT_RESOURCES }; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const tile = state.map[city.y + dy]?.[city.x + dx]; + if (tile) addYield(total, tile); + } + } + total.food += city.pop; + total.prod += Math.max(0, city.pop - 1); + total.gold += city.owner === 'player' ? 1 : 0; + return total; +} + +export function summarizeEmpire(state, owner) { + const totals = { ...(state.resources?.[owner] || DEFAULT_RESOURCES) }; + const rates = { ...DEFAULT_RESOURCES }; + const ownedCities = state.cities.filter((city) => city.owner === owner); + + for (const city of ownedCities) { + const cityYield = summarizeCityYield(state, city); + rates.food += cityYield.food; + rates.prod += cityYield.prod; + rates.science += cityYield.science; + rates.gold += cityYield.gold; + } + + return { + totals, + rates, + cityCount: ownedCities.length, + unitCount: state.units.filter((unit) => unit.owner === owner).length, + notifications: [`Turn economy: ${ownedCities.length} cities, ${rates.food} food, ${rates.prod} prod`], + }; +} + +export function describeTile(tile) { + const terrain = tile.terrain.replace(/^\w/, (c) => c.toUpperCase()); + const resource = tile.resource ? ` + ${RESOURCE_TYPES[tile.resource].label}` : ''; + return `${terrain}${resource}`; +} diff --git a/demo/civ-lite/civ-model.test.mjs b/demo/civ-lite/civ-model.test.mjs new file mode 100644 index 0000000..52c42de --- /dev/null +++ b/demo/civ-lite/civ-model.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +import { + RESOURCE_TYPES, + TERRAIN_TYPES, + TERRAIN_YIELDS, + computeVisibility, + createCivState, + generateCivMap, + summarizeEmpire, +} from './civ-model.js'; +import { TERRAIN_FILES } from './sprites.js'; + +test('terrain model covers Civ biomes, resource overlays, and yields', () => { + const requiredTerrain = [ + 'ocean', + 'coast', + 'plains', + 'grassland', + 'forest', + 'jungle', + 'hill', + 'mountain', + 'desert', + 'tundra', + 'snow', + ]; + + assert.deepEqual(TERRAIN_TYPES, requiredTerrain); + assert.equal(RESOURCE_TYPES.wheat.yield.food, 1); + assert.equal(RESOURCE_TYPES.gold.yield.gold, 2); + + for (const terrain of requiredTerrain) { + assert.ok(TERRAIN_YIELDS[terrain], `${terrain} has yields`); + assert.equal(typeof TERRAIN_YIELDS[terrain].food, 'number'); + assert.equal(typeof TERRAIN_YIELDS[terrain].prod, 'number'); + assert.equal(typeof TERRAIN_YIELDS[terrain].science, 'number'); + assert.equal(typeof TERRAIN_YIELDS[terrain].gold, 'number'); + assert.ok(TERRAIN_FILES[terrain]?.length >= 1, `${terrain} has sprite sources`); + } +}); + +test('generated map has tile objects with plausible biome and resource variety', () => { + const map = generateCivMap(32, 24, 17); + const terrains = new Set(map.flat().map((tile) => tile.terrain)); + const resources = map.flat().filter((tile) => tile.resource); + + assert.equal(map.length, 24); + assert.equal(map[0].length, 32); + assert.ok(terrains.has('ocean')); + assert.ok(terrains.has('coast')); + assert.ok(terrains.has('grassland')); + assert.ok(terrains.has('jungle')); + assert.ok(terrains.has('tundra')); + assert.ok(resources.length >= 10); +}); + +test('visibility model keeps hidden, explored, and visible fog states separate', () => { + const previousFog = Array.from({ length: 5 }, () => Array(5).fill(0)); + previousFog[0][0] = 2; + const fog = computeVisibility({ + width: 5, + height: 5, + previousFog, + units: [{ owner: 'player', x: 2, y: 2, sight: 1 }], + cities: [{ owner: 'player', x: 4, y: 4, sight: 1 }], + owner: 'player', + }); + + assert.equal(fog[0][0], 1, 'previously visible tiles become explored'); + assert.equal(fog[2][2], 2, 'unit tile is visible'); + assert.equal(fog[4][4], 2, 'city tile is visible'); + assert.equal(fog[0][4], 0, 'unseen far tile remains hidden'); +}); + +test('empire summary feeds the 4X HUD with totals and per-turn rates', () => { + const state = createCivState({ width: 12, height: 8, seed: 3 }); + const summary = summarizeEmpire(state, 'player'); + + assert.ok(summary.totals.food >= 0); + assert.ok(summary.rates.food > 0); + assert.ok(summary.rates.prod > 0); + assert.ok(summary.rates.science >= 0); + assert.ok(summary.rates.gold >= 0); + assert.ok(summary.notifications.some((line) => line.includes('Turn'))); +}); + +test('Civ HUD markup exposes resource rates, minimap, contextual panel, and turn log', () => { + const html = fs.readFileSync(new URL('./index.html', import.meta.url), 'utf8'); + + for (const id of [ + 'foodRate', + 'prodRate', + 'scienceRate', + 'gold', + 'goldRate', + 'contextPanel', + 'turnState', + 'eventLog', + 'minimap', + ]) { + assert.match(html, new RegExp(`id="${id}"`)); + } +}); diff --git a/demo/civ-lite/game.js b/demo/civ-lite/game.js index c48c018..3faff81 100644 --- a/demo/civ-lite/game.js +++ b/demo/civ-lite/game.js @@ -4,6 +4,19 @@ Inspired by Freeciv-web layers, C7 terrain system, and Unciv tile groups. ───────────────────────────────────────────────────── */ +import { + CIV_COLORS, + RESOURCE_TYPES, + TERRAIN_DEFENSE, + claimTerritory, + computeVisibility, + createCivState, + describeTile, + getTileYield, + isWaterTerrain, + summarizeCityYield, + summarizeEmpire, +} from './civ-model.js'; import { applyCombatResult, calculateCombatPreview, @@ -25,15 +38,6 @@ const ZOOM_MIN = 0.4, ZOOM_MAX = 3, ZOOM_SPEED = 0.08; 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 TERRAIN_TYPES = ['plains', 'forest', 'hill', 'water', 'desert']; -const TERRAIN_YIELD = { - 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 TERRAIN_DEFENSE = { plains: 0, forest: 0.25, hill: 0.35, water: 0, desert: 0 }; // ─── DOM refs ─────────────────────────────────────── const canvas = document.getElementById('gameCanvas'); @@ -45,11 +49,17 @@ const tooltip = document.getElementById('tooltip'); const dom = { turn: document.getElementById('turnLabel'), status: document.getElementById('status'), + turnState: document.getElementById('turnState'), food: document.getElementById('food'), + foodRate: document.getElementById('foodRate'), prod: document.getElementById('prod'), + prodRate: document.getElementById('prodRate'), science: document.getElementById('science'), - unitDet: document.getElementById('unitDetails'), - logBox: document.getElementById('logContent'), + scienceRate: document.getElementById('scienceRate'), + gold: document.getElementById('gold'), + goldRate: document.getElementById('goldRate'), + unitDet: document.getElementById('contextPanel') || document.getElementById('unitDetails'), + logBox: document.getElementById('eventLog') || document.getElementById('logContent'), menu: document.getElementById('mainMenu'), gameOver: document.getElementById('gameOver'), tutorial: document.getElementById('tutorialPanel'), @@ -115,54 +125,18 @@ function restartGame() { // ─── Seeded RNG for tile variants ─────────────────── function hashTile(x, y) { return ((x * 374761393 + y * 668265263) ^ 1274126177) >>> 0; } -// ─── Map generation ───────────────────────────────── -function generateMap() { - const map = []; - for (let y = 0; y < MAP_H; y++) { - const row = []; - for (let x = 0; x < MAP_W; x++) { - const n = noise(x, y); - let t; - if (n < 0.2) t = 'water'; - else if (n < 0.38) t = 'plains'; - else if (n < 0.55) t = 'forest'; - else if (n < 0.7) t = 'hill'; - else t = 'desert'; - row.push(t); - } - map.push(row); - } - // Ensure starting positions are on land - for (let dy = -1; dy <= 1; dy++) - for (let dx = -1; dx <= 1; dx++) { - const y1 = 2 + dy, x1 = 2 + dx; - const y2 = MAP_H - 3 + dy, x2 = MAP_W - 3 + dx; - if (map[y1][x1] === 'water') map[y1][x1] = 'plains'; - if (map[y2][x2] === 'water') map[y2][x2] = 'plains'; - } - return map; -} - -function noise(x, y) { - const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453; - return s - Math.floor(s); -} - // ─── State ────────────────────────────────────────── +const initialCiv = createCivState({ width: MAP_W, height: MAP_H, seed: 17 }); const S = { - map: generateMap(), - units: [ - { id: 1, owner: 'player', x: 2, y: 2, hp: 100, atk: 29, def: 20, mov: 2, movLeft: 2, type: 'spearman', xp: 0, level: 1 }, - { id: 2, owner: 'bot', x: MAP_W - 3, y: MAP_H - 3, hp: 100, atk: 36, def: 17, mov: 3, movLeft: 3, type: 'cavalry', xp: 0, level: 1 }, - ], - 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 }, - ], + map: initialCiv.map, + units: initialCiv.units, + cities: initialCiv.cities, + resources: initialCiv.resources, turn: 1, phase: 'menu', // menu | player | bot | animating | gameover ux: createUxState(), selected: null, + selectedCity: null, fog: [], // 0=unknown, 1=seen, 2=visible camX: 0, camY: 0, zoom: 1, targetZoom: 1, @@ -204,9 +178,14 @@ function fadeVision() { } function refreshVision() { - fadeVision(); - S.units.filter(u => u.owner === 'player').forEach(u => revealAround(u.x, u.y)); - S.cities.filter(c => c.owner === 'player').forEach(c => revealAround(c.x, c.y)); + S.fog = computeVisibility({ + width: MAP_W, + height: MAP_H, + previousFog: S.fog, + units: S.units, + cities: S.cities, + owner: 'player', + }); } refreshVision(); @@ -219,17 +198,17 @@ function heuristic(a, b) { return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); } function neighbors(x, y) { const dirs = [[1,0],[-1,0],[0,1],[0,-1]]; return dirs.map(([dx, dy]) => ({ x: x + dx, y: y + dy })) - .filter(p => p.x >= 0 && p.x < MAP_W && p.y >= 0 && p.y < MAP_H && S.map[p.y][p.x] !== 'water'); + .filter(p => p.x >= 0 && p.x < MAP_W && p.y >= 0 && p.y < MAP_H && !isWaterTerrain(S.map[p.y][p.x].terrain)); } function moveCost(tx, ty) { - const t = S.map[ty][tx]; - if (t === 'hill' || t === 'forest') return 2; + const t = S.map[ty][tx].terrain; + if (t === 'hill' || t === 'forest' || t === 'jungle' || t === 'mountain' || t === 'snow') return 2; return 1; } function findPath(sx, sy, gx, gy) { - if (S.map[gy][gx] === 'water') return []; + if (isWaterTerrain(S.map[gy][gx].terrain)) return []; const key = (x, y) => x + ',' + y; const open = [{ x: sx, y: sy, g: 0, f: heuristic({ x: sx, y: sy }, { x: gx, y: gy }) }]; const came = {}, gScore = { [key(sx, sy)]: 0 }; @@ -277,13 +256,27 @@ function calcReachable(unit) { } // ─── Combat ───────────────────────────────────────── +// Adapt the civ-model tile-object map into the terrain-string grid that +// combat-model.js expects (its TERRAIN_COMBAT table keys on terrain strings, +// using 'water' for ocean/coast tiles). +function combatTerrainAt(x, y) { + const terrain = S.map[y]?.[x]?.terrain; + if (!terrain) return 'plains'; + return isWaterTerrain(terrain) ? 'water' : terrain; +} + +function combatTerrainMap() { + return S.map.map((row, y) => row.map((_, x) => combatTerrainAt(x, y))); +} + function crossesRiver(attacker, defender) { - return S.map[attacker.y]?.[defender.x] === 'water' || S.map[defender.y]?.[attacker.x] === 'water'; + return combatTerrainAt(defender.x, attacker.y) === 'water' + || combatTerrainAt(attacker.x, defender.y) === 'water'; } function previewCombat(attacker, defender) { return calculateCombatPreview(attacker, defender, { - map: S.map, + map: combatTerrainMap(), units: S.units, crossesRiver: crossesRiver(attacker, defender), }); @@ -361,21 +354,16 @@ function updateParticles() { // ─── City production ──────────────────────────────── function gatherResources(owner) { const cities = S.cities.filter(c => c.owner === owner); - let totalFood = 0, totalProd = 0, totalSci = 0; + let totalFood = 0, totalProd = 0, totalSci = 0, totalGold = 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 cityYield = summarizeCityYield(S, city); + totalFood += cityYield.food; + totalProd += cityYield.prod; + totalSci += cityYield.science; + totalGold += cityYield.gold; + + city.food += cityYield.food; + city.prod += cityYield.prod; // City growth if (city.food >= 8 * city.pop) { @@ -392,7 +380,7 @@ function gatherResources(owner) { 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 && - S.map[ny][nx] !== 'water' && + !isWaterTerrain(S.map[ny][nx].terrain) && !S.units.find(u => u.x === nx && u.y === ny)) { spawnX = nx; spawnY = ny; break; } @@ -417,7 +405,40 @@ function gatherResources(owner) { if (owner === 'player') revealAround(spawnX, spawnY); } } - return { food: totalFood, prod: totalProd, sci: totalSci }; + S.resources[owner].food += totalFood; + S.resources[owner].prod += totalProd; + S.resources[owner].science += totalSci; + S.resources[owner].gold += totalGold; + return { food: totalFood, prod: totalProd, science: totalSci, gold: totalGold }; +} + +function fmtRate(value) { + return `${value >= 0 ? '+' : ''}${value}`; +} + +function syncText(name, value) { + const el = dom[name] || document.getElementById(name); + if (el) el.textContent = value; + for (const mirror of document.querySelectorAll(`[data-source="${name}"]`)) { + mirror.textContent = value; + } +} + +function updateHud() { + const summary = summarizeEmpire(S, 'player'); + syncText('food', summary.totals.food); + syncText('prod', summary.totals.prod); + syncText('science', summary.totals.science); + syncText('gold', summary.totals.gold); + syncText('foodRate', fmtRate(summary.rates.food)); + syncText('prodRate', fmtRate(summary.rates.prod)); + syncText('scienceRate', fmtRate(summary.rates.science)); + syncText('goldRate', fmtRate(summary.rates.gold)); + if (dom.turnState) { + const cityLabel = summary.cityCount === 1 ? '1 city' : `${summary.cityCount} cities`; + const unitLabel = summary.unitCount === 1 ? '1 unit' : `${summary.unitCount} units`; + dom.turnState.textContent = `${cityLabel} · ${unitLabel}`; + } } // ─── Turn management ──────────────────────────────── @@ -425,6 +446,7 @@ function endPlayerTurn() { if (S.phase !== 'player') return; S.phase = 'bot'; S.selected = null; + S.selectedCity = null; S.reachable.clear(); S.path = []; dom.status.textContent = 'Bot thinking…'; @@ -432,9 +454,8 @@ function endPlayerTurn() { updateUnitPanel(); const res = gatherResources('player'); - dom.food.textContent = res.food; - dom.prod.textContent = res.prod; - dom.science.textContent = res.sci; + updateHud(); + log(`Income: +${res.food} food, +${res.prod} prod, +${res.science} science, +${res.gold} gold`, 'build'); setTimeout(botTurn, 400); } @@ -447,6 +468,8 @@ function startPlayerTurn() { dom.status.className = ''; S.units.filter(u => u.owner === 'player').forEach(u => { u.movLeft = u.mov; }); refreshVision(); + claimTerritory(S.map, S.cities); + updateHud(); log(`─── Turn ${S.turn} ───`); } @@ -476,7 +499,12 @@ function botTurn() { bot.movLeft = 0; // Check city capture const cap = S.cities.find(c => c.owner === 'player' && c.x === bot.x && c.y === bot.y); - if (cap) { cap.owner = 'bot'; log(`Bot captured ${cap.name}!`, 'combat'); } + if (cap) { + cap.owner = 'bot'; + claimTerritory(S.map, S.cities); + updateHud(); + log(`Bot captured ${cap.name}!`, 'combat'); + } continue; } @@ -503,7 +531,12 @@ function botTurn() { bot.movLeft = movLeft; // Capture city const cap = S.cities.find(c => c.owner === 'player' && c.x === bot.x && c.y === bot.y); - if (cap) { cap.owner = 'bot'; log(`Bot captured ${cap.name}!`, 'combat'); } + if (cap) { + cap.owner = 'bot'; + claimTerritory(S.map, S.cities); + updateHud(); + log(`Bot captured ${cap.name}!`, 'combat'); + } } } @@ -593,6 +626,56 @@ function smoothZoom(newZoom, pivotScreenX, pivotScreenY) { centerOn(2, 2); // ─── Rendering layers ─────────────────────────────── +function hexPath(context, px, py, inset = 2) { + const x = px + inset; + const y = py + inset; + const w = TILE - inset * 2; + const h = TILE - inset * 2; + context.beginPath(); + context.moveTo(x + w * 0.5, y); + context.lineTo(x + w, y + h * 0.24); + context.lineTo(x + w, y + h * 0.76); + context.lineTo(x + w * 0.5, y + h); + context.lineTo(x, y + h * 0.76); + context.lineTo(x, y + h * 0.24); + context.closePath(); +} + +function fillHex(px, py, fillStyle, inset = 2) { + ctx.fillStyle = fillStyle; + hexPath(ctx, px, py, inset); + ctx.fill(); +} + +function strokeHex(px, py, strokeStyle, lineWidth = 1, inset = 2) { + ctx.strokeStyle = strokeStyle; + ctx.lineWidth = lineWidth; + hexPath(ctx, px, py, inset); + ctx.stroke(); +} + +function drawResourceMarker(tile, px, py) { + if (!tile.resource) return; + const resource = RESOURCE_TYPES[tile.resource]; + if (!resource) return; + + const cx = px + TILE - 12; + const cy = py + 12; + ctx.fillStyle = 'rgba(7, 11, 20, 0.72)'; + ctx.beginPath(); + ctx.arc(cx, cy, 9, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(255, 236, 160, 0.85)'; + ctx.lineWidth = 1 / S.zoom; + ctx.stroke(); + ctx.fillStyle = '#ffe9a8'; + ctx.font = `bold ${8 / S.zoom}px system-ui`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(resource.icon, cx, cy + 0.5); + ctx.textBaseline = 'alphabetic'; +} + // Layer 0: Terrain function drawLayerTerrain() { const vw = viewW(), vh = viewH(); @@ -604,18 +687,32 @@ function drawLayerTerrain() { for (let y = startY; y <= endY; y++) { for (let x = startX; x <= endX; x++) { if (S.fog[y][x] === 0) continue; - const terrain = S.map[y][x]; - const variant = S.tileVariants[y][x]; - const sprite = ATLAS.terrain[terrain][variant]; + const tile = S.map[y][x]; + const terrain = tile.terrain; + const variant = tile.variant ?? S.tileVariants[y][x]; + const sprite = ATLAS.terrain[terrain]?.[variant % 4]; const px = x * TILE; const py = y * TILE; - ctx.drawImage(sprite, px, py, TILE, TILE); + ctx.save(); + hexPath(ctx, px, py, 1.5); + ctx.clip(); + if (sprite) ctx.drawImage(sprite, px, py, TILE, TILE); + else fillHex(px, py, '#2a3a2a', 0); - if (terrain === 'water') { + if (terrain === 'ocean' || terrain === 'coast') { const shimmer = Math.sin(S.waterPhase + x * 0.7 + y * 0.5) * 0.08 + 0.04; ctx.fillStyle = `rgba(120,200,255,${shimmer})`; ctx.fillRect(px, py, TILE, TILE); } + ctx.restore(); + + if (tile.owner) { + ctx.fillStyle = tile.owner === 'player' ? 'rgba(68, 170, 255, 0.12)' : 'rgba(255, 85, 85, 0.12)'; + hexPath(ctx, px, py, 3); + ctx.fill(); + } + drawResourceMarker(tile, px, py); + strokeHex(px, py, terrain === 'coast' ? 'rgba(180,220,255,0.22)' : 'rgba(200,220,255,0.08)', 0.75 / S.zoom, 1.5); } } } @@ -633,7 +730,7 @@ function drawLayerGrid() { for (let y = startY; y <= endY; y++) { for (let x = startX; x <= endX; x++) { if (S.fog[y][x] === 0) continue; - ctx.strokeRect(x * TILE, y * TILE, TILE, TILE); + strokeHex(x * TILE, y * TILE, 'rgba(200,220,255,0.06)', 0.5 / S.zoom, 2); } } } @@ -641,10 +738,16 @@ function drawLayerGrid() { // Layer 2: Reachable tile highlights function drawLayerReachable() { if (!S.selected || S.reachable.size === 0) return; - ctx.fillStyle = 'rgba(90,200,250,0.12)'; for (const key of S.reachable) { const [x, y] = key.split(',').map(Number); - ctx.fillRect(x * TILE, y * TILE, TILE, TILE); + fillHex(x * TILE, y * TILE, 'rgba(90,200,250,0.12)', 4); + } + for (const enemy of S.units.filter(u => u.owner !== S.selected.owner)) { + const dist = Math.abs(enemy.x - S.selected.x) + Math.abs(enemy.y - S.selected.y); + if (dist <= 1) { + fillHex(enemy.x * TILE, enemy.y * TILE, 'rgba(255,80,80,0.18)', 4); + strokeHex(enemy.x * TILE, enemy.y * TILE, 'rgba(255,120,120,0.55)', 1.5 / S.zoom, 4); + } } } @@ -682,12 +785,18 @@ function drawLayerCities() { const sprite = city.owner === 'player' ? ATLAS.cities.player : ATLAS.cities.bot; ctx.drawImage(sprite, px - 28, py - 28, 56, 56); - ctx.font = `bold ${9 / S.zoom}px system-ui`; - ctx.fillStyle = city.owner === 'player' ? '#44aaff' : '#ff5555'; + const bannerColor = city.owner === 'player' ? CIV_COLORS.player : CIV_COLORS.bot; + ctx.fillStyle = 'rgba(7,11,20,0.78)'; + ctx.fillRect(px - 30, py + 19, 60, 13); + ctx.strokeStyle = bannerColor; + ctx.lineWidth = 1 / S.zoom; + ctx.strokeRect(px - 30, py + 19, 60, 13); + ctx.font = `bold ${8 / S.zoom}px system-ui`; + ctx.fillStyle = bannerColor; ctx.textAlign = 'center'; - ctx.fillText(city.name, px, py + 28); + ctx.fillText(city.name, px, py + 29); - ctx.fillStyle = '#222'; + ctx.fillStyle = 'rgba(7,11,20,0.86)'; ctx.beginPath(); ctx.arc(px + 18, py - 18, 7, 0, Math.PI * 2); ctx.fill(); @@ -705,11 +814,20 @@ function drawLayerUnits() { if (S.fog[unit.y][unit.x] === 0) continue; if (unit.owner === 'bot' && S.fog[unit.y][unit.x] < 2) continue; - const sprite = unit.owner === 'player' ? ATLAS.units.player : ATLAS.units.bot; + const side = unit.owner === 'player' ? 'player' : 'bot'; + const sprite = ATLAS.unitTypes?.[unit.type]?.[side] || (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); + ctx.fillStyle = unit.owner === 'player' ? CIV_COLORS.player : CIV_COLORS.bot; + ctx.beginPath(); + ctx.arc(px + TILE - 8, py + 8, 5, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.8)'; + ctx.lineWidth = 1 / S.zoom; + ctx.stroke(); + // HP bar drawHPBar(px + 4, py + TILE - 6, TILE - 8, 3, unit.hp / 100); @@ -730,7 +848,8 @@ function drawLayerUnits() { const ease = 1 - Math.pow(1 - a.t, 3); const px = a.from.x + (a.to.x - a.from.x) * ease; const py = a.from.y + (a.to.y - a.from.y) * ease; - const sprite = a.unit.owner === 'player' ? ATLAS.units.player : ATLAS.units.bot; + const side = a.unit.owner === 'player' ? 'player' : 'bot'; + const sprite = ATLAS.unitTypes?.[a.unit.type]?.[side] || (a.unit.owner === 'player' ? ATLAS.units.player : ATLAS.units.bot); ctx.drawImage(sprite, px, py, TILE, TILE); drawHPBar(px + 4, py + TILE - 6, TILE - 8, 3, a.unit.hp / 100); } @@ -808,8 +927,17 @@ function drawMinimap() { miniCtx.fillRect(0, 0, mw, mh); const terrainColors = { - plains: '#4d8f52', forest: '#2a6e3a', hill: '#8a7d50', - water: '#1e5799', desert: '#c9a855' + ocean: '#1e5799', + coast: '#2f80c9', + plains: '#7f9b45', + grassland: '#4d8f52', + forest: '#2a6e3a', + jungle: '#1d6b42', + hill: '#8a7d50', + mountain: '#7d8490', + desert: '#c9a855', + tundra: '#79927a', + snow: '#dce8ed', }; for (let y = 0; y < MAP_H; y++) { @@ -817,13 +945,19 @@ function drawMinimap() { if (S.fog[y][x] === 0) { miniCtx.fillStyle = '#111'; } else { - miniCtx.fillStyle = terrainColors[S.map[y][x]]; + const tile = S.map[y][x]; + miniCtx.fillStyle = terrainColors[tile.terrain] || '#555'; if (S.fog[y][x] === 1) { miniCtx.globalAlpha = 0.5; } } miniCtx.fillRect(x * tw, y * th, tw + 0.5, th + 0.5); miniCtx.globalAlpha = 1; + const owner = S.map[y][x].owner; + if (owner) { + miniCtx.fillStyle = owner === 'player' ? 'rgba(68,170,255,0.35)' : 'rgba(255,85,85,0.35)'; + miniCtx.fillRect(x * tw, y * th, tw + 0.5, th + 0.5); + } } } @@ -854,8 +988,26 @@ function drawMinimap() { // ─── Unit info panel ──────────────────────────────── function updateUnitPanel() { const u = S.selected; + const city = S.selectedCity; if (!u) { - dom.unitDet.innerHTML = '
Click a unit to see details
'; + if (city) { + const yields = summarizeCityYield(S, city); + dom.unitDet.innerHTML = ` +Click a unit or city to see details
'; return; } const profile = getUnitProfile(u); @@ -878,7 +1030,8 @@ function updateUnitPanel() { const ic = document.getElementById('unitIcon'); if (ic) { const ictx = ic.getContext('2d'); - const sprite = u.owner === 'player' ? ATLAS.units.player : ATLAS.units.bot; + const side = u.owner === 'player' ? 'player' : 'bot'; + const sprite = ATLAS.unitTypes?.[u.type]?.[side] || (u.owner === 'player' ? ATLAS.units.player : ATLAS.units.bot); ictx.drawImage(sprite, 0, 0, 32, 32); } } @@ -887,8 +1040,9 @@ function updateUnitPanel() { function showTooltip(x, y, tileX, tileY) { if (tileX < 0 || tileX >= MAP_W || tileY < 0 || tileY >= MAP_H) { hideTooltip(); return; } if (S.fog[tileY][tileX] === 0) { hideTooltip(); return; } - const terrain = S.map[tileY][tileX]; - const yields = TERRAIN_YIELD[terrain]; + const tile = S.map[tileY][tileX]; + const terrain = tile.terrain; + const yields = getTileYield(tile); const def = Math.round(TERRAIN_DEFENSE[terrain] * 100); const target = S.units.find(u => u.owner !== 'player' && u.x === tileX && u.y === tileY); const preview = S.selected && target ? previewCombat(S.selected, target) : null; @@ -902,11 +1056,12 @@ function showTooltip(x, y, tileX, tileY) { ${modifierLabels} ` : ''; tooltip.innerHTML = ` -