diff --git a/demo/civ-lite/game.js b/demo/civ-lite/game.js index 3faff81..1fcc3f2 100644 --- a/demo/civ-lite/game.js +++ b/demo/civ-lite/game.js @@ -24,6 +24,17 @@ import { getUnitProfile, } from './combat-model.js'; import { buildSpriteAtlas } from './sprites.js'; +import { + TECH_TREE, + advanceResearch, + chooseBotResearch, + createResearchState, + getAvailableTechs, + getTech, + getTechStatus, + getUnlockedContent, + selectResearch, +} from './tech-model.js'; import { createUxState, describeOutcome, @@ -38,6 +49,12 @@ 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 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 }, +}; // ─── DOM refs ─────────────────────────────────────── const canvas = document.getElementById('gameCanvas'); @@ -58,6 +75,8 @@ const dom = { scienceRate: document.getElementById('scienceRate'), gold: document.getElementById('gold'), goldRate: document.getElementById('goldRate'), + currentTech: document.getElementById('currentTech'), + techList: document.getElementById('techList'), unitDet: document.getElementById('contextPanel') || document.getElementById('unitDetails'), logBox: document.getElementById('eventLog') || document.getElementById('logContent'), menu: document.getElementById('mainMenu'), @@ -141,6 +160,11 @@ const S = { camX: 0, camY: 0, zoom: 1, targetZoom: 1, nextId: 3, + research: { + player: selectResearch(createResearchState(), 'mining'), + bot: createResearchState(), + }, + botPersonality: 'military', // Visual state particles: [], waterPhase: 0, @@ -152,6 +176,11 @@ const S = { tileVariants: [], }; +function createUnit(id, owner, x, y, type = 'warrior') { + const stats = UNIT_STATS[type] || UNIT_STATS.warrior; + return { id, owner, x, y, hp: 100, atk: stats.atk, def: stats.def, mov: stats.mov, movLeft: stats.mov, type }; +} + // Init fog for (let y = 0; y < MAP_H; y++) { S.fog[y] = []; @@ -385,7 +414,7 @@ function gatherResources(owner) { spawnX = nx; spawnY = ny; break; } } - const type = owner === 'player' && id % 3 === 0 ? 'archer' : 'warrior'; + const type = chooseUnitToTrain(owner); const profile = getUnitProfile({ type }); S.units.push({ id, @@ -441,6 +470,85 @@ function updateHud() { } } +function chooseUnitToTrain(owner) { + const unlocks = getUnlockedContent(S.research[owner]); + if (unlocks.units.includes('horseman')) return 'horseman'; + if (unlocks.units.includes('swordsman')) return 'swordsman'; + if (unlocks.units.includes('archer')) return 'archer'; + return 'warrior'; +} + +function completeResearch(owner, sciencePerTurn) { + if (owner === 'bot' && !S.research.bot.current) { + const next = chooseBotResearch(S.research.bot, S.botPersonality); + if (next) S.research.bot = selectResearch(S.research.bot, next); + } + + const before = S.research[owner].current; + const result = advanceResearch(S.research[owner], sciencePerTurn); + S.research[owner] = result.state; + + if (result.completed.length > 0) { + for (const techId of result.completed) { + const tech = getTech(techId); + log(`${owner === 'player' ? 'You' : 'Bot'} researched ${tech.name}!`, 'build'); + } + if (owner === 'player') updateTechPanel(); + } else if (before && owner === 'player') { + updateTechPanel(); + } + + if (owner === 'bot' && !S.research.bot.current) { + const next = chooseBotResearch(S.research.bot, S.botPersonality); + if (next) S.research.bot = selectResearch(S.research.bot, next); + } +} + +function researchProgressLabel(state) { + if (!state.current) return 'Choose research'; + const tech = getTech(state.current); + return `${tech.name}: ${state.progress}/${tech.cost} 🔬`; +} + +function updateTechPanel() { + if (!dom.currentTech || !dom.techList) return; + dom.currentTech.textContent = researchProgressLabel(S.research.player); + dom.techList.innerHTML = ''; + + for (const tech of TECH_TREE) { + const status = getTechStatus(S.research.player, tech.id); + const row = document.createElement('button'); + row.type = 'button'; + row.className = `tech-card tech-${status}`; + row.disabled = status === 'blocked' || status === 'researched'; + row.dataset.tech = tech.id; + row.innerHTML = ` + ${tech.name} + ${status.replace('-', ' ')} + 🔬 ${tech.cost} + ${formatUnlocks(tech.unlocks)}`; + row.addEventListener('click', () => { + try { + S.research.player = selectResearch(S.research.player, tech.id); + log(`Research started: ${tech.name}`, 'build'); + updateTechPanel(); + } catch (error) { + log(error.message, 'combat'); + } + }); + dom.techList.append(row); + } +} + +function formatUnlocks(unlocks) { + const parts = [ + ...(unlocks.units || []).map((item) => `Unit: ${item}`), + ...(unlocks.buildings || []).map((item) => `Building: ${item}`), + ...(unlocks.improvements || []).map((item) => `Improvement: ${item}`), + ]; + return parts.join(' · ') || 'Economy'; +} + // ─── Turn management ──────────────────────────────── function endPlayerTurn() { if (S.phase !== 'player') return; @@ -454,6 +562,7 @@ function endPlayerTurn() { updateUnitPanel(); const res = gatherResources('player'); + completeResearch('player', res.science); updateHud(); log(`Income: +${res.food} food, +${res.prod} prod, +${res.science} science, +${res.gold} gold`, 'build'); @@ -470,12 +579,14 @@ function startPlayerTurn() { refreshVision(); claimTerritory(S.map, S.cities); updateHud(); + updateTechPanel(); log(`─── Turn ${S.turn} ───`); } // ─── Bot AI ───────────────────────────────────────── function botTurn() { - gatherResources('bot'); + const botRes = gatherResources('bot'); + completeResearch('bot', botRes.science); const bots = S.units.filter(u => u.owner === 'bot'); for (const bot of bots) { @@ -1384,5 +1495,6 @@ function gameLoop(now) { updateHud(); updateUnitPanel(); log('Sprites loaded — game starting!', 'good'); + updateTechPanel(); requestAnimationFrame(gameLoop); })(); diff --git a/demo/civ-lite/index.html b/demo/civ-lite/index.html index 1e4edc2..6634c98 100644 --- a/demo/civ-lite/index.html +++ b/demo/civ-lite/index.html @@ -40,6 +40,12 @@

Context

+
+

Technology

+
Choose research
+
+
+
diff --git a/demo/civ-lite/styles.css b/demo/civ-lite/styles.css index c6eb534..1672c7b 100644 --- a/demo/civ-lite/styles.css +++ b/demo/civ-lite/styles.css @@ -243,6 +243,80 @@ main { .stat-val.hp-mid { color: var(--gold); } .stat-val.hp-low { color: var(--danger); } +/* ───── Technology ───────────────────────────────── */ +#technology { + max-height: 260px; + overflow-y: auto; +} + +#currentTech { + padding: 7px 8px; + margin-bottom: 8px; + border: 1px solid rgba(88,166,255,0.24); + border-radius: 6px; + background: rgba(88,166,255,0.08); + color: var(--accent); + font-size: 0.76rem; + font-weight: 700; +} + +#techList { + display: grid; + gap: 6px; +} + +.tech-card { + display: grid; + grid-template-columns: 1fr auto; + gap: 2px 8px; + width: 100%; + padding: 7px 8px; + text-align: left; + border-radius: 6px; +} + +.tech-name { + color: var(--text); + font-size: 0.76rem; + font-weight: 700; +} + +.tech-status, +.tech-cost, +.tech-unlocks { + color: var(--dim); + font-size: 0.64rem; + text-transform: capitalize; +} + +.tech-cost { + text-align: right; + color: var(--gold); +} + +.tech-unlocks { + grid-column: 1 / -1; + text-transform: none; +} + +.tech-available { + border-color: rgba(63,185,80,0.35); +} + +.tech-in-progress { + border-color: rgba(210,153,34,0.7); + box-shadow: inset 0 0 0 1px rgba(210,153,34,0.22); +} + +.tech-researched { + opacity: 0.76; + background: linear-gradient(180deg, rgba(45,90,45,0.65), rgba(26,51,26,0.75)); +} + +.tech-blocked { + opacity: 0.48; +} + /* ───── Actions ──────────────────────────────────── */ #actions { display: flex; diff --git a/demo/civ-lite/tech-model.js b/demo/civ-lite/tech-model.js new file mode 100644 index 0000000..4081a21 --- /dev/null +++ b/demo/civ-lite/tech-model.js @@ -0,0 +1,157 @@ +export const TECH_TREE = [ + { + id: 'mining', + name: 'Mining', + cost: 10, + prereqs: [], + unlocks: { units: [], buildings: [], improvements: ['mine'] }, + flavor: 'Reveals productive hills and enables mine improvements.', + }, + { + id: 'archery', + name: 'Archery', + cost: 12, + prereqs: [], + unlocks: { units: ['archer'], buildings: [], improvements: [] }, + flavor: 'Unlocks ranged city defense and early skirmishers.', + }, + { + id: 'writing', + name: 'Writing', + cost: 14, + prereqs: [], + unlocks: { units: [], buildings: ['library'], improvements: [] }, + flavor: 'Turns science income into a focused research economy.', + }, + { + id: 'bronze_working', + name: 'Bronze Working', + cost: 16, + prereqs: ['mining'], + unlocks: { units: ['swordsman'], buildings: [], improvements: [] }, + flavor: 'Unlocks swordsmen for stronger melee assaults.', + }, + { + id: 'horseback_riding', + name: 'Horseback Riding', + cost: 18, + prereqs: ['archery'], + unlocks: { units: ['horseman'], buildings: [], improvements: ['pasture'] }, + flavor: 'Unlocks fast cavalry and pasture economies.', + }, + { + id: 'engineering', + name: 'Engineering', + cost: 22, + prereqs: ['bronze_working', 'writing'], + unlocks: { units: [], buildings: ['walls'], improvements: ['road'] }, + flavor: 'Adds defensive infrastructure and strategic movement.', + }, +]; + +const TECH_BY_ID = new Map(TECH_TREE.map((tech) => [tech.id, tech])); +const EMPTY_UNLOCKS = { units: [], buildings: [], improvements: [] }; + +export function createResearchState(seed = {}) { + return { + researched: [...(seed.researched || [])], + current: seed.current || null, + progress: Number(seed.progress || 0), + }; +} + +export function getTech(id) { + const tech = TECH_BY_ID.get(id); + if (!tech) throw new Error(`Unknown technology: ${id}`); + return tech; +} + +export function isResearched(state, techId) { + return state.researched.includes(techId); +} + +export function getTechStatus(state, techId) { + const tech = getTech(techId); + if (isResearched(state, tech.id)) return 'researched'; + if (state.current === tech.id) return 'in-progress'; + const unlocked = tech.prereqs.every((id) => isResearched(state, id)); + return unlocked ? 'available' : 'blocked'; +} + +export function getAvailableTechs(state) { + return TECH_TREE.filter((tech) => getTechStatus(state, tech.id) === 'available'); +} + +export function selectResearch(state, techId) { + const status = getTechStatus(state, techId); + if (status !== 'available' && status !== 'in-progress') { + throw new Error(`Cannot research ${techId}: ${status}`); + } + return { + ...state, + current: techId, + progress: state.current === techId ? state.progress : 0, + }; +} + +export function advanceResearch(state, sciencePerTurn) { + if (!state.current) { + return { state: createResearchState(state), completed: [] }; + } + + const tech = getTech(state.current); + const total = state.progress + Math.max(0, Number(sciencePerTurn || 0)); + if (total < tech.cost) { + return { + state: { ...state, progress: total }, + completed: [], + }; + } + + const researched = Array.from(new Set([...state.researched, tech.id])); + return { + state: { + researched, + current: null, + progress: total - tech.cost, + }, + completed: [tech.id], + }; +} + +export function getUnlockedContent(state) { + const unlocks = { + units: ['warrior'], + buildings: [], + improvements: [], + }; + + for (const techId of state.researched) { + const tech = getTech(techId); + mergeUnlocks(unlocks, tech.unlocks || EMPTY_UNLOCKS); + } + + return unlocks; +} + +export function chooseBotResearch(state, personality = 'balanced') { + const available = getAvailableTechs(state); + if (available.length === 0) return null; + + const preferences = { + military: ['archery', 'bronze_working', 'horseback_riding', 'engineering', 'mining', 'writing'], + science: ['writing', 'mining', 'engineering', 'archery', 'bronze_working', 'horseback_riding'], + expansion: ['mining', 'horseback_riding', 'writing', 'archery', 'bronze_working', 'engineering'], + balanced: ['mining', 'writing', 'archery', 'bronze_working', 'horseback_riding', 'engineering'], + }; + const order = preferences[personality] || preferences.balanced; + return [...available].sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id))[0].id; +} + +function mergeUnlocks(target, incoming) { + for (const key of Object.keys(target)) { + for (const value of incoming[key] || []) { + if (!target[key].includes(value)) target[key].push(value); + } + } +} diff --git a/demo/civ-lite/tech-model.test.mjs b/demo/civ-lite/tech-model.test.mjs new file mode 100644 index 0000000..7096f63 --- /dev/null +++ b/demo/civ-lite/tech-model.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; + +import { + TECH_TREE, + advanceResearch, + chooseBotResearch, + createResearchState, + getAvailableTechs, + getTechStatus, + getUnlockedContent, + selectResearch, +} from './tech-model.js'; + +test('tech tree defines prerequisites, costs, and concrete gameplay unlocks', () => { + assert.ok(TECH_TREE.length >= 6); + + const mining = TECH_TREE.find((tech) => tech.id === 'mining'); + const bronze = TECH_TREE.find((tech) => tech.id === 'bronze_working'); + const writing = TECH_TREE.find((tech) => tech.id === 'writing'); + + assert.equal(mining.cost, 10); + assert.deepEqual(mining.prereqs, []); + assert.ok(mining.unlocks.improvements.includes('mine')); + assert.deepEqual(bronze.prereqs, ['mining']); + assert.ok(bronze.unlocks.units.includes('swordsman')); + assert.ok(writing.unlocks.buildings.includes('library')); +}); + +test('research state exposes available, blocked, in-progress, and researched statuses', () => { + let state = createResearchState(); + + assert.deepEqual(getAvailableTechs(state).map((tech) => tech.id).sort(), ['archery', 'mining', 'writing']); + assert.equal(getTechStatus(state, 'bronze_working'), 'blocked'); + + state = selectResearch(state, 'mining'); + assert.equal(getTechStatus(state, 'mining'), 'in-progress'); + + const result = advanceResearch(state, 10); + assert.deepEqual(result.completed, ['mining']); + assert.equal(getTechStatus(result.state, 'mining'), 'researched'); + assert.equal(getTechStatus(result.state, 'bronze_working'), 'available'); +}); + +test('research progress accumulates science per turn without losing overflow', () => { + let state = selectResearch(createResearchState(), 'archery'); + + let result = advanceResearch(state, 4); + assert.equal(result.state.current, 'archery'); + assert.equal(result.state.progress, 4); + assert.deepEqual(result.completed, []); + + result = advanceResearch(result.state, 9); + assert.deepEqual(result.completed, ['archery']); + assert.equal(result.state.current, null); + assert.equal(result.state.progress, 1); +}); + +test('unlocks and bot choice support the opponent research path', () => { + let player = selectResearch(createResearchState(), 'mining'); + player = advanceResearch(player, 10).state; + player = selectResearch(player, 'bronze_working'); + player = advanceResearch(player, 16).state; + + const unlocks = getUnlockedContent(player); + assert.ok(unlocks.units.includes('swordsman')); + assert.ok(unlocks.improvements.includes('mine')); + + const botChoice = chooseBotResearch(createResearchState(), 'military'); + assert.equal(botChoice, 'archery'); +}); + +test('Civ Lite markup includes a technology panel for selectable research', async () => { + const html = await readFile(new URL('./index.html', import.meta.url), 'utf8'); + + assert.match(html, /id="technology"/); + assert.match(html, /id="currentTech"/); + assert.match(html, /id="techList"/); +});