empire-web / js /ui.js
HelloSun's picture
全面繁體中文化
9709a07 verified
Raw History Blame Contribute Delete
77.2 kB
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 = '') => `<svg class="ico ${cls}" aria-hidden="true"><use href="#i-${name}"/></svg>`;
const crestSvg = (f, cls = '') => `<svg class="ico ${cls}" aria-hidden="true"><use href="#c-${CREST[f?.crest] || f?.crest || CREST_FALLBACK}"/></svg>`;
const terrSvg = (type) => `<use href="#t-${type}" x="-13.5" y="-14" width="27" height="27" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>`;
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 `<span class="cost-item ${lack ? 'lack' : ''}">${ico(RES_META[k].icon)}${v} ${RES_META[k].label}</span>`;
}).join('');
}
function resMini(value, key) {
return `<span class="res-mini" style="--rc:${RES_META[key].color}">${ico(RES_META[key].icon)}${value}</span>`;
}
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 `<button class="map-card ${sel ? 'selected' : ''}" data-map="${m.id}">
<div class="map-thumb" style="background-image:url('assets/images/map-parchment.png')">${this.miniMapSvg(m)}</div>
<span class="map-check">${sel ? '✓' : ''}</span>
<span class="map-info">
<span class="map-kicker">${esc(m.kicker)}</span>
<h3>${esc(m.name)}</h3>
<p>${esc(m.description)}</p>
<span class="map-meta"><span>${m.territories.length} 塊疆域</span><span>·</span><span>${m.factions.length} 個勢力</span></span>
</span>
</button>`;
}).join('');
}
const facEl = document.getElementById('faction-options');
if (facEl) {
facEl.innerHTML = factions.map((f) => {
const sel = f.id === this.selectedFaction;
return `<button class="faction-card ${sel ? 'selected' : ''}" data-faction="${f.id}" style="--fc:${f.color}">
<span class="fc-selected-mark">${ico('crown')}</span>
<span class="fc-top">
<span class="fc-crest">${crestSvg(f)}</span>
<span style="min-width:0">
<h3>${esc(f.name)}</h3>
<span class="fc-personality">${PERS_CN[f.personality]}</span>
</span>
</span>
<p class="fc-blurb">${esc(f.blurb)}</p>
<span class="fc-res">
${resMini(f.gold, 'gold')}${resMini(f.food, 'food')}${resMini(f.wood, 'wood')}${resMini(f.stone, 'stone')}
</span>
</button>`;
}).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 = `
<div class="sum-row"><span>世界</span><b class="sum-tag">${esc(map.name)}</b></div>
<div class="sum-row"><span>勢力</span><b class="sum-tag" style="color:${f.color}">${esc(f.name)}</b></div>
<div class="sum-row"><span>君主</span><b>${esc(leader)} · ${esc(nation)}</b></div>
<div class="sum-row"><span>格言</span><b style="font-style:italic;color:var(--muted)">“${esc(slogan)}”</b></div>`;
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 `<div class="res-chip ${changed ? 'bump' : ''}" style="--rc:${RES_META[k].color}" title="${RES_META[k].label} · 每回合 ${fmt(delta)}">
${ico(RES_META[k].icon)}
<b>${player[k]}</b>
<small>${RES_META[k].label}</small>
<span class="delta ${delta >= 0 ? 'up' : 'down'}">${fmt(delta)}</span>
</div>`;
}).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) =>
`<span class="pip ${i < state.actionsLeft ? '' : 'used'}"></span>`).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 = `
<div class="sovereign-bg"></div>
<div class="sovereign-body">
<span class="sovereign-personality"><span class="chip" style="color:${f.color};border-color:${f.color}66;background:${f.color}18">${PERS_CN[f.personality]}</span></span>
<div class="sovereign-nation"><span class="dot"></span>${esc(id.nationName)}</div>
<div class="sovereign-name">${esc(id.leaderName)} · ${esc(f.name)}</div>
<div class="sovereign-slogan">“${esc(id.slogan)}”</div>
</div>`;
}
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 = `
<div class="panel-head"><h3>國勢</h3><span class="chip gold">${g.state.turn} 回合</span></div>
<div class="stat-grid">
<div class="stat-cell"><div class="stat-k">${ico('flag')}疆域</div><div class="stat-v">${p.territories.length}<small> / ${total}</small></div></div>
<div class="stat-cell"><div class="stat-k">${ico('army')}兵力</div><div class="stat-v">${g.factionTerritories(p.id).reduce((s, t) => s + t.armies, 0)}<small> 單位</small></div></div>
<div class="stat-cell"><div class="stat-k">${ico('gold')}國庫</div><div class="stat-v">${p.gold}<small> 金</small></div><div class="stat-sub">${fmt(inc.gold)} 每回合</div></div>
<div class="stat-cell"><div class="stat-k">${ico('food')}糧倉</div><div class="stat-v">${p.food}<small> 糧</small></div><div class="stat-sub">${fmt(inc.food - up.food)} 淨值</div></div>
<div class="stat-cell wide">
<div class="stat-k">${ico('spark')}每回合物資產出</div>
<div class="res-row">
<span style="color:#e3c06a">${ico('gold')}+${inc.gold}</span>
<span style="color:#d99668">${ico('food')}+${inc.food}</span>
<span style="color:#b08b5c">${ico('wood')}+${inc.wood}</span>
<span style="color:#9aa7b4">${ico('stone')}+${inc.stone}</span>
<span class="res-upkeep">維持 -${up.food} 糧</span>
</div>
</div>
</div>`;
}
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 = `
<div class="panel-head"><h3>霸業</h3><span class="chip ${pct >= 100 ? 'good' : 'gold'}">${pct}%</span></div>
<div class="obj-body">
<div class="obj-bar"><div class="obj-fill" style="width:${Math.min(100, pct)}%"></div></div>
<div class="obj-meta"><span>已征服 <b>${owned}</b> / ${total} 疆域</span><span>還差 ${Math.max(0, total - owned)}</span></div>
<div class="obj-note">${top ? `最強對手:<strong style="color:${top.color}">${esc(top.name)}</strong>(${top.territories.length} 疆域)` : '再無對手,世界盡歸你手。'}</div>
</div>`;
}
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 `<div class="rel-row ${dead ? 'rel-dead' : ''}" style="--fc:${f.color}">
<span class="rel-crest">${crestSvg(f)}</span>
<span class="rel-name">${esc(f.name)}</span>
<span class="rel-owned">${f.territories.length} 域</span>
<span class="rel-status ${status}">${dead ? '滅亡' : label}</span>
</div>`;
}).join('');
}
renderMapFoot() {
const g = this.game;
const el = document.getElementById('turn-status');
if (el) {
el.innerHTML = g.state.actionsLeft > 0
? `本回合尚餘 <b>${g.state.actionsLeft}</b> 個行動`
: '<b style="color:var(--danger)">行動已用盡</b> · 請結束回合';
}
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 `<line x1="${p1[0]}" y1="${p1[1]}" x2="${p2[0]}" y2="${p2[1]}" stroke="rgba(230,205,150,.32)" stroke-width="2" stroke-dasharray="6 8"/>`;
}).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 `<g>
<circle cx="${p[0]}" cy="${p[1]}" r="26" fill="none" stroke="${c}" stroke-width="2" opacity=".9"/>
<circle cx="${p[0]}" cy="${p[1]}" r="20" fill="#0d131c" stroke="${c}" stroke-width="1.2"/>
<circle cx="${p[0]}" cy="${p[1]}" r="4" fill="${c}"/>
</g>`;
}).join('');
return `<svg viewBox="${vx} ${vy} ${vw} ${vh}" preserveAspectRatio="xMidYMid meet" aria-hidden="true">${routes}${nodes}</svg>`;
}
// ── 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 = `
<path d="${d}" fill="none" stroke="rgba(196,168,112,.18)" stroke-width="16" filter="url(#soft-blur)"/>
<path d="${d}" class="map-land-sand" transform="translate(0,4)"/>
<path d="${d}" class="map-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 `<path d="${roughPath(p[0], p[1] + 4, rx + 8, ry + 6, 'sand-' + t.id)}" class="map-island-sand"/>
<path d="${roughPath(p[0], p[1] + 2, rx, ry, t.id)}" class="map-island"/>`;
}).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 `<path d="M ${p1[0]} ${p1[1]} Q ${cx.toFixed(1)} ${cy.toFixed(1)} ${p2[0]} ${p2[1]}" class="map-route ${cls}"/>`;
}).join('');
const nodes = map.territories.map((tpl) => this.mapNode(g.territory(tpl.id), map, sel, player)).filter(Boolean).join('');
svg.innerHTML = `
<defs>
<radialGradient id="land-grad" cx="42%" cy="38%" r="72%">
<stop offset="0%" stop-color="#2c3b32"/>
<stop offset="55%" stop-color="#22302a"/>
<stop offset="100%" stop-color="#16211e"/>
</radialGradient>
<radialGradient id="island-grad" cx="38%" cy="30%" r="80%">
<stop offset="0%" stop-color="#2b3a31"/>
<stop offset="100%" stop-color="#17231f"/>
</radialGradient>
<radialGradient id="node-grad" cx="35%" cy="28%" r="85%">
<stop offset="0%" stop-color="#1d2733"/>
<stop offset="100%" stop-color="#0c1119"/>
</radialGradient>
<pattern id="sea-grid" width="72" height="72" patternUnits="userSpaceOnUse">
<path d="M72 0H0V72" fill="none" stroke="rgba(148,180,190,.055)" stroke-width="1"/>
</pattern>
<filter id="rough-coast" x="-25%" y="-25%" width="150%" height="150%">
<feTurbulence type="fractalNoise" baseFrequency="0.013 0.019" numOctaves="2" seed="11" result="n"/>
<feDisplacementMap in="SourceGraphic" in2="n" scale="13" xChannelSelector="R" yChannelSelector="G"/>
</filter>
<filter id="soft-blur" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="9"/>
</filter>
<filter id="node-shadow" x="-60%" y="-60%" width="220%" height="220%">
<feDropShadow dx="0" dy="4" stdDeviation="6" flood-color="#000" flood-opacity=".55"/>
</filter>
</defs>
<rect x="${vx}" y="${vy}" width="${vw}" height="${vh}" fill="url(#sea-grid)"/>
${land}
<g class="routes">${routes}</g>
<g class="nodes">${nodes}</g>`;
// 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 = `<circle r="44" class="select-ring"/>`;
else if (isAdj) {
if (selectedIsOurs) ring = isPlayerTerr ? `<circle r="43" class="adj-ring"/>` : `<circle r="43" class="atk-ring"/>`;
else if (selectedIsEnemy && isPlayerTerr) ring = `<circle r="43" class="atk-ring"/>`;
}
const dim = sel && !isSel && !isAdj ? 'dimmed' : '';
const buildings = t.buildings.map((b, i) => `<circle class="b-pip" cx="${(i - (t.buildings.length - 1) / 2) * 8}" cy="-44" r="2.5"/>`).join('');
const ownerLabel = owner ? owner.name : '無主之地';
const tname = esc(t.name);
const labelY = 57;
return `<g class="terr ${owner ? '' : 'unclaimed'} ${isSel ? 'selected' : ''} ${dim}" data-id="${t.id}" transform="translate(${p[0]},${p[1]})" style="--fc:${fc}" tabindex="0" role="button" aria-label="${tname}, ${esc(ownerLabel)}, ${t.armies} units">
<circle class="halo" r="52"/>
${ring}
<g filter="url(#node-shadow)">
<circle class="ring-outer" r="35"/>
<circle class="ring-inner" r="30"/>
<circle class="core" r="27"/>
</g>
<g class="terrain-ico" style="color:${owner ? '#e8dcc0' : '#9aa2ad'}">${terrSvg(t.type)}</g>
<g class="b-pips">${buildings}</g>
<g class="army-badge" transform="translate(26,25)">
<circle r="12"/>
<text y="4.2">${t.armies}</text>
</g>
<text class="tname" y="${labelY}">${tname}</text>
<text class="towner" y="${labelY + 13}">${esc(ownerLabel)}</text>
</g>`;
}
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 = `
<div class="tt-name">${esc(t.name)}</div>
<div class="tt-owner" style="color:${owner?.color || '#8d929d'}">${esc(owner?.name || '無主之地')} · ${TERR_CN[t.type]}</div>
<div class="tt-row"><span>${ico('army')} 駐軍</span><b>${t.armies}</b></div>
<div class="tt-row"><span>${ico('gold')} 產出</span><b>+${t.resources.gold + (t.buildings.includes('market') ? 2 : 0)}</b></div>
${t.buildings.length ? `<div class="tt-row"><span>${ico('build')} 建築</span><b>${t.buildings.map((b) => BUILDINGS[b].label).join('、')}</b></div>` : ''}
${hint ? `<div class="tt-hint">${hint}</div>` : ''}`;
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 `<div class="council">
<div class="council-head">
<p class="eyebrow">戰爭議會</p>
<h2>戰爭議會</h2>
</div>
<div class="council-hero">
<div class="hero-img"></div>
<div class="hero-body">
<div class="hero-kicker">${esc(map.name)}</div>
<h3>${esc(g.state.identity.nationName)}的下一步</h3>
<p>選擇地圖上的疆域,或從下方召集議事。每一道命令都將消耗一次行動。</p>
</div>
</div>
<div class="quick-grid">
<button class="quick-btn" data-open="diplomacy">${ico('handshake')}<span>外交議會<small>同盟與貿易</small></span></button>
<button class="quick-btn" data-open="chronicle">${ico('scroll')}<span>王朝編年史<small>帝國紀事</small></span></button>
<button class="quick-btn" data-open="help">${ico('help')}<span>征戰指南<small>玩法說明</small></span></button>
<button class="quick-btn" data-action="save">${ico('save')}<span>保存進度<small>寫入本機</small></span></button>
</div>
<div class="council-tip"><b>軍令提示:</b>先點選你自己的疆域,再點擊相鄰疆域即可招募、行軍或進攻。市場能增加金幣收入,兵營能降低徵兵成本。</div>
<div class="panel">
<div class="panel-head"><h3>勢力圖騰</h3></div>
<div class="relations-list">
${Object.values(g.state.factions).filter((f) => f.id !== g.player.id).map((f) => `
<div class="rel-row ${f.territories.length ? '' : 'rel-dead'}" style="--fc:${f.color}">
<span class="rel-crest">${crestSvg(f)}</span>
<span class="rel-name">${esc(f.name)}</span>
<span class="rel-owned">${f.territories.length} 域</span>
<span class="rel-status ${g.relationWith(f.id)?.status || 'neutral'}">${g.relationWith(f.id)?.status === 'allied' ? '同盟' : g.relationWith(f.id)?.status === 'peace' ? '停戰' : '中立'}</span>
</div>`).join('')}
</div>
</div>
</div>`;
}
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 = `<div class="target-panel" data-mode="march">
<div class="target-head"><span>選擇行軍目的地</span><button data-cancel-mode>×</button></div>
${selAdjFriend.length ? selAdjFriend.map((id) => {
const x = g.territory(id);
return `<button class="target-row" data-move="${id}" style="--fc:${fc}">
<span class="target-dot"></span>
<span class="target-name">${esc(x.name)}<small>${TERR_CN[x.type]} · 我軍駐地</small></span>
<span class="target-army"><b>${x.armies}</b> 兵</span>${ico('chevron')}
</button>`;
}).join('') : `<div class="target-empty">沒有相鄰的友軍疆域可供行軍。</div>`}
</div>`;
} else if (this.orderMode === 'attack' && ours) {
modeBlock = `<div class="target-panel" data-mode="attack">
<div class="target-head"><span>選擇進攻目標</span><button data-cancel-mode>×</button></div>
${selAdjEnemy.length ? selAdjEnemy.map((id) => {
const x = g.territory(id);
const o = x.owner ? g.faction(x.owner) : null;
return `<button class="target-row" data-attack="${id}" style="--fc:${o?.color || '#8d929d'}">
<span class="target-dot"></span>
<span class="target-name">${esc(x.name)}<small>${o ? o.name : '無主之地'} · ${TERR_CN[x.type]}</small></span>
<span class="target-army"><b>${x.armies}</b> 兵</span>${ico('sword')}
</button>`;
}).join('') : `<div class="target-empty">附近沒有可進攻的疆域。</div>`}
</div>`;
} else if (!ours) {
modeBlock = `<div class="target-panel">
<div class="target-head"><span>${t.owner ? '從相鄰疆域進攻' : '派兵占領'}</span></div>
${fromOptions.length ? fromOptions.map((p) => `<button class="target-row" data-attack-from="${p.id}" style="--fc:${fc}">
<span class="target-dot" style="--fc:${g.player.color}"></span>
<span class="target-name">${esc(p.name)}<small>我軍駐地 · ${p.armies} 兵</small></span>
${ico('sword')}
</button>`).join('') : `<div class="target-empty">你的疆域皆不與此地相鄰。先攻下鄰近土地吧。</div>`}
</div>`;
}
const actions = ours ? `
<div class="order-title">軍令</div>
<div class="order-grid">
<button class="order-btn" data-order="recruit" ${!canAct || g.maxRecruit(t.id) < 1 ? 'disabled' : ''}>
<span class="o-ico">${ico('army')}</span>
<span><span class="o-label">招募</span><span class="o-sub">${g.recruitCost(t.id).gold} 金 + 2 糧 / 兵</span></span>
</button>
<button class="order-btn" data-order="march" ${!canAct || selAdjFriend.length === 0 || t.armies <= 2 ? 'disabled' : ''}>
<span class="o-ico">${ico('flag')}</span>
<span><span class="o-label">行軍</span><span class="o-sub">調動 ${Math.max(0, t.armies - 2)} 兵</span></span>
</button>
<button class="order-btn danger" data-order="attack" ${!canAct || selAdjEnemy.length === 0 ? 'disabled' : ''}>
<span class="o-ico">${ico('sword')}</span>
<span><span class="o-label">進攻</span><span class="o-sub">${selAdjEnemy.length} 個目標</span></span>
</button>
<button class="order-btn" data-order="build" ${!canAct ? 'disabled' : ''}>
<span class="o-ico">${ico('build')}</span>
<span><span class="o-label">建造</span><span class="o-sub">${3 - t.buildings.length} 個空位</span></span>
</button>
</div>` : `
<div class="order-title">軍令</div>
<div class="order-grid">
<button class="order-btn danger" data-order="attack" ${!canAct || !isAdjToOurs ? 'disabled' : ''}>
<span class="o-ico">${ico('sword')}</span>
<span><span class="o-label">${t.owner ? '進攻此地' : '占領此地'}</span><span class="o-sub">${fromOptions.length} 條進軍路線</span></span>
</button>
<button class="order-btn" data-order="none" disabled>
<span class="o-ico">${ico('shield')}</span>
<span><span class="o-label">防禦加成</span><span class="o-sub">地形 x${TERRAIN[t.type].bonus}${t.buildings.includes('walls') ? ' + 城牆' : ''}</span></span>
</button>
</div>`;
const buildingSlots = Object.values(BUILDINGS).map((b) => {
const built = t.buildings.includes(b.id);
return `<button class="b-slot ${built ? 'built' : ''}" data-build="${b.id}" ${built || !ours ? 'disabled' : ''} style="--bc:${b.accent}">
<span class="b-img" style="background-image:url('${b.image}')"></span>
<span class="b-body"><span class="b-name">${b.label}</span><span class="b-effect">${built ? '已建成' : b.effect}</span></span>
${built ? `<span class="b-built">✓</span>` : ''}
</button>`;
}).join('');
return `<div class="council">
<div class="council-head"><p class="eyebrow">${ours ? '你的領地' : '異邦疆域'}</p><h2>${ours ? '領地議會' : '異邦疆域'}</h2></div>
<div class="terr-view">
<div class="terr-banner" style="--fc:${fc}">
<div class="tb-top">
<span class="chip" style="color:${fc};border-color:${fc}66;background:${fc}18">${TERR_CN[t.type]}</span>
${ours ? '<span class="chip gold">我方領地</span>' : `<span class="chip">${t.owner ? '敵對勢力' : '無主之地'}</span>`}
</div>
<h2>${esc(t.name)}</h2>
<div class="tb-sub"><b>${esc(owner?.name || '無主之地')}</b><span>·</span><span>${ico('army')} ${t.armies} 駐軍</span></div>
<span class="crest-big">${owner ? crestSvg(owner) : ico('flag')}</span>
</div>
<div class="detail-grid">
<div class="detail-cell"><div class="dk">${ico('army')} 駐軍</div><div class="dv">${t.armies}<small> 單位</small></div></div>
<div class="detail-cell"><div class="dk">${ico('shield')} 防禦倍率</div><div class="dv">×${terrBonus.toFixed(1)}</div><div class="ds">地形 x${TERRAIN[t.type].bonus}${t.buildings.includes('walls') ? ' · 城牆 +0.3' : ''}</div></div>
<div class="detail-cell"><div class="dk">${ico('gold')} 金幣產出</div><div class="dv">+${inc.gold + (t.buildings.includes('market') ? 2 : 0)}<small> /回合</small></div></div>
<div class="detail-cell"><div class="dk">${ico('food')} 糧食產出</div><div class="dv">+${inc.food}<small> /回合</small></div><div class="ds">木 +${inc.wood} · 石 +${inc.stone}</div></div>
</div>
<div class="order-title">建築</div>
<div class="building-strip">${buildingSlots}</div>
${actions}
${modeBlock}
<button class="btn btn-ghost btn-block" data-deselect style="margin-top:2px">${ico('map')} 返回地圖總覽</button>
</div>
</div>`;
}
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 = `
<div class="modal-backdrop" data-close></div>
<div class="modal-wrap">
<div class="modal ${size}">
<button class="modal-close" data-close aria-label="關閉">${ico('close')}</button>
${art ? `<div class="modal-art" style="background-image:url('${art}')">
<div class="art-title">${eyebrow ? `<p class="eyebrow">${esc(eyebrow)}</p>` : ''}<h2>${title}</h2>${subtitle ? `<p>${subtitle}</p>` : ''}</div>
</div>` : ''}
<div class="modal-body">${body}</div>
${actions ? `<div class="modal-actions">${actions}</div>` : ''}
</div>
</div>`;
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)}<span>${esc(msg)}</span>`;
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 = () => `
<p class="lede">在 <b>${esc(t.name)}</b> 升起徵兵旗。每名士兵需 ${cost.gold} 金幣與 2 糧食${t.buildings.includes('barracks') ? ',兵營已為你降低徵募成本。' : '。建造兵營可將金幣成本降至 2。'}</p>
<div class="qty-box">
<div>
<input type="range" class="qty-slider" id="qty-range" min="1" max="${Math.max(1, max)}" value="${qty}" ${max < 1 ? 'disabled' : ''}/>
<div class="qty-meta">
<span>目前駐軍 <b>${t.armies}</b> · 可徵募 <b>${max}</b></span>
<button class="qty-max" id="qty-max" ${max < 1 ? 'disabled' : ''}>全部徵募</button>
</div>
</div>
<div class="qty-stepper">
<button id="qty-minus">−</button>
<span class="qty-value" id="qty-val">${max < 1 ? 0 : qty}</span>
<button id="qty-plus">+</button>
</div>
</div>
<div class="confirm-row">
<span class="ico-wrap">${ico('gold')}</span>
<div><div class="cr-label">總計花費</div><div class="cr-value" id="cost-row">${costItems({ gold: cost.gold * qty, food: 2 * qty }, g.player)}</div></div>
<span class="spacer" style="flex:1"></span>
<div style="text-align:right"><div class="cr-label">徵募後駐軍</div><div class="cr-value" id="after-army">${t.armies + qty}</div></div>
</div>`;
const modal = this.openModal({
art, eyebrow: '徵集兵員', title: '徵募新兵',
subtitle: `${esc(t.name)} · ${TERR_CN[t.type]}`,
body: renderBody(),
actions: `<button class="btn btn-ghost" data-close>取消</button><button class="btn btn-primary" id="do-recruit" ${max < 1 ? 'disabled' : ''}>${ico('army')}<span>徵募 ${max < 1 ? '' : qty} 名士兵</span></button>`,
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')}<span>徵募 ${qty} 名士兵</span>`;
};
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 = () => `
<p class="lede">從 <b>${esc(from.name)}</b> 抽調兵力進駐 <b>${esc(to.name)}</b>。出發地必須留下至少 2 名守軍。</p>
<div class="battle-sides" style="grid-template-columns:1fr auto 1fr">
<div class="battle-side"><div class="bs-role">出征地</div><div class="bs-name">${esc(from.name)}</div><div class="bs-units">${from.armies}<small>駐軍</small></div></div>
<div class="battle-vs">${ico('flag')}</div>
<div class="battle-side"><div class="bs-role">目的地</div><div class="bs-name">${esc(to.name)}</div><div class="bs-units">${to.armies}<small>駐軍</small></div></div>
</div>
<div class="qty-box">
<div>
<input type="range" class="qty-slider" id="qty-range" min="1" max="${max}" value="${qty}"/>
<div class="qty-meta"><span>可調動 <b>${max}</b> 兵(留守 2)</span><button class="qty-max" id="qty-max">全部調動</button></div>
</div>
<div class="qty-stepper"><button id="qty-minus">−</button><span class="qty-value" id="qty-val">${qty}</span><button id="qty-plus">+</button></div>
</div>
<div class="confirm-row">${ico('flag')}<div><div class="cr-label">行軍後</div><div class="cr-value" id="after-row"><b>${from.name}</b> ${from.armies - qty} · <b>${to.name}</b> ${to.armies + qty}</div></div></div>`;
const modal = this.openModal({
art: 'assets/images/map-parchment.png', eyebrow: '行軍號令', title: '行軍',
subtitle: `${esc(from.name)} → ${esc(to.name)}`, body: body(),
actions: `<button class="btn btn-ghost" data-close>取消</button><button class="btn btn-primary" id="do-move">${ico('flag')}<span>行軍 ${qty} 兵</span></button>`,
size: 'medium',
});
const update = () => {
modal.querySelector('#qty-range').value = qty;
modal.querySelector('#qty-val').textContent = qty;
modal.querySelector('#after-row').innerHTML = `<b>${esc(from.name)}</b> ${from.armies - qty} · <b>${esc(to.name)}</b> ${to.armies + qty}`;
modal.querySelector('#do-move').innerHTML = `${ico('flag')}<span>行軍 ${qty} 兵</span>`;
};
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 `<div class="forecast">
<div class="forecast-top"><span>預期戰果</span><b class="${f.tone}">${f.label}</b></div>
<div class="forecast-bar"><i class="${f.tone}" style="width:${Math.max(4, Math.min(100, (f.ratio / 2.4) * 100))}%"></i></div>
<div class="forecast-meta"><span>我軍戰力 <b>${f.atk.toFixed(1)}</b></span><span>守軍戰力 <b>${f.def.toFixed(1)}</b></span><span>地形 ×${(TERRAIN[to.type].bonus + (to.buildings.includes('walls') ? 0.3 : 0)).toFixed(1)}</span></div>
</div>`;
};
const body = () => `
<div class="battle-sides">
<div class="battle-side">
<div class="bs-role">進攻方</div>
<div class="bs-name">${esc(from.name)}</div>
<div class="bs-owner">${esc(g.player.name)}</div>
<div class="bs-units" id="atk-units">${qty}<small>投入兵力</small></div>
</div>
<div class="battle-vs">對</div>
<div class="battle-side defender">
<div class="bs-role">防守方</div>
<div class="bs-name">${esc(to.name)}</div>
<div class="bs-owner">${esc(defender?.name || '無主之地')}</div>
<div class="bs-units">${to.armies}<small>駐軍</small></div>
</div>
</div>
<div class="qty-box">
<div>
<input type="range" class="qty-slider" id="qty-range" min="1" max="${from.armies}" value="${qty}"/>
<div class="qty-meta"><span>可投入 <b>${from.armies}</b> 兵 · 出征地將不留守軍</span><button class="qty-max" id="qty-max">全軍出擊</button></div>
</div>
<div class="qty-stepper"><button id="qty-minus">−</button><span class="qty-value" id="qty-val">${qty}</span><button id="qty-plus">+</button></div>
</div>
<div id="forecast-wrap">${renderForecast()}</div>
<p class="lede" style="margin:14px 0 0;font-size:10.5px;color:var(--muted)">戰鬥為決定性推演:兵力、地形與城牆共同決定勝負,不會有隨機骰子。壓倒性優勢可將損失降到最低。</p>`;
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: `<button class="btn btn-ghost" data-close>按兵不動</button><button class="btn btn-primary" id="do-attack">${ico('sword')}<span>${to.owner ? '發動進攻' : '派兵占領'} · ${qty} 兵</span></button>`,
size: 'medium',
});
const update = () => {
modal.querySelector('#qty-range').value = qty;
modal.querySelector('#qty-val').textContent = qty;
modal.querySelector('#atk-units').innerHTML = `${qty}<small>投入兵力</small>`;
modal.querySelector('#forecast-wrap').innerHTML = renderForecast();
modal.querySelector('#do-attack').innerHTML = `${ico('sword')}<span>${to.owner ? '發動進攻' : '派兵占領'} · ${qty} 兵</span>`;
};
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 = `
<div class="outcome-banner ${won ? 'win' : 'lose'}">
<span class="ob-ico">${won ? ico('crown') : ico('skull')}</span>
<div><h3>${outcomeTitle}</h3><p>${won ? `你的旗幟在 ${esc(res.toId ? g.territory(res.toId).name : '')} 升起。` : `部隊自 ${esc(g.territory(res.fromId).name)} 撤退。`}</p></div>
</div>
<div class="casualties">
<div class="cas-cell"><div class="ck">我軍傷亡</div><div class="cv">${res.result.attackerCasualties}<small> 單位</small></div><div class="ck" style="margin-top:3px">倖存 ${res.survivors}</div></div>
<div class="cas-cell"><div class="ck">敵軍傷亡</div><div class="cv">${res.result.defenderCasualties}<small> 單位</small></div><div class="ck" style="margin-top:3px">原守軍 ${res.defenderUnits}</div></div>
</div>
${res.eliminated ? `<div class="confirm-row" style="border-color:rgba(208,90,76,.4)">${ico('skull')}<div><div class="cr-label">局勢劇變</div><div class="cr-value">${esc(res.eliminated.name)} 已被徹底消滅。</div></div></div>` : ''}
<div class="modal-section-title">戰報</div>
<ul class="battle-log">${res.result.log.map((l) => `<li>${esc(l)}</li>`).join('')}</ul>`;
this.openModal({
art: 'assets/images/battle.png', eyebrow: outcomeEn, title: outcomeTitle,
subtitle: `${res.defenderName || '無主之地'} · 第 ${g.state.turn} 回合`, body,
actions: `<button class="btn btn-primary" data-close>${ico('flag')}<span>繼續</span></button>`,
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 `<div class="build-card ${built ? 'built' : ''} ${focus === b.id ? 'focused' : ''}">
${built ? '<span class="bc-flag">已建成</span>' : ''}
<div class="bc-img" style="background-image:url('${b.image}')"></div>
<div class="bc-body">
<h3>${b.label}</h3>
<div class="bc-effect">${b.effect}</div>
<p class="bc-blurb">${esc(b.blurb)}</p>
<div class="bc-cost">${built ? '<span class="chip gold">效益已生效</span>' : costItems(b.cost, g.player)}</div>
<button class="btn ${built ? 'btn-ghost' : 'btn-primary'}" data-build-do="${b.id}" ${built || !canAct || !afford ? 'disabled' : ''}>
${built ? ico('shield') : ico('build')}<span>${built ? '已建成' : afford ? '建造' : '資源不足'}</span>
</button>
</div>
</div>`;
}).join('');
const modal = this.openModal({
art: 'assets/images/walls.png', eyebrow: '營造工程', title: '營造工程',
subtitle: `${esc(t.name)} · 每回合限建造一座 · 目前行動 ${g.state.actionsLeft}`,
body: `<p class="lede">建築將永久附屬於這塊疆域,即使易主也會保留。城牆強化防禦,兵營降低徵兵成本,市場帶來穩定金流。</p>
<div class="build-grid">${cards}</div>
${!canAct ? '<p class="lede" style="color:var(--danger);margin-top:14px">本回合已無行動,請先結束回合。</p>' : ''}`,
actions: `<span class="spacer"></span><button class="btn btn-ghost" data-close>關閉</button>`,
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 `<div class="diplo-row" style="--fc:${f.color}">
<span class="dr-crest">${crestSvg(f)}</span>
<span style="min-width:0">
<span class="dr-name">${esc(f.name)}</span>
<span class="dr-meta">${PERS_CN[f.personality]} · ${f.territories.length} 疆域 · <span style="color:${status === 'allied' ? 'var(--good)' : status === 'peace' ? 'var(--info)' : 'var(--muted)'}">${dead ? '已滅亡' : label}</span></span>
</span>
<span class="dr-actions">
<button class="diplo-btn ${selected ? 'gold' : ''}" data-trade="${f.id}" ${dead || !canAct ? 'disabled' : ''}>貿易</button>
<button class="diplo-btn" data-peace="${f.id}" ${dead || !canAct || status === 'allied' || status === 'peace' ? 'disabled' : ''}>停戰</button>
<button class="diplo-btn gold" data-ally="${f.id}" ${dead || !canAct || status === 'allied' ? 'disabled' : ''}>同盟</button>
</span>
</div>`;
}).join('');
let tradeHtml = '';
if (this.tradeTarget) {
const f = g.faction(this.tradeTarget);
tradeHtml = `<div class="trade-box">
<div class="modal-section-title" style="margin-top:0">與 ${esc(f.name)} 貿易</div>
<div class="trade-row">
<label>你提供<select id="trade-give-res">${Object.keys(RES_META).map((k) => `<option value="${k}">${RES_META[k].label}(持有 ${g.player[k]})</option>`).join('')}</select></label>
<label>數量<input id="trade-give-amt" type="number" min="1" value="5" /></label>
<label>換取<select id="trade-get-res">${Object.keys(RES_META).map((k) => `<option value="${k}" ${k === 'food' ? 'selected' : ''}>${RES_META[k].label}(對方 ${f[k]})</option>`).join('')}</select></label>
<label>&nbsp;<button class="btn btn-primary" id="trade-submit">${ico('trade')}<span>提出</span></button></label>
</div>
<p class="lede" style="margin:8px 0 0;font-size:10px;color:var(--muted)">商業勢力匯率最佳(0.8),侵略勢力最差(1.5)。</p>
</div>`;
}
const modal = this.openModal({
art: 'assets/images/council.png', eyebrow: '外交議會', title: '外交議會',
subtitle: `本回合尚餘 ${g.state.actionsLeft} 個行動 · 每次交涉消耗 1 次`,
body: `<p class="lede">在刀劍之外,言語同樣能改變疆界。同盟能阻止戰火,停戰爭取喘息,貿易則讓國庫充盈。</p>
<div class="diplo-list">${rows}</div>${tradeHtml}`,
actions: `<span class="spacer"></span><button class="btn btn-ghost" data-close>關閉</button>`,
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 ? `<ul class="log-list">${logs.map((l) => `
<li class="log-item kind-${l.kind}"><span class="li-turn">第 ${l.turn} 回合</span><span class="li-text">${esc(l.text)}</span></li>`).join('')}</ul>`
: `<div class="empty-state">${ico('scroll')}編年史尚為空白。你的第一道命令將成為傳說的開端。</div>`;
this.openModal({
art: 'assets/images/map-parchment.png', eyebrow: '王朝編年史', title: '王朝編年史',
subtitle: `第 ${g.state.turn} 回合 · ${g.state.identity.nationName}`,
body, actions: `<span class="spacer"></span><button class="btn btn-ghost" data-close>關閉</button>`,
size: 'medium',
});
}
// ── help ──────────────────────────────────────────────────────────────────
openHelp() {
const body = `
<p class="lede">你是一位新登基的君主。目標只有一個:<b>征服地圖上的所有疆域</b>。每回合有 3 個行動,謹慎分配於軍事、建設與外交。</p>
<div class="modal-section-title">核心循環</div>
<div class="report-grid">
<div class="report-panel">
<h4>每個回合</h4>
<div class="report-line"><span>1 · 下達軍令</span><b>3 個行動</b></div>
<div class="report-line"><span>2 · 結束回合</span><b>收取資源</b></div>
<div class="report-line"><span>3 · 敵軍行動</span><b>招募與進攻</b></div>
<div class="report-line"><span>4 · 新回合</span><b>行動重置</b></div>
</div>
<div class="report-panel">
<h4>四種資源</h4>
<div class="report-line"><span>金幣 · 徵兵與建造</span><b>${ico('gold')}</b></div>
<div class="report-line"><span>糧食 · 徵兵與軍隊維持</span><b>${ico('food')}</b></div>
<div class="report-line"><span>木材 · 建造建築</span><b>${ico('wood')}</b></div>
<div class="report-line"><span>石料 · 建造與城防</span><b>${ico('stone')}</b></div>
</div>
</div>
<div class="modal-section-title">戰鬥推演</div>
<p class="lede" style="font-size:11px">戰鬥沒有隨機數。戰力 = 兵力 × 士氣,防守方再乘以地形與城牆加成。平原 ×1.0、森林 ×1.2、城市 ×1.3、山地 ×1.5;城牆額外 +0.3。<br><b>2:1 以上</b>為壓倒性勝利,<b>1.2:1</b> 為勝利,<b>1:1</b> 為慘勝,低於 <b>0.8:1</b> 則潰敗。</p>
<div class="modal-section-title">建築與外交</div>
<p class="lede" style="font-size:11px">城牆提升防禦;兵營讓徵兵金幣成本從 3 降至 2;市場每回合 +2 金幣。外交可提出同盟、停戰或貿易,成功率取決於對方性格,弱小的勢力更容易接受。</p>
<div class="modal-section-title">介面提示</div>
<p class="lede" style="font-size:11px">先點選你自己的疆域,再點擊相鄰疆域:友軍→行軍,敵軍或無主地→進攻。右側議會提供完整軍令與建築卡。所有進度會自動保存在瀏覽器。</p>`;
this.openModal({
art: 'assets/images/council.png', eyebrow: '征戰指南', title: '征戰指南',
subtitle: '從一位君主,到一個帝國。',
body, actions: `<span class="spacer"></span><button class="btn btn-primary" data-close>${ico('crown')}<span>開始征途</span></button>`,
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 = `
<p class="lede">第 <b>${report.turn}</b> 回合落幕。使者與軍隊同時抵達,帝國的帳冊與戰報如下。</p>
<div class="report-grid">
<div class="report-panel">
<h4>本回合收支</h4>
<div class="report-line"><span>${ico('gold')} 金幣收入</span><b class="pos">+${report.income.gold}</b></div>
<div class="report-line"><span>${ico('food')} 糧食收入</span><b class="pos">+${report.income.food}</b></div>
<div class="report-line"><span>${ico('wood')} 木材收入</span><b class="pos">+${report.income.wood}</b></div>
<div class="report-line"><span>${ico('stone')} 石料收入</span><b class="pos">+${report.income.stone}</b></div>
<div class="report-line"><span>軍隊糧食維持</span><b class="neg">-${report.upkeep.food}</b></div>
<div class="report-line" style="border-top:1px solid var(--line-soft);margin-top:5px;padding-top:8px"><span>淨收益</span><b class="${net.food >= 0 ? 'pos' : 'neg'}">${fmt(net.gold)}金 · ${fmt(net.food)}糧</b></div>
</div>
<div class="report-panel">
<h4>勢力動向</h4>
${events.length ? `<ul class="report-events">${events.slice(0, 7).map((e) => `<li>${ico(e.icon)}<span>${esc(e.text)}</span></li>`).join('')}</ul>` : `<p class="report-quiet">列強按兵不動。這是暴風雨前的寧靜。</p>`}
</div>
</div>`;
this.openModal({
art: 'assets/images/throne.png', eyebrow: '回合落幕', title: `第 ${report.turn} 回合結束`,
subtitle: events.length ? `${events.length} 件大事發生` : '帝國在沉默中醞釀下一步',
body,
actions: `<button class="btn btn-primary" data-close>${ico('crown')}<span>迎接第 ${g.state.turn} 回合</span></button>`,
size: 'medium',
onClose: () => { if (g.state.isOver) this.showGameOver(); },
});
}
confirmMenu() {
this.openModal({
eyebrow: '離開戰爭地圖', title: '返回主選單?',
body: `<p class="lede">目前的進度會保留在本機瀏覽器中。你可以隨時從主選單點擊「繼續王朝」回到第 ${this.game.state.turn} 回合。</p>`,
actions: `<button class="btn btn-ghost" data-close>留在這裡</button><button class="btn btn-primary" id="confirm-menu">${ico('crown')}<span>返回主選單</span></button>`,
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 = `
<div class="gameover-bg" style="background-image:url('${won ? 'assets/images/throne.png' : 'assets/images/battle.png'}')"></div>
<div class="gameover-inner">
<p class="eyebrow">${won ? '天下歸一' : '王冠墜落'}</p>
<h1>${won ? '勝利' : '敗亡'}</h1>
<h2>${won ? '天下歸一' : '王朝傾覆'}</h2>
<p>${won
? `第 ${g.state.turn} 回合,${esc(g.state.identity.nationName)} 的旗幟插遍每一寸土地。${esc(g.state.identity.leaderName)} 的名字將與神話並列。`
: `第 ${g.state.turn} 回合,${esc(winner?.name || '敵軍')} 的鐵蹄踏碎了你的王座。但歷史從不止於一場敗仗。`}</p>
<div class="gameover-stats">
<div><b>${g.state.turn}</b><span>回合</span></div>
<div><b>${g.state.stats.battlesWon}</b><span>勝仗</span></div>
<div><b>${g.state.stats.captured}</b><span>征服</span></div>
<div><b>${g.state.stats.recruited}</b><span>徵兵</span></div>
</div>
<button class="btn btn-primary btn-lg" id="over-again">${ico('sword')}<span>再啟征程<small>重開新局</small></span></button>
<button class="btn btn-ghost btn-lg" id="over-menu">${ico('crown')}<span>返回主選單<small>回到開端</small></span></button>
</div>`;
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();
});
}
}