Spaces:
Running
Running
Download js/ui.js from HelloSun/empire-web: direct link, hf CLI and curl.
- Browser
- Download file 77.2 kB
-
https://huggingface.co/spaces/HelloSun/empire-web/resolve/main/js/ui.js
- Command line
-
hf download hf://spaces/HelloSun/empire-web/js/ui.js
-
curl -L -o ui.js https://huggingface.co/spaces/HelloSun/empire-web/resolve/main/js/ui.js
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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> <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(); | |
| }); | |
| } | |
| } | |