import { MAPS, MAP_LIST, TERRAIN, BUILDINGS, PERSONALITY, RESOURCES } from './data.js'; import { MAX_ACTIONS } from './game.js'; const CREST = { skull: 'skullcrest' }; const CREST_FALLBACK = 'legion'; const TERR_CN = { plains: '平原', forest: '森林', mountain: '山地', city: '城市' }; const PERS_CN = { aggressive: '侵略', defensive: '防禦', diplomatic: '外交', mercantile: '商業' }; const RES_META = { gold: { label: '金幣', en: 'GOLD', icon: 'gold', color: '#e3c06a' }, food: { label: '糧食', en: 'FOOD', icon: 'food', color: '#d99668' }, wood: { label: '木材', en: 'WOOD', icon: 'wood', color: '#b08b5c' }, stone: { label: '石料', en: 'STONE', icon: 'stone', color: '#9aa7b4' }, }; const ico = (name, cls = '') => ``; const crestSvg = (f, cls = '') => ``; const terrSvg = (type) => ``; const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const fmt = (n) => (n > 0 ? `+${n}` : `${n}`); function costItems(cost, player) { return Object.entries(cost).filter(([, v]) => v > 0).map(([k, v]) => { const lack = player && player[k] < v; return `${ico(RES_META[k].icon)}${v} ${RES_META[k].label}`; }).join(''); } function resMini(value, key) { return `${ico(RES_META[key].icon)}${value}`; } function edgePairs(map) { const seen = new Set(); const out = []; for (const t of map.territories) { for (const a of t.adjacentTo) { const k = [t.id, a].sort().join('|'); if (seen.has(k)) continue; seen.add(k); out.push([t.id, a]); } } return out; } function hash(str) { let h = 2166136261; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0) / 4294967295; } function smoothClosed(points) { const n = points.length; if (!n) return ''; let d = `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)}`; for (let i = 0; i < n; i++) { const p0 = points[(i - 1 + n) % n], p1 = points[i], p2 = points[(i + 1) % n], p3 = points[(i + 2) % n]; const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6; const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6; d += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`; } return d + ' Z'; } function convexHull(pts) { const p = pts.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]); if (p.length < 3) return p; const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); const lower = []; for (const pt of p) { while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop(); lower.push(pt); } const upper = []; for (let i = p.length - 1; i >= 0; i--) { const pt = p[i]; while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop(); upper.push(pt); } upper.pop(); lower.pop(); return lower.concat(upper); } function roughPath(cx, cy, rx, ry, seed, points = 13) { const pts = []; for (let i = 0; i < points; i++) { const a = (i / points) * Math.PI * 2; const r = 0.88 + 0.18 * hash(`${seed}-${i}`); pts.push([cx + Math.cos(a) * rx * r, cy + Math.sin(a) * ry * r]); } return smoothClosed(pts); } export class UI { constructor(game) { this.game = game; this.screen = 'menu'; this.selectedTid = null; this.orderMode = null; this.tradeTarget = null; this.selectedMap = 'mainland'; this.selectedFaction = 'iron_legion'; this.pendingGameOver = false; this._lastRes = null; this._escHandler = null; } // ── boot ────────────────────────────────────────────────────────────────── init() { this.bind(); this.renderSetup(); this.refreshMenu(); this.spawnEmbers(); this.game.on((state, type, payload) => this.onChange(type, payload)); } showScreen(name) { for (const el of document.querySelectorAll('.screen')) el.classList.remove('active'); const el = document.getElementById(`screen-${name}`); if (el) el.classList.add('active'); this.screen = name; if (name === 'menu') this.refreshMenu(); if (name === 'setup') this.renderSetup(); } refreshMenu() { const cont = document.getElementById('btn-continue'); if (cont) cont.classList.toggle('hidden', !this.game.hasSave()); } spawnEmbers() { const box = document.querySelector('.menu-embers'); if (!box || box.dataset.ready) return; box.dataset.ready = '1'; for (let i = 0; i < 22; i++) { const s = document.createElement('span'); s.className = 'ember'; s.style.left = `${Math.random() * 100}%`; s.style.animationDuration = `${9 + Math.random() * 14}s`; s.style.animationDelay = `${Math.random() * 16}s`; s.style.opacity = (0.2 + Math.random() * 0.7).toFixed(2); s.style.transform = `scale(${(0.5 + Math.random()).toFixed(2)})`; box.appendChild(s); } } // ── events ──────────────────────────────────────────────────────────────── bind() { const on = (sel, ev, fn) => { const el = typeof sel === 'string' ? document.querySelector(sel) : sel; if (el) el.addEventListener(ev, fn); }; on('#btn-new', 'click', () => { this.showScreen('setup'); }); on('#btn-help', 'click', () => this.openHelp()); on('#btn-continue', 'click', () => this.continueGame()); on('#btn-setup-back', 'click', () => this.showScreen('menu')); on('#map-options', 'click', (e) => { const card = e.target.closest('[data-map]'); if (!card) return; this.selectedMap = card.dataset.map; const map = MAPS[this.selectedMap]; if (!map.factions.some((f) => f.id === this.selectedFaction)) this.selectedFaction = map.factions[0].id; this.renderSetup(); }); on('#faction-options', 'click', (e) => { const card = e.target.closest('[data-faction]'); if (!card) return; this.selectedFaction = card.dataset.faction; this.renderSetup(); }); on('#in-leader', 'input', () => this.updateSummary()); on('#in-nation', 'input', () => this.updateSummary()); on('#in-slogan', 'input', () => this.updateSummary()); on('#btn-begin', 'click', () => this.beginGame()); on('#btn-end-turn', 'click', () => this.endTurn()); on('#btn-save', 'click', () => { this.game.save(); this.toast('進度已保存至本機', 'good', 'save'); }); on('#btn-game-menu', 'click', () => this.confirmMenu()); document.addEventListener('click', (e) => { const opener = e.target.closest('[data-open]'); if (opener) { const what = opener.dataset.open; if (what === 'help') this.openHelp(); if (what === 'chronicle') this.openChronicle(); if (what === 'diplomacy') this.openDiplomacy(); } }); on('#map-stage', 'click', (e) => { if (e.target.closest('.map-tooltip')) return; const g = e.target.closest('.terr'); if (g) this.handleTerritoryClick(g.dataset.id); }); on('#map-stage', 'mousemove', (e) => this.handleMapHover(e)); on('#map-stage', 'mouseleave', () => this.hideTooltip()); on('#map-stage', 'keydown', (e) => { if (e.key !== 'Enter' && e.key !== ' ') return; const g = e.target.closest('.terr'); if (g) { e.preventDefault(); this.handleTerritoryClick(g.dataset.id); } }); on('#chronicle-peek', 'click', () => this.openChronicle()); } onChange(type) { if (type === 'new') { this.selectedTid = null; this.orderMode = null; } if (type === 'load') { this.selectedTid = null; this.orderMode = null; } if (this.screen === 'game') { this.renderAll(); if (this.game.state.isOver) this.pendingGameOver = true; } if (this.screen === 'menu') this.refreshMenu(); } // ── setup ───────────────────────────────────────────────────────────────── renderSetup() { const map = MAPS[this.selectedMap]; const factions = map.factions; if (!factions.some((f) => f.id === this.selectedFaction)) this.selectedFaction = factions[0].id; const mapEl = document.getElementById('map-options'); if (mapEl) { mapEl.innerHTML = MAP_LIST.map((m) => { const sel = m.id === this.selectedMap; return ``; }).join(''); } const facEl = document.getElementById('faction-options'); if (facEl) { facEl.innerHTML = factions.map((f) => { const sel = f.id === this.selectedFaction; return ``; }).join(''); } const f = factions.find((x) => x.id === this.selectedFaction) || factions[0]; const badge = document.getElementById('identity-crest'); if (badge) { badge.style.setProperty('--fc', f.color); badge.innerHTML = crestSvg(f); } const nation = document.getElementById('in-nation'); if (nation) nation.placeholder = f.name; const leader = document.getElementById('in-leader'); if (leader) leader.placeholder = '你的名字'; const slogan = document.getElementById('in-slogan'); if (slogan) slogan.placeholder = '榮耀就在前方!'; this.updateSummary(); } updateSummary() { const map = MAPS[this.selectedMap]; const f = map.factions.find((x) => x.id === this.selectedFaction) || map.factions[0]; const leader = document.getElementById('in-leader')?.value.trim() || '君主'; const nation = document.getElementById('in-nation')?.value.trim() || f.name; const slogan = document.getElementById('in-slogan')?.value.trim() || '榮耀就在前方!'; const sum = document.getElementById('setup-summary'); if (!sum) return; sum.innerHTML = `
世界${esc(map.name)}
勢力${esc(f.name)}
君主${esc(leader)} · ${esc(nation)}
格言“${esc(slogan)}”
`; const btn = document.getElementById('btn-begin'); if (btn) btn.disabled = !this.selectedMap || !this.selectedFaction; } beginGame() { const leader = document.getElementById('in-leader')?.value.trim(); const nation = document.getElementById('in-nation')?.value.trim(); const slogan = document.getElementById('in-slogan')?.value.trim(); this.game.newGame({ mapId: this.selectedMap, factionId: this.selectedFaction, identity: { leaderName: leader, nationName: nation, slogan }, }); this.showScreen('game'); this.selectedTid = null; this.orderMode = null; this.renderAll(); this.toast(`${this.game.state.identity.leaderName} 登基,${this.game.state.identity.nationName} 的旗幟升起`, 'good', 'crown'); } continueGame() { const state = this.game.loadFromStorage(); if (!state) { this.toast('找不到存檔', 'bad', 'help'); return; } this.showScreen('game'); this.selectedTid = null; this.orderMode = null; this.renderAll(); this.toast(`王朝延續:第 ${this.game.state.turn} 回合`, 'good', 'crown'); } // ── main render ─────────────────────────────────────────────────────────── renderAll() { if (!this.game.state) return; this.renderTopbar(); this.renderSovereign(); this.renderStats(); this.renderObjective(); this.renderRelations(); this.renderMap(); this.renderCouncil(); this.renderMapFoot(); } renderTopbar() { const { player } = this.game; const state = this.game.state; const income = this.game.income(); const upkeep = this.game.upkeep(); const nets = { gold: income.gold, food: income.food - upkeep.food, wood: income.wood, stone: income.stone }; const el = document.getElementById('resource-bar'); if (el) { el.innerHTML = Object.keys(RES_META).map((k) => { const delta = nets[k]; const changed = this._lastRes && this._lastRes[k] !== player[k]; return `
${ico(RES_META[k].icon)} ${player[k]} ${RES_META[k].label} ${fmt(delta)}
`; }).join(''); } this._lastRes = { gold: player.gold, food: player.food, wood: player.wood, stone: player.stone }; const turn = document.getElementById('turn-num'); if (turn) turn.textContent = state.turn; const pips = document.getElementById('action-pips'); if (pips) { pips.innerHTML = Array.from({ length: MAX_ACTIONS }, (_, i) => ``).join(''); } const end = document.getElementById('btn-end-turn'); if (end) { end.disabled = state.isOver; end.style.animation = state.actionsLeft === 0 && !state.isOver ? 'end-pulse 2.2s ease-in-out infinite' : ''; } } renderSovereign() { const f = this.game.player; const id = this.game.state.identity; const el = document.getElementById('sovereign-card'); if (!el) return; el.style.setProperty('--fc', f.color); el.innerHTML = `
${PERS_CN[f.personality]}
${esc(id.nationName)}
${esc(id.leaderName)} · ${esc(f.name)}
“${esc(id.slogan)}”
`; } renderStats() { const g = this.game; const p = g.player; const inc = g.income(); const up = g.upkeep(); const total = Object.keys(g.state.territories).length; const el = document.getElementById('stats-panel'); if (!el) return; el.innerHTML = `

國勢

${g.state.turn} 回合
${ico('flag')}疆域
${p.territories.length} / ${total}
${ico('army')}兵力
${g.factionTerritories(p.id).reduce((s, t) => s + t.armies, 0)} 單位
${ico('gold')}國庫
${p.gold} 金
${fmt(inc.gold)} 每回合
${ico('food')}糧倉
${p.food} 糧
${fmt(inc.food - up.food)} 淨值
${ico('spark')}每回合物資產出
${ico('gold')}+${inc.gold} ${ico('food')}+${inc.food} ${ico('wood')}+${inc.wood} ${ico('stone')}+${inc.stone} 維持 -${up.food} 糧
`; } renderObjective() { const g = this.game; const total = Object.keys(g.state.territories).length; const owned = g.player.territories.length; const pct = Math.round((owned / total) * 100); const rivals = Object.values(g.state.factions).filter((f) => f.id !== g.player.id && f.territories.length > 0) .sort((a, b) => b.territories.length - a.territories.length); const top = rivals[0]; const el = document.getElementById('objective-panel'); if (!el) return; el.innerHTML = `

霸業

${pct}%
已征服 ${owned} / ${total} 疆域還差 ${Math.max(0, total - owned)}
${top ? `最強對手:${esc(top.name)}(${top.territories.length} 疆域)` : '再無對手,世界盡歸你手。'}
`; } renderRelations() { const g = this.game; const el = document.getElementById('relations-list'); if (!el) return; const others = Object.values(g.state.factions).filter((f) => f.id !== g.player.id) .sort((a, b) => b.territories.length - a.territories.length); el.innerHTML = others.map((f) => { const rel = g.relationWith(f.id); const status = rel?.status || 'neutral'; const label = status === 'allied' ? '同盟' : status === 'peace' ? `停戰${rel.turnsRemaining > 0 ? ' ' + rel.turnsRemaining : ''}` : status === 'hostile' ? '敵對' : '中立'; const dead = f.territories.length === 0; return `
${crestSvg(f)} ${esc(f.name)} ${f.territories.length} 域 ${dead ? '滅亡' : label}
`; }).join(''); } renderMapFoot() { const g = this.game; const el = document.getElementById('turn-status'); if (el) { el.innerHTML = g.state.actionsLeft > 0 ? `本回合尚餘 ${g.state.actionsLeft} 個行動` : '行動已用盡 · 請結束回合'; } const last = [...g.state.log].reverse().find((l) => l.kind !== 'story'); const latest = document.getElementById('latest-log'); if (latest) latest.textContent = last ? last.text : (g.state.log.at(-1)?.text || '你的故事從這裡開始……'); } // ── setup mini map ──────────────────────────────────────────────────────── miniMapSvg(map) { const [vx, vy, vw, vh] = map.viewBox; const pos = map.positions; const routes = edgePairs(map).map(([a, b]) => { const p1 = pos[a], p2 = pos[b]; if (!p1 || !p2) return ''; return ``; }).join(''); const nodes = map.territories.map((t) => { const p = pos[t.id]; const f = map.factions.find((x) => x.id === t.owner); const c = f?.color || '#7b8494'; if (!p) return ''; return ` `; }).join(''); return ``; } // ── map ─────────────────────────────────────────────────────────────────── renderMap() { const g = this.game; const state = g.state; const map = MAPS[state.mapId]; const mapName = document.getElementById('map-name'); if (mapName) mapName.textContent = map.name; const [vx, vy, vw, vh] = map.viewBox; const svg = document.getElementById('map-svg'); if (!svg) return; svg.setAttribute('viewBox', `${vx} ${vy} ${vw} ${vh}`); svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); const pos = map.positions; const sel = this.selectedTid ? g.territory(this.selectedTid) : null; const selOwner = sel?.owner || null; const player = g.state.playerFactionId; const pts = map.territories.map((t) => pos[t.id]).filter(Boolean); const centroid = pts.reduce((a, p) => [a[0] + p[0] / pts.length, a[1] + p[1] / pts.length], [0, 0]); let land = ''; if (map.style === 'continent') { const hull = convexHull(pts); const expanded = hull.map((p, i) => { const dx = p[0] - centroid[0], dy = p[1] - centroid[1]; const len = Math.hypot(dx, dy) || 1; const pad = 78 + hash(`hull-${i}`) * 34; return [p[0] + (dx / len) * pad, p[1] + (dy / len) * pad]; }); const d = smoothClosed(expanded); land = ` `; } else { land = map.territories.map((t) => { const p = pos[t.id]; if (!p) return ''; const seed = hash(t.id); const rx = 58 + seed * 12, ry = 48 + seed * 10; return ` `; }).join(''); } const routes = edgePairs(map).map(([a, b]) => { const p1 = pos[a], p2 = pos[b]; if (!p1 || !p2) return ''; const mx = (p1[0] + p2[0]) / 2, my = (p1[1] + p2[1]) / 2; const dx = p2[0] - p1[0], dy = p2[1] - p1[1]; const len = Math.hypot(dx, dy) || 1; const bend = (hash(a + b) - 0.5) * 34; const cx = mx + (-dy / len) * bend, cy = my + (dx / len) * bend; const touchesSel = sel && (a === sel.id || b === sel.id); const otherId = sel ? (a === sel.id ? b : b === sel.id ? a : null) : null; const other = otherId ? g.territory(otherId) : null; let cls = ''; if (touchesSel) { const isHostile = selOwner === player ? (other && other.owner !== player) : other?.owner === player; cls = isHostile ? 'active hostile' : 'active'; } return ``; }).join(''); const nodes = map.territories.map((tpl) => this.mapNode(g.territory(tpl.id), map, sel, player)).filter(Boolean).join(''); svg.innerHTML = ` ${land} ${routes} ${nodes}`; // hide hint once selected const hint = document.getElementById('map-hint'); if (hint) { hint.classList.toggle('hidden-hint', !!sel); hint.textContent = sel ? (sel.owner === player ? `${sel.name} · 點擊相鄰疆域下達軍令` : `${sel.name} · 點擊你相鄰的疆域籌劃進攻`) : '點選疆域以查看詳情'; } } mapNode(t, map, sel, player) { const g = this.game; const p = map.positions[t.id]; if (!p) return ''; const owner = t.owner ? g.faction(t.owner) : null; const fc = owner?.color || '#7b8494'; const isSel = sel?.id === t.id; const isAdj = sel && sel.adjacentTo.includes(t.id); const isPlayerTerr = t.owner === player; const selectedIsOurs = sel?.owner === player; const selectedIsEnemy = sel && sel.owner !== player; let ring = ''; if (isSel) ring = ``; else if (isAdj) { if (selectedIsOurs) ring = isPlayerTerr ? `` : ``; else if (selectedIsEnemy && isPlayerTerr) ring = ``; } const dim = sel && !isSel && !isAdj ? 'dimmed' : ''; const buildings = t.buildings.map((b, i) => ``).join(''); const ownerLabel = owner ? owner.name : '無主之地'; const tname = esc(t.name); const labelY = 57; return ` ${ring} ${terrSvg(t.type)} ${buildings} ${t.armies} ${tname} ${esc(ownerLabel)} `; } handleTerritoryClick(tid) { if (!this.game.state || this.game.state.isOver) return; const g = this.game; const target = g.territory(tid); if (!target) return; const sel = this.selectedTid ? g.territory(this.selectedTid) : null; const player = g.state.playerFactionId; if (sel) { if (sel.id === tid) { this.selectedTid = null; this.orderMode = null; this.renderAll(); return; } if (sel.adjacentTo.includes(tid)) { if (sel.owner === player && target.owner === player) { this.openMoveModal(sel.id, tid); return; } if (sel.owner === player && target.owner !== player) { this.openAttackModal(sel.id, tid); return; } if (sel.owner !== player && target.owner === player) { this.openAttackModal(tid, sel.id); return; } } } this.selectedTid = tid; this.orderMode = null; this.renderAll(); } handleMapHover(e) { const gEl = e.target.closest('.terr'); const tip = document.getElementById('map-tooltip'); const stage = document.getElementById('map-stage'); if (!tip || !stage) return; if (!gEl) { this.hideTooltip(); return; } const t = this.game.territory(gEl.dataset.id); if (!t) return; const owner = t.owner ? this.game.faction(t.owner) : null; const rect = stage.getBoundingClientRect(); const x = e.clientX - rect.left, y = e.clientY - rect.top; const sel = this.selectedTid ? this.game.territory(this.selectedTid) : null; let hint = ''; if (sel && sel.id !== t.id && sel.adjacentTo.includes(t.id)) { if (sel.owner === this.game.state.playerFactionId) hint = t.owner === this.game.state.playerFactionId ? '點擊:行軍至此' : '點擊:發動進攻'; else if (t.owner === this.game.state.playerFactionId) hint = '點擊:由此進攻'; } else if (!sel) { hint = t.owner === this.game.state.playerFactionId ? '點擊:查看並下達軍令' : '點擊:查看疆域'; } tip.innerHTML = `
${esc(t.name)}
${esc(owner?.name || '無主之地')} · ${TERR_CN[t.type]}
${ico('army')} 駐軍${t.armies}
${ico('gold')} 產出+${t.resources.gold + (t.buildings.includes('market') ? 2 : 0)}
${t.buildings.length ? `
${ico('build')} 建築${t.buildings.map((b) => BUILDINGS[b].label).join('、')}
` : ''} ${hint ? `
${hint}
` : ''}`; tip.style.left = `${x}px`; tip.style.top = `${y}px`; tip.classList.add('show'); } hideTooltip() { const tip = document.getElementById('map-tooltip'); if (tip) tip.classList.remove('show'); } // ── council ─────────────────────────────────────────────────────────────── renderCouncil() { const el = document.getElementById('council-panel'); if (!el) return; const t = this.selectedTid ? this.game.territory(this.selectedTid) : null; el.innerHTML = t ? this.terrCouncil(t) : this.defaultCouncil(); this.bindCouncil(el); } defaultCouncil() { const g = this.game; const map = MAPS[g.state.mapId]; return `

戰爭議會

戰爭議會

${esc(map.name)}

${esc(g.state.identity.nationName)}的下一步

選擇地圖上的疆域,或從下方召集議事。每一道命令都將消耗一次行動。

軍令提示:先點選你自己的疆域,再點擊相鄰疆域即可招募、行軍或進攻。市場能增加金幣收入,兵營能降低徵兵成本。

勢力圖騰

${Object.values(g.state.factions).filter((f) => f.id !== g.player.id).map((f) => `
${crestSvg(f)} ${esc(f.name)} ${f.territories.length} 域 ${g.relationWith(f.id)?.status === 'allied' ? '同盟' : g.relationWith(f.id)?.status === 'peace' ? '停戰' : '中立'}
`).join('')}
`; } terrCouncil(t) { const g = this.game; const player = g.state.playerFactionId; const owner = t.owner ? g.faction(t.owner) : null; const fc = owner?.color || '#7b8494'; const ours = t.owner === player; const terrBonus = TERRAIN[t.type].bonus + (t.buildings.includes('walls') ? 0.3 : 0); const inc = t.resources; const isAdjToOurs = !ours && g.playerTerritories().some((p) => p.adjacentTo.includes(t.id)); const selAdjEnemy = ours ? t.adjacentTo.filter((id) => g.territory(id)?.owner !== player) : []; const selAdjFriend = ours ? t.adjacentTo.filter((id) => g.territory(id)?.owner === player) : []; const fromOptions = !ours ? g.playerTerritories().filter((p) => p.adjacentTo.includes(t.id)) : []; const canAct = g.state.actionsLeft > 0 && !g.state.isOver; let modeBlock = ''; if (this.orderMode === 'march' && ours) { modeBlock = `
選擇行軍目的地
${selAdjFriend.length ? selAdjFriend.map((id) => { const x = g.territory(id); return ``; }).join('') : `
沒有相鄰的友軍疆域可供行軍。
`}
`; } else if (this.orderMode === 'attack' && ours) { modeBlock = `
選擇進攻目標
${selAdjEnemy.length ? selAdjEnemy.map((id) => { const x = g.territory(id); const o = x.owner ? g.faction(x.owner) : null; return ``; }).join('') : `
附近沒有可進攻的疆域。
`}
`; } else if (!ours) { modeBlock = `
${t.owner ? '從相鄰疆域進攻' : '派兵占領'}
${fromOptions.length ? fromOptions.map((p) => ``).join('') : `
你的疆域皆不與此地相鄰。先攻下鄰近土地吧。
`}
`; } const actions = ours ? `
軍令
` : `
軍令
`; const buildingSlots = Object.values(BUILDINGS).map((b) => { const built = t.buildings.includes(b.id); return ``; }).join(''); return `

${ours ? '你的領地' : '異邦疆域'}

${ours ? '領地議會' : '異邦疆域'}

${TERR_CN[t.type]} ${ours ? '我方領地' : `${t.owner ? '敵對勢力' : '無主之地'}`}

${esc(t.name)}

${esc(owner?.name || '無主之地')}·${ico('army')} ${t.armies} 駐軍
${owner ? crestSvg(owner) : ico('flag')}
${ico('army')} 駐軍
${t.armies} 單位
${ico('shield')} 防禦倍率
×${terrBonus.toFixed(1)}
地形 x${TERRAIN[t.type].bonus}${t.buildings.includes('walls') ? ' · 城牆 +0.3' : ''}
${ico('gold')} 金幣產出
+${inc.gold + (t.buildings.includes('market') ? 2 : 0)} /回合
${ico('food')} 糧食產出
+${inc.food} /回合
木 +${inc.wood} · 石 +${inc.stone}
建築
${buildingSlots}
${actions} ${modeBlock}
`; } bindCouncil(el) { const g = this.game; el.querySelectorAll('[data-action="save"]').forEach((b) => b.addEventListener('click', () => { g.save(); this.toast('進度已保存至本機', 'good', 'save'); })); el.querySelectorAll('[data-deselect]').forEach((b) => b.addEventListener('click', () => { this.selectedTid = null; this.orderMode = null; this.renderAll(); })); el.querySelectorAll('[data-cancel-mode]').forEach((b) => b.addEventListener('click', () => { this.orderMode = null; this.renderAll(); })); el.querySelectorAll('[data-order]').forEach((b) => b.addEventListener('click', () => { const order = b.dataset.order; const t = g.territory(this.selectedTid); if (order === 'recruit') this.openRecruitModal(t.id); if (order === 'build') this.openBuildModal(t.id); if (order === 'march') { this.orderMode = this.orderMode === 'march' ? null : 'march'; this.renderAll(); } if (order === 'attack') { if (t.owner !== g.state.playerFactionId) { const options = g.playerTerritories().filter((p) => p.adjacentTo.includes(t.id)); if (options.length === 1) this.openAttackModal(options[0].id, t.id); else { this.orderMode = this.orderMode === 'attack' ? null : 'attack'; this.renderAll(); } } else { this.orderMode = this.orderMode === 'attack' ? null : 'attack'; this.renderAll(); } } })); el.querySelectorAll('[data-attack]').forEach((b) => b.addEventListener('click', () => { this.openAttackModal(this.selectedTid, b.dataset.attack); })); el.querySelectorAll('[data-attack-from]').forEach((b) => b.addEventListener('click', () => { this.openAttackModal(b.dataset.attackFrom, this.selectedTid); })); el.querySelectorAll('[data-move]').forEach((b) => b.addEventListener('click', () => { this.openMoveModal(this.selectedTid, b.dataset.move); })); el.querySelectorAll('[data-build]').forEach((b) => b.addEventListener('click', () => { const t = g.territory(this.selectedTid); if (t && !t.buildings.includes(b.dataset.build)) this.openBuildModal(t.id, b.dataset.build); })); } // ── modals ──────────────────────────────────────────────────────────────── openModal({ art, eyebrow, title, subtitle, body, actions, size = '', onMount, onClose }) { const root = document.getElementById('modal-root'); root.innerHTML = ` `; root.classList.add('open'); root.querySelectorAll('[data-close]').forEach((b) => b.addEventListener('click', () => this.closeModal())); if (this._escHandler) document.removeEventListener('keydown', this._escHandler); this._escHandler = (e) => { if (e.key === 'Escape') this.closeModal(); }; document.addEventListener('keydown', this._escHandler); this._closeCallback = onClose || null; if (onMount) onMount(root.querySelector('.modal')); return root.querySelector('.modal'); } closeModal() { const root = document.getElementById('modal-root'); if (!root) return; root.classList.remove('open'); root.innerHTML = ''; if (this._escHandler) { document.removeEventListener('keydown', this._escHandler); this._escHandler = null; } this.tradeTarget = null; this.hideTooltip(); const cb = this._closeCallback; this._closeCallback = null; if (cb) setTimeout(cb, 60); } toast(msg, tone = '', icon = 'spark') { const root = document.getElementById('toast-root'); if (!root) return; const el = document.createElement('div'); el.className = `toast ${tone}`; el.innerHTML = `${ico(icon)}${esc(msg)}`; root.appendChild(el); setTimeout(() => { el.classList.add('out'); setTimeout(() => el.remove(), 260); }, 3200); } // ── recruit ─────────────────────────────────────────────────────────────── openRecruitModal(tid) { const g = this.game; const t = g.territory(tid); if (!t) return; const max = g.maxRecruit(tid); const cost = g.recruitCost(tid); let qty = Math.max(1, Math.min(3, max || 1)); const art = 'assets/images/barracks.png'; const renderBody = () => `

在 ${esc(t.name)} 升起徵兵旗。每名士兵需 ${cost.gold} 金幣與 2 糧食${t.buildings.includes('barracks') ? ',兵營已為你降低徵募成本。' : '。建造兵營可將金幣成本降至 2。'}

目前駐軍 ${t.armies} · 可徵募 ${max}
${max < 1 ? 0 : qty}
${ico('gold')}
總計花費
${costItems({ gold: cost.gold * qty, food: 2 * qty }, g.player)}
徵募後駐軍
${t.armies + qty}
`; const modal = this.openModal({ art, eyebrow: '徵集兵員', title: '徵募新兵', subtitle: `${esc(t.name)} · ${TERR_CN[t.type]}`, body: renderBody(), actions: ``, size: 'medium', }); const update = () => { const range = modal.querySelector('#qty-range'); if (range) range.value = qty; modal.querySelector('#qty-val').textContent = qty; modal.querySelector('#cost-row').innerHTML = costItems({ gold: cost.gold * qty, food: 2 * qty }, g.player); modal.querySelector('#after-army').textContent = t.armies + qty; const btn = modal.querySelector('#do-recruit'); if (btn) btn.innerHTML = `${ico('army')}徵募 ${qty} 名士兵`; }; modal.querySelector('#qty-range')?.addEventListener('input', (e) => { qty = parseInt(e.target.value, 10) || 1; update(); }); modal.querySelector('#qty-minus')?.addEventListener('click', () => { qty = Math.max(1, qty - 1); update(); }); modal.querySelector('#qty-plus')?.addEventListener('click', () => { qty = Math.min(Math.max(1, max), qty + 1); update(); }); modal.querySelector('#qty-max')?.addEventListener('click', () => { qty = Math.max(1, max); update(); }); modal.querySelector('#do-recruit')?.addEventListener('click', () => { const res = g.recruit(tid, qty); if (!res.ok) { this.toast(res.msg, 'bad', 'help'); return; } this.closeModal(); this.toast(`在 ${t.name} 徵募了 ${res.n} 名士兵`, 'good', 'army'); }); } // ── move ────────────────────────────────────────────────────────────────── openMoveModal(fromId, toId) { const g = this.game; const from = g.territory(fromId), to = g.territory(toId); if (!from || !to) return; const max = Math.max(0, from.armies - 2); if (max < 1) { this.toast(`必須在 ${from.name} 留下 2 名守軍`, 'bad', 'shield'); return; } let qty = Math.min(max, Math.max(1, Math.floor(from.armies / 2))); const body = () => `

從 ${esc(from.name)} 抽調兵力進駐 ${esc(to.name)}。出發地必須留下至少 2 名守軍。

出征地
${esc(from.name)}
${from.armies}駐軍
${ico('flag')}
目的地
${esc(to.name)}
${to.armies}駐軍
可調動 ${max} 兵(留守 2)
${qty}
${ico('flag')}
行軍後
${from.name} ${from.armies - qty} · ${to.name} ${to.armies + qty}
`; const modal = this.openModal({ art: 'assets/images/map-parchment.png', eyebrow: '行軍號令', title: '行軍', subtitle: `${esc(from.name)} → ${esc(to.name)}`, body: body(), actions: ``, size: 'medium', }); const update = () => { modal.querySelector('#qty-range').value = qty; modal.querySelector('#qty-val').textContent = qty; modal.querySelector('#after-row').innerHTML = `${esc(from.name)} ${from.armies - qty} · ${esc(to.name)} ${to.armies + qty}`; modal.querySelector('#do-move').innerHTML = `${ico('flag')}行軍 ${qty} 兵`; }; modal.querySelector('#qty-range')?.addEventListener('input', (e) => { qty = parseInt(e.target.value, 10) || 1; update(); }); modal.querySelector('#qty-minus')?.addEventListener('click', () => { qty = Math.max(1, qty - 1); update(); }); modal.querySelector('#qty-plus')?.addEventListener('click', () => { qty = Math.min(max, qty + 1); update(); }); modal.querySelector('#qty-max')?.addEventListener('click', () => { qty = max; update(); }); modal.querySelector('#do-move')?.addEventListener('click', () => { const res = g.move(fromId, toId, qty); if (!res.ok) { this.toast(res.msg, 'bad', 'help'); return; } this.closeModal(); this.toast(`${res.n} 兵自 ${from.name} 進駐 ${to.name}`, 'good', 'flag'); }); } // ── attack ──────────────────────────────────────────────────────────────── openAttackModal(fromId, toId) { const g = this.game; const from = g.territory(fromId), to = g.territory(toId); if (!from || !to) return; if (from.owner !== g.state.playerFactionId) return; const defender = to.owner ? g.faction(to.owner) : null; let qty = from.armies; const renderForecast = () => { const f = g.forecast(fromId, toId, qty); return `
預期戰果${f.label}
我軍戰力 ${f.atk.toFixed(1)}守軍戰力 ${f.def.toFixed(1)}地形 ×${(TERRAIN[to.type].bonus + (to.buildings.includes('walls') ? 0.3 : 0)).toFixed(1)}
`; }; const body = () => `
進攻方
${esc(from.name)}
${esc(g.player.name)}
${qty}投入兵力
對
防守方
${esc(to.name)}
${esc(defender?.name || '無主之地')}
${to.armies}駐軍
可投入 ${from.armies} 兵 · 出征地將不留守軍
${qty}
${renderForecast()}

戰鬥為決定性推演:兵力、地形與城牆共同決定勝負,不會有隨機骰子。壓倒性優勢可將損失降到最低。

`; const modal = this.openModal({ art: 'assets/images/battle.png', eyebrow: to.owner ? '宣戰' : '佔領荒野', title: to.owner ? '發動進攻' : '占領無主之地', subtitle: `${esc(from.name)} → ${esc(to.name)}`, body: body(), actions: ``, size: 'medium', }); const update = () => { modal.querySelector('#qty-range').value = qty; modal.querySelector('#qty-val').textContent = qty; modal.querySelector('#atk-units').innerHTML = `${qty}投入兵力`; modal.querySelector('#forecast-wrap').innerHTML = renderForecast(); modal.querySelector('#do-attack').innerHTML = `${ico('sword')}${to.owner ? '發動進攻' : '派兵占領'} · ${qty} 兵`; }; modal.querySelector('#qty-range')?.addEventListener('input', (e) => { qty = parseInt(e.target.value, 10) || 1; update(); }); modal.querySelector('#qty-minus')?.addEventListener('click', () => { qty = Math.max(1, qty - 1); update(); }); modal.querySelector('#qty-plus')?.addEventListener('click', () => { qty = Math.min(from.armies, qty + 1); update(); }); modal.querySelector('#qty-max')?.addEventListener('click', () => { qty = from.armies; update(); }); modal.querySelector('#do-attack')?.addEventListener('click', () => { const res = g.attack(fromId, toId, qty); if (!res.ok) { this.toast(res.msg, 'bad', 'help'); return; } this.closeModal(); this.showBattleResult(res); }); } showBattleResult(res) { const g = this.game; const won = res.result.captured; const outcomeTitle = res.result.outcome === 'uncontested' ? '不戰而得' : won ? '勝利' : '攻勢受挫'; const outcomeEn = res.result.outcome === 'uncontested' ? '不戰而得' : won ? '勝利' : '戰敗'; const body = `
${won ? ico('crown') : ico('skull')}

${outcomeTitle}

${won ? `你的旗幟在 ${esc(res.toId ? g.territory(res.toId).name : '')} 升起。` : `部隊自 ${esc(g.territory(res.fromId).name)} 撤退。`}

我軍傷亡
${res.result.attackerCasualties} 單位
倖存 ${res.survivors}
敵軍傷亡
${res.result.defenderCasualties} 單位
原守軍 ${res.defenderUnits}
${res.eliminated ? `
${ico('skull')}
局勢劇變
${esc(res.eliminated.name)} 已被徹底消滅。
` : ''}
    ${res.result.log.map((l) => `
  • ${esc(l)}
  • `).join('')}
`; this.openModal({ art: 'assets/images/battle.png', eyebrow: outcomeEn, title: outcomeTitle, subtitle: `${res.defenderName || '無主之地'} · 第 ${g.state.turn} 回合`, body, actions: ``, size: 'medium', onClose: () => { if (g.state.isOver) this.showGameOver(); }, }); } // ── build ───────────────────────────────────────────────────────────────── openBuildModal(tid, focus) { const g = this.game; const t = g.territory(tid); if (!t) return; const canAct = g.state.actionsLeft > 0; const cards = Object.values(BUILDINGS).map((b) => { const built = t.buildings.includes(b.id); const afford = g.canAfford(b.cost); return `
${built ? '已建成' : ''}

${b.label}

${b.effect}

${esc(b.blurb)}

${built ? '效益已生效' : costItems(b.cost, g.player)}
`; }).join(''); const modal = this.openModal({ art: 'assets/images/walls.png', eyebrow: '營造工程', title: '營造工程', subtitle: `${esc(t.name)} · 每回合限建造一座 · 目前行動 ${g.state.actionsLeft}`, body: `

建築將永久附屬於這塊疆域,即使易主也會保留。城牆強化防禦,兵營降低徵兵成本,市場帶來穩定金流。

${cards}
${!canAct ? '

本回合已無行動,請先結束回合。

' : ''}`, actions: ``, size: 'medium', }); modal.querySelectorAll('[data-build-do]').forEach((b) => b.addEventListener('click', () => { const type = b.dataset.buildDo; const res = g.build(tid, type); if (!res.ok) { this.toast(res.msg, 'bad', 'help'); return; } this.closeModal(); this.toast(`${BUILDINGS[type].label} 在 ${t.name} 落成`, 'good', 'build'); })); } // ── diplomacy ───────────────────────────────────────────────────────────── openDiplomacy() { const g = this.game; const canAct = g.state.actionsLeft > 0 && !g.state.isOver; const rows = Object.values(g.state.factions).filter((f) => f.id !== g.player.id).map((f) => { const rel = g.relationWith(f.id); const status = rel?.status || 'neutral'; const label = status === 'allied' ? '同盟' : status === 'peace' ? `停戰 ${rel.turnsRemaining} 回合` : '中立'; const dead = f.territories.length === 0; const selected = this.tradeTarget === f.id; return `
${crestSvg(f)} ${esc(f.name)} ${PERS_CN[f.personality]} · ${f.territories.length} 疆域 · ${dead ? '已滅亡' : label}
`; }).join(''); let tradeHtml = ''; if (this.tradeTarget) { const f = g.faction(this.tradeTarget); tradeHtml = `

商業勢力匯率最佳(0.8),侵略勢力最差(1.5)。

`; } const modal = this.openModal({ art: 'assets/images/council.png', eyebrow: '外交議會', title: '外交議會', subtitle: `本回合尚餘 ${g.state.actionsLeft} 個行動 · 每次交涉消耗 1 次`, body: `

在刀劍之外,言語同樣能改變疆界。同盟能阻止戰火,停戰爭取喘息,貿易則讓國庫充盈。

${rows}
${tradeHtml}`, actions: ``, size: 'medium', }); const reopen = () => this.openDiplomacy(); modal.querySelectorAll('[data-ally]').forEach((b) => b.addEventListener('click', () => { const res = g.proposeAlliance(b.dataset.ally); if (!res.ok) { this.toast(res.msg, 'bad', 'help'); return; } this.toast(res.msg, res.accepted ? 'good' : '', res.accepted ? 'handshake' : 'dove'); reopen(); })); modal.querySelectorAll('[data-peace]').forEach((b) => b.addEventListener('click', () => { const res = g.proposePeace(b.dataset.peace); if (!res.ok) { this.toast(res.msg, 'bad', 'help'); return; } this.toast(res.msg, res.accepted ? 'good' : '', 'dove'); reopen(); })); modal.querySelectorAll('[data-trade]').forEach((b) => b.addEventListener('click', () => { this.tradeTarget = this.tradeTarget === b.dataset.trade ? null : b.dataset.trade; reopen(); })); modal.querySelector('#trade-submit')?.addEventListener('click', () => { const giveRes = modal.querySelector('#trade-give-res').value; const giveAmt = parseInt(modal.querySelector('#trade-give-amt').value, 10); const getRes = modal.querySelector('#trade-get-res').value; const res = g.proposeTrade(this.tradeTarget, giveRes, giveAmt, getRes); if (!res.ok) { this.toast(res.msg, 'bad', 'help'); return; } this.toast(res.msg, res.accepted ? 'good' : '', res.accepted ? 'trade' : 'dove'); this.tradeTarget = null; reopen(); }); } // ── chronicle ───────────────────────────────────────────────────────────── openChronicle() { const g = this.game; const logs = g.state.log.slice(-80).reverse(); const body = logs.length ? `
    ${logs.map((l) => `
  • 第 ${l.turn} 回合${esc(l.text)}
  • `).join('')}
` : `
${ico('scroll')}編年史尚為空白。你的第一道命令將成為傳說的開端。
`; this.openModal({ art: 'assets/images/map-parchment.png', eyebrow: '王朝編年史', title: '王朝編年史', subtitle: `第 ${g.state.turn} 回合 · ${g.state.identity.nationName}`, body, actions: ``, size: 'medium', }); } // ── help ────────────────────────────────────────────────────────────────── openHelp() { const body = `

你是一位新登基的君主。目標只有一個:征服地圖上的所有疆域。每回合有 3 個行動,謹慎分配於軍事、建設與外交。

每個回合

1 · 下達軍令3 個行動
2 · 結束回合收取資源
3 · 敵軍行動招募與進攻
4 · 新回合行動重置

四種資源

金幣 · 徵兵與建造${ico('gold')}
糧食 · 徵兵與軍隊維持${ico('food')}
木材 · 建造建築${ico('wood')}
石料 · 建造與城防${ico('stone')}

戰鬥沒有隨機數。戰力 = 兵力 × 士氣,防守方再乘以地形與城牆加成。平原 ×1.0、森林 ×1.2、城市 ×1.3、山地 ×1.5;城牆額外 +0.3。
2:1 以上為壓倒性勝利,1.2:1 為勝利,1:1 為慘勝,低於 0.8:1 則潰敗。

城牆提升防禦;兵營讓徵兵金幣成本從 3 降至 2;市場每回合 +2 金幣。外交可提出同盟、停戰或貿易,成功率取決於對方性格,弱小的勢力更容易接受。

先點選你自己的疆域,再點擊相鄰疆域:友軍→行軍,敵軍或無主地→進攻。右側議會提供完整軍令與建築卡。所有進度會自動保存在瀏覽器。

`; this.openModal({ art: 'assets/images/council.png', eyebrow: '征戰指南', title: '征戰指南', subtitle: '從一位君主,到一個帝國。', body, actions: ``, size: 'medium', }); } // ── turn cycle ──────────────────────────────────────────────────────────── endTurn() { const g = this.game; if (g.state.isOver) return; const report = g.endTurn(); if (!report) return; this.selectedTid = null; this.orderMode = null; this.renderAll(); this.showTurnReport(report); } showTurnReport(report) { const g = this.game; const net = report.net || { gold: 0, food: 0, wood: 0, stone: 0 }; const ai = report.aiLogs || []; const diplo = report.diploLogs || []; const events = [ ...report.starvation.map((t) => ({ icon: 'food', text: t })), ...ai.map((t) => ({ icon: 'sword', text: t })), ...diplo.map((t) => ({ icon: 'dove', text: t })), ]; const body = `

第 ${report.turn} 回合落幕。使者與軍隊同時抵達,帝國的帳冊與戰報如下。

本回合收支

${ico('gold')} 金幣收入+${report.income.gold}
${ico('food')} 糧食收入+${report.income.food}
${ico('wood')} 木材收入+${report.income.wood}
${ico('stone')} 石料收入+${report.income.stone}
軍隊糧食維持-${report.upkeep.food}
淨收益${fmt(net.gold)}金 · ${fmt(net.food)}糧

勢力動向

${events.length ? `
    ${events.slice(0, 7).map((e) => `
  • ${ico(e.icon)}${esc(e.text)}
  • `).join('')}
` : `

列強按兵不動。這是暴風雨前的寧靜。

`}
`; this.openModal({ art: 'assets/images/throne.png', eyebrow: '回合落幕', title: `第 ${report.turn} 回合結束`, subtitle: events.length ? `${events.length} 件大事發生` : '帝國在沉默中醞釀下一步', body, actions: ``, size: 'medium', onClose: () => { if (g.state.isOver) this.showGameOver(); }, }); } confirmMenu() { this.openModal({ eyebrow: '離開戰爭地圖', title: '返回主選單?', body: `

目前的進度會保留在本機瀏覽器中。你可以隨時從主選單點擊「繼續王朝」回到第 ${this.game.state.turn} 回合。

`, actions: ``, size: 'small', }); document.getElementById('confirm-menu')?.addEventListener('click', () => { this.game.save(); this.closeModal(); this.showScreen('menu'); }); } // ── game over ───────────────────────────────────────────────────────────── showGameOver() { if (document.querySelector('.gameover')) return; const g = this.game; const won = g.state.winner === g.state.playerFactionId; const winner = g.state.winner ? g.faction(g.state.winner) : null; const el = document.createElement('div'); el.className = 'gameover open'; el.innerHTML = `

${won ? '天下歸一' : '王冠墜落'}

${won ? '勝利' : '敗亡'}

${won ? '天下歸一' : '王朝傾覆'}

${won ? `第 ${g.state.turn} 回合,${esc(g.state.identity.nationName)} 的旗幟插遍每一寸土地。${esc(g.state.identity.leaderName)} 的名字將與神話並列。` : `第 ${g.state.turn} 回合,${esc(winner?.name || '敵軍')} 的鐵蹄踏碎了你的王座。但歷史從不止於一場敗仗。`}

${g.state.turn}回合
${g.state.stats.battlesWon}勝仗
${g.state.stats.captured}征服
${g.state.stats.recruited}徵兵
`; document.body.appendChild(el); el.querySelector('#over-again').addEventListener('click', () => { el.remove(); this.game.clearSave(); this.showScreen('setup'); }); el.querySelector('#over-menu').addEventListener('click', () => { el.remove(); this.showScreen('menu'); this.refreshMenu(); }); } }