From 4c3b5eb89a92bf75964b622695379fb0383c3efb Mon Sep 17 00:00:00 2001 From: YfengJ <166808804+YfengJ@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:35:26 +0800 Subject: [PATCH] Add Civ Lite technology tree gameplay --- demo/civ-lite/game.js | 123 ++++++++++++++++++++++- demo/civ-lite/index.html | 7 ++ demo/civ-lite/styles.css | 74 ++++++++++++++ demo/civ-lite/tech-model.js | 157 ++++++++++++++++++++++++++++++ demo/civ-lite/tech-model.test.mjs | 80 +++++++++++++++ 5 files changed, 436 insertions(+), 5 deletions(-) create mode 100644 demo/civ-lite/tech-model.js create mode 100644 demo/civ-lite/tech-model.test.mjs diff --git a/demo/civ-lite/game.js b/demo/civ-lite/game.js index 2428a1f..7360f3a 100644 --- a/demo/civ-lite/game.js +++ b/demo/civ-lite/game.js @@ -5,6 +5,17 @@ and Unciv tile groups. ───────────────────────────────────────────────────── */ import { buildSpriteAtlas } from './sprites.js'; +import { + TECH_TREE, + advanceResearch, + chooseBotResearch, + createResearchState, + getAvailableTechs, + getTech, + getTechStatus, + getUnlockedContent, + selectResearch, +} from './tech-model.js'; // ─── Constants ────────────────────────────────────── const MAP_W = 24, MAP_H = 16, TILE = 48; @@ -21,6 +32,12 @@ const TERRAIN_YIELD = { desert: { food: 0, prod: 1, sci: 1 }, }; const TERRAIN_DEFENSE = { plains: 0, forest: 0.25, hill: 0.35, water: 0, desert: 0 }; +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'); @@ -35,6 +52,8 @@ const dom = { food: document.getElementById('food'), prod: document.getElementById('prod'), science: document.getElementById('science'), + currentTech: document.getElementById('currentTech'), + techList: document.getElementById('techList'), unitDet: document.getElementById('unitDetails'), logBox: document.getElementById('logContent'), }; @@ -91,8 +110,8 @@ function noise(x, y) { const S = { map: generateMap(), units: [ - { id: 1, owner: 'player', x: 2, y: 2, hp: 100, atk: 30, def: 15, mov: 2, movLeft: 2, type: 'warrior' }, - { id: 2, owner: 'bot', x: MAP_W - 3, y: MAP_H - 3, hp: 100, atk: 28, def: 18, mov: 2, movLeft: 2, type: 'warrior' }, + createUnit(1, 'player', 2, 2, 'warrior'), + createUnit(2, 'bot', MAP_W - 3, MAP_H - 3, 'warrior'), ], cities: [ { owner: 'player', x: 2, y: 2, name: 'Athens', food: 0, prod: 0, pop: 1 }, @@ -105,6 +124,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, @@ -116,6 +140,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] = []; @@ -322,14 +351,94 @@ function gatherResources(owner) { spawnX = nx; spawnY = ny; break; } } - S.units.push({ id, owner, x: spawnX, y: spawnY, hp: 100, atk: 28, def: 15, mov: 2, movLeft: 2, type: 'warrior' }); - log(`${city.name} trained a warrior!`, 'build'); + const type = chooseUnitToTrain(owner); + S.units.push(createUnit(id, owner, spawnX, spawnY, type)); + log(`${city.name} trained a ${type}!`, 'build'); if (owner === 'player') revealAround(spawnX, spawnY); } } return { food: totalFood, prod: totalProd, sci: totalSci }; } +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; @@ -342,6 +451,7 @@ function endPlayerTurn() { updateUnitPanel(); const res = gatherResources('player'); + completeResearch('player', res.sci); dom.food.textContent = res.food; dom.prod.textContent = res.prod; dom.science.textContent = res.sci; @@ -357,12 +467,14 @@ function startPlayerTurn() { dom.status.className = ''; S.units.filter(u => u.owner === 'player').forEach(u => { u.movLeft = u.mov; }); refreshVision(); + updateTechPanel(); log(`─── Turn ${S.turn} ───`); } // ─── Bot AI ───────────────────────────────────────── function botTurn() { - gatherResources('bot'); + const botRes = gatherResources('bot'); + completeResearch('bot', botRes.sci); const bots = S.units.filter(u => u.owner === 'bot'); for (const bot of bots) { @@ -1090,5 +1202,6 @@ function gameLoop(now) { log('Loading sprites…'); ATLAS = await buildSpriteAtlas(); log('Sprites loaded — game starting!', 'good'); + updateTechPanel(); requestAnimationFrame(gameLoop); })(); diff --git a/demo/civ-lite/index.html b/demo/civ-lite/index.html index 163f984..25ecffc 100644 --- a/demo/civ-lite/index.html +++ b/demo/civ-lite/index.html @@ -4,6 +4,7 @@ CIV Lite – Human vs Bots + @@ -31,6 +32,12 @@

Selected Unit

+
+

Technology

+
Choose research
+
+
+
diff --git a/demo/civ-lite/styles.css b/demo/civ-lite/styles.css index 249777a..74dc8e3 100644 --- a/demo/civ-lite/styles.css +++ b/demo/civ-lite/styles.css @@ -187,6 +187,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"/); +});