import { MAPS, MAP_LIST, TERRAIN, BUILDINGS, PERSONALITY, RESOURCES } from './data.js';
import { MAX_ACTIONS } from './game.js';
const CREST = { skull: 'skullcrest' };
const CREST_FALLBACK = 'legion';
const TERR_CN = { plains: '平原', forest: '森林', mountain: '山地', city: '城市' };
const PERS_CN = { aggressive: '侵略', defensive: '防禦', diplomatic: '外交', mercantile: '商業' };
const RES_META = {
gold: { label: '金幣', en: 'GOLD', icon: 'gold', color: '#e3c06a' },
food: { label: '糧食', en: 'FOOD', icon: 'food', color: '#d99668' },
wood: { label: '木材', en: 'WOOD', icon: 'wood', color: '#b08b5c' },
stone: { label: '石料', en: 'STONE', icon: 'stone', color: '#9aa7b4' },
};
const ico = (name, cls = '') => ``;
const crestSvg = (f, cls = '') => ``;
const terrSvg = (type) => ``;
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const fmt = (n) => (n > 0 ? `+${n}` : `${n}`);
function costItems(cost, player) {
return Object.entries(cost).filter(([, v]) => v > 0).map(([k, v]) => {
const lack = player && player[k] < v;
return `${ico(RES_META[k].icon)}${v} ${RES_META[k].label}`;
}).join('');
}
function resMini(value, key) {
return `${ico(RES_META[key].icon)}${value}`;
}
function edgePairs(map) {
const seen = new Set();
const out = [];
for (const t of map.territories) {
for (const a of t.adjacentTo) {
const k = [t.id, a].sort().join('|');
if (seen.has(k)) continue;
seen.add(k);
out.push([t.id, a]);
}
}
return out;
}
function hash(str) {
let h = 2166136261;
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); }
return (h >>> 0) / 4294967295;
}
function smoothClosed(points) {
const n = points.length;
if (!n) return '';
let d = `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)}`;
for (let i = 0; i < n; i++) {
const p0 = points[(i - 1 + n) % n], p1 = points[i], p2 = points[(i + 1) % n], p3 = points[(i + 2) % n];
const c1x = p1[0] + (p2[0] - p0[0]) / 6, c1y = p1[1] + (p2[1] - p0[1]) / 6;
const c2x = p2[0] - (p3[0] - p1[0]) / 6, c2y = p2[1] - (p3[1] - p1[1]) / 6;
d += ` C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;
}
return d + ' Z';
}
function convexHull(pts) {
const p = pts.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
if (p.length < 3) return p;
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const lower = [];
for (const pt of p) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], pt) <= 0) lower.pop();
lower.push(pt);
}
const upper = [];
for (let i = p.length - 1; i >= 0; i--) {
const pt = p[i];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pt) <= 0) upper.pop();
upper.push(pt);
}
upper.pop(); lower.pop();
return lower.concat(upper);
}
function roughPath(cx, cy, rx, ry, seed, points = 13) {
const pts = [];
for (let i = 0; i < points; i++) {
const a = (i / points) * Math.PI * 2;
const r = 0.88 + 0.18 * hash(`${seed}-${i}`);
pts.push([cx + Math.cos(a) * rx * r, cy + Math.sin(a) * ry * r]);
}
return smoothClosed(pts);
}
export class UI {
constructor(game) {
this.game = game;
this.screen = 'menu';
this.selectedTid = null;
this.orderMode = null;
this.tradeTarget = null;
this.selectedMap = 'mainland';
this.selectedFaction = 'iron_legion';
this.pendingGameOver = false;
this._lastRes = null;
this._escHandler = null;
}
// ── boot ──────────────────────────────────────────────────────────────────
init() {
this.bind();
this.renderSetup();
this.refreshMenu();
this.spawnEmbers();
this.game.on((state, type, payload) => this.onChange(type, payload));
}
showScreen(name) {
for (const el of document.querySelectorAll('.screen')) el.classList.remove('active');
const el = document.getElementById(`screen-${name}`);
if (el) el.classList.add('active');
this.screen = name;
if (name === 'menu') this.refreshMenu();
if (name === 'setup') this.renderSetup();
}
refreshMenu() {
const cont = document.getElementById('btn-continue');
if (cont) cont.classList.toggle('hidden', !this.game.hasSave());
}
spawnEmbers() {
const box = document.querySelector('.menu-embers');
if (!box || box.dataset.ready) return;
box.dataset.ready = '1';
for (let i = 0; i < 22; i++) {
const s = document.createElement('span');
s.className = 'ember';
s.style.left = `${Math.random() * 100}%`;
s.style.animationDuration = `${9 + Math.random() * 14}s`;
s.style.animationDelay = `${Math.random() * 16}s`;
s.style.opacity = (0.2 + Math.random() * 0.7).toFixed(2);
s.style.transform = `scale(${(0.5 + Math.random()).toFixed(2)})`;
box.appendChild(s);
}
}
// ── events ────────────────────────────────────────────────────────────────
bind() {
const on = (sel, ev, fn) => {
const el = typeof sel === 'string' ? document.querySelector(sel) : sel;
if (el) el.addEventListener(ev, fn);
};
on('#btn-new', 'click', () => { this.showScreen('setup'); });
on('#btn-help', 'click', () => this.openHelp());
on('#btn-continue', 'click', () => this.continueGame());
on('#btn-setup-back', 'click', () => this.showScreen('menu'));
on('#map-options', 'click', (e) => {
const card = e.target.closest('[data-map]');
if (!card) return;
this.selectedMap = card.dataset.map;
const map = MAPS[this.selectedMap];
if (!map.factions.some((f) => f.id === this.selectedFaction)) this.selectedFaction = map.factions[0].id;
this.renderSetup();
});
on('#faction-options', 'click', (e) => {
const card = e.target.closest('[data-faction]');
if (!card) return;
this.selectedFaction = card.dataset.faction;
this.renderSetup();
});
on('#in-leader', 'input', () => this.updateSummary());
on('#in-nation', 'input', () => this.updateSummary());
on('#in-slogan', 'input', () => this.updateSummary());
on('#btn-begin', 'click', () => this.beginGame());
on('#btn-end-turn', 'click', () => this.endTurn());
on('#btn-save', 'click', () => { this.game.save(); this.toast('進度已保存至本機', 'good', 'save'); });
on('#btn-game-menu', 'click', () => this.confirmMenu());
document.addEventListener('click', (e) => {
const opener = e.target.closest('[data-open]');
if (opener) {
const what = opener.dataset.open;
if (what === 'help') this.openHelp();
if (what === 'chronicle') this.openChronicle();
if (what === 'diplomacy') this.openDiplomacy();
}
});
on('#map-stage', 'click', (e) => {
if (e.target.closest('.map-tooltip')) return;
const g = e.target.closest('.terr');
if (g) this.handleTerritoryClick(g.dataset.id);
});
on('#map-stage', 'mousemove', (e) => this.handleMapHover(e));
on('#map-stage', 'mouseleave', () => this.hideTooltip());
on('#map-stage', 'keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const g = e.target.closest('.terr');
if (g) { e.preventDefault(); this.handleTerritoryClick(g.dataset.id); }
});
on('#chronicle-peek', 'click', () => this.openChronicle());
}
onChange(type) {
if (type === 'new') { this.selectedTid = null; this.orderMode = null; }
if (type === 'load') { this.selectedTid = null; this.orderMode = null; }
if (this.screen === 'game') {
this.renderAll();
if (this.game.state.isOver) this.pendingGameOver = true;
}
if (this.screen === 'menu') this.refreshMenu();
}
// ── setup ─────────────────────────────────────────────────────────────────
renderSetup() {
const map = MAPS[this.selectedMap];
const factions = map.factions;
if (!factions.some((f) => f.id === this.selectedFaction)) this.selectedFaction = factions[0].id;
const mapEl = document.getElementById('map-options');
if (mapEl) {
mapEl.innerHTML = MAP_LIST.map((m) => {
const sel = m.id === this.selectedMap;
return ``;
}).join('');
}
const facEl = document.getElementById('faction-options');
if (facEl) {
facEl.innerHTML = factions.map((f) => {
const sel = f.id === this.selectedFaction;
return ``;
}).join('');
}
const f = factions.find((x) => x.id === this.selectedFaction) || factions[0];
const badge = document.getElementById('identity-crest');
if (badge) {
badge.style.setProperty('--fc', f.color);
badge.innerHTML = crestSvg(f);
}
const nation = document.getElementById('in-nation');
if (nation) nation.placeholder = f.name;
const leader = document.getElementById('in-leader');
if (leader) leader.placeholder = '你的名字';
const slogan = document.getElementById('in-slogan');
if (slogan) slogan.placeholder = '榮耀就在前方!';
this.updateSummary();
}
updateSummary() {
const map = MAPS[this.selectedMap];
const f = map.factions.find((x) => x.id === this.selectedFaction) || map.factions[0];
const leader = document.getElementById('in-leader')?.value.trim() || '君主';
const nation = document.getElementById('in-nation')?.value.trim() || f.name;
const slogan = document.getElementById('in-slogan')?.value.trim() || '榮耀就在前方!';
const sum = document.getElementById('setup-summary');
if (!sum) return;
sum.innerHTML = `
世界${esc(map.name)}
勢力${esc(f.name)}
君主${esc(leader)} · ${esc(nation)}
格言“${esc(slogan)}”
`;
const btn = document.getElementById('btn-begin');
if (btn) btn.disabled = !this.selectedMap || !this.selectedFaction;
}
beginGame() {
const leader = document.getElementById('in-leader')?.value.trim();
const nation = document.getElementById('in-nation')?.value.trim();
const slogan = document.getElementById('in-slogan')?.value.trim();
this.game.newGame({
mapId: this.selectedMap,
factionId: this.selectedFaction,
identity: { leaderName: leader, nationName: nation, slogan },
});
this.showScreen('game');
this.selectedTid = null;
this.orderMode = null;
this.renderAll();
this.toast(`${this.game.state.identity.leaderName} 登基,${this.game.state.identity.nationName} 的旗幟升起`, 'good', 'crown');
}
continueGame() {
const state = this.game.loadFromStorage();
if (!state) { this.toast('找不到存檔', 'bad', 'help'); return; }
this.showScreen('game');
this.selectedTid = null;
this.orderMode = null;
this.renderAll();
this.toast(`王朝延續:第 ${this.game.state.turn} 回合`, 'good', 'crown');
}
// ── main render ───────────────────────────────────────────────────────────
renderAll() {
if (!this.game.state) return;
this.renderTopbar();
this.renderSovereign();
this.renderStats();
this.renderObjective();
this.renderRelations();
this.renderMap();
this.renderCouncil();
this.renderMapFoot();
}
renderTopbar() {
const { player } = this.game;
const state = this.game.state;
const income = this.game.income();
const upkeep = this.game.upkeep();
const nets = { gold: income.gold, food: income.food - upkeep.food, wood: income.wood, stone: income.stone };
const el = document.getElementById('resource-bar');
if (el) {
el.innerHTML = Object.keys(RES_META).map((k) => {
const delta = nets[k];
const changed = this._lastRes && this._lastRes[k] !== player[k];
return `
${ico(RES_META[k].icon)}
${player[k]}
${RES_META[k].label}
${fmt(delta)}
`;
}).join('');
}
this._lastRes = { gold: player.gold, food: player.food, wood: player.wood, stone: player.stone };
const turn = document.getElementById('turn-num');
if (turn) turn.textContent = state.turn;
const pips = document.getElementById('action-pips');
if (pips) {
pips.innerHTML = Array.from({ length: MAX_ACTIONS }, (_, i) =>
``).join('');
}
const end = document.getElementById('btn-end-turn');
if (end) {
end.disabled = state.isOver;
end.style.animation = state.actionsLeft === 0 && !state.isOver ? 'end-pulse 2.2s ease-in-out infinite' : '';
}
}
renderSovereign() {
const f = this.game.player;
const id = this.game.state.identity;
const el = document.getElementById('sovereign-card');
if (!el) return;
el.style.setProperty('--fc', f.color);
el.innerHTML = `
${PERS_CN[f.personality]}
${esc(id.nationName)}
${esc(id.leaderName)} · ${esc(f.name)}
“${esc(id.slogan)}”
`;
}
renderStats() {
const g = this.game;
const p = g.player;
const inc = g.income();
const up = g.upkeep();
const total = Object.keys(g.state.territories).length;
const el = document.getElementById('stats-panel');
if (!el) return;
el.innerHTML = `
國勢
${g.state.turn} 回合
${ico('flag')}疆域
${p.territories.length} / ${total}
${ico('army')}兵力
${g.factionTerritories(p.id).reduce((s, t) => s + t.armies, 0)} 單位
${ico('gold')}國庫
${p.gold} 金
${fmt(inc.gold)} 每回合
${ico('food')}糧倉
${p.food} 糧
${fmt(inc.food - up.food)} 淨值
${ico('spark')}每回合物資產出
${ico('gold')}+${inc.gold}
${ico('food')}+${inc.food}
${ico('wood')}+${inc.wood}
${ico('stone')}+${inc.stone}
維持 -${up.food} 糧
`;
}
renderObjective() {
const g = this.game;
const total = Object.keys(g.state.territories).length;
const owned = g.player.territories.length;
const pct = Math.round((owned / total) * 100);
const rivals = Object.values(g.state.factions).filter((f) => f.id !== g.player.id && f.territories.length > 0)
.sort((a, b) => b.territories.length - a.territories.length);
const top = rivals[0];
const el = document.getElementById('objective-panel');
if (!el) return;
el.innerHTML = `
霸業
${pct}%
已征服 ${owned} / ${total} 疆域還差 ${Math.max(0, total - owned)}
${top ? `最強對手:${esc(top.name)}(${top.territories.length} 疆域)` : '再無對手,世界盡歸你手。'}
`;
}
renderRelations() {
const g = this.game;
const el = document.getElementById('relations-list');
if (!el) return;
const others = Object.values(g.state.factions).filter((f) => f.id !== g.player.id)
.sort((a, b) => b.territories.length - a.territories.length);
el.innerHTML = others.map((f) => {
const rel = g.relationWith(f.id);
const status = rel?.status || 'neutral';
const label = status === 'allied' ? '同盟' : status === 'peace' ? `停戰${rel.turnsRemaining > 0 ? ' ' + rel.turnsRemaining : ''}` : status === 'hostile' ? '敵對' : '中立';
const dead = f.territories.length === 0;
return `
${crestSvg(f)}
${esc(f.name)}
${f.territories.length} 域
${dead ? '滅亡' : label}
`;
}).join('');
}
renderMapFoot() {
const g = this.game;
const el = document.getElementById('turn-status');
if (el) {
el.innerHTML = g.state.actionsLeft > 0
? `本回合尚餘 ${g.state.actionsLeft} 個行動`
: '行動已用盡 · 請結束回合';
}
const last = [...g.state.log].reverse().find((l) => l.kind !== 'story');
const latest = document.getElementById('latest-log');
if (latest) latest.textContent = last ? last.text : (g.state.log.at(-1)?.text || '你的故事從這裡開始……');
}
// ── setup mini map ────────────────────────────────────────────────────────
miniMapSvg(map) {
const [vx, vy, vw, vh] = map.viewBox;
const pos = map.positions;
const routes = edgePairs(map).map(([a, b]) => {
const p1 = pos[a], p2 = pos[b];
if (!p1 || !p2) return '';
return ``;
}).join('');
const nodes = map.territories.map((t) => {
const p = pos[t.id];
const f = map.factions.find((x) => x.id === t.owner);
const c = f?.color || '#7b8494';
if (!p) return '';
return `
`;
}).join('');
return ``;
}
// ── map ───────────────────────────────────────────────────────────────────
renderMap() {
const g = this.game;
const state = g.state;
const map = MAPS[state.mapId];
const mapName = document.getElementById('map-name');
if (mapName) mapName.textContent = map.name;
const [vx, vy, vw, vh] = map.viewBox;
const svg = document.getElementById('map-svg');
if (!svg) return;
svg.setAttribute('viewBox', `${vx} ${vy} ${vw} ${vh}`);
svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
const pos = map.positions;
const sel = this.selectedTid ? g.territory(this.selectedTid) : null;
const selOwner = sel?.owner || null;
const player = g.state.playerFactionId;
const pts = map.territories.map((t) => pos[t.id]).filter(Boolean);
const centroid = pts.reduce((a, p) => [a[0] + p[0] / pts.length, a[1] + p[1] / pts.length], [0, 0]);
let land = '';
if (map.style === 'continent') {
const hull = convexHull(pts);
const expanded = hull.map((p, i) => {
const dx = p[0] - centroid[0], dy = p[1] - centroid[1];
const len = Math.hypot(dx, dy) || 1;
const pad = 78 + hash(`hull-${i}`) * 34;
return [p[0] + (dx / len) * pad, p[1] + (dy / len) * pad];
});
const d = smoothClosed(expanded);
land = `
`;
} else {
land = map.territories.map((t) => {
const p = pos[t.id];
if (!p) return '';
const seed = hash(t.id);
const rx = 58 + seed * 12, ry = 48 + seed * 10;
return `
`;
}).join('');
}
const routes = edgePairs(map).map(([a, b]) => {
const p1 = pos[a], p2 = pos[b];
if (!p1 || !p2) return '';
const mx = (p1[0] + p2[0]) / 2, my = (p1[1] + p2[1]) / 2;
const dx = p2[0] - p1[0], dy = p2[1] - p1[1];
const len = Math.hypot(dx, dy) || 1;
const bend = (hash(a + b) - 0.5) * 34;
const cx = mx + (-dy / len) * bend, cy = my + (dx / len) * bend;
const touchesSel = sel && (a === sel.id || b === sel.id);
const otherId = sel ? (a === sel.id ? b : b === sel.id ? a : null) : null;
const other = otherId ? g.territory(otherId) : null;
let cls = '';
if (touchesSel) {
const isHostile = selOwner === player ? (other && other.owner !== player) : other?.owner === player;
cls = isHostile ? 'active hostile' : 'active';
}
return ``;
}).join('');
const nodes = map.territories.map((tpl) => this.mapNode(g.territory(tpl.id), map, sel, player)).filter(Boolean).join('');
svg.innerHTML = `
${land}
${routes}
${nodes}`;
// hide hint once selected
const hint = document.getElementById('map-hint');
if (hint) {
hint.classList.toggle('hidden-hint', !!sel);
hint.textContent = sel
? (sel.owner === player ? `${sel.name} · 點擊相鄰疆域下達軍令` : `${sel.name} · 點擊你相鄰的疆域籌劃進攻`)
: '點選疆域以查看詳情';
}
}
mapNode(t, map, sel, player) {
const g = this.game;
const p = map.positions[t.id];
if (!p) return '';
const owner = t.owner ? g.faction(t.owner) : null;
const fc = owner?.color || '#7b8494';
const isSel = sel?.id === t.id;
const isAdj = sel && sel.adjacentTo.includes(t.id);
const isPlayerTerr = t.owner === player;
const selectedIsOurs = sel?.owner === player;
const selectedIsEnemy = sel && sel.owner !== player;
let ring = '';
if (isSel) ring = ``;
else if (isAdj) {
if (selectedIsOurs) ring = isPlayerTerr ? `` : ``;
else if (selectedIsEnemy && isPlayerTerr) ring = ``;
}
const dim = sel && !isSel && !isAdj ? 'dimmed' : '';
const buildings = t.buildings.map((b, i) => ``).join('');
const ownerLabel = owner ? owner.name : '無主之地';
const tname = esc(t.name);
const labelY = 57;
return `
${ring}
${terrSvg(t.type)}
${buildings}
${t.armies}
${tname}
${esc(ownerLabel)}
`;
}
handleTerritoryClick(tid) {
if (!this.game.state || this.game.state.isOver) return;
const g = this.game;
const target = g.territory(tid);
if (!target) return;
const sel = this.selectedTid ? g.territory(this.selectedTid) : null;
const player = g.state.playerFactionId;
if (sel) {
if (sel.id === tid) { this.selectedTid = null; this.orderMode = null; this.renderAll(); return; }
if (sel.adjacentTo.includes(tid)) {
if (sel.owner === player && target.owner === player) {
this.openMoveModal(sel.id, tid);
return;
}
if (sel.owner === player && target.owner !== player) {
this.openAttackModal(sel.id, tid);
return;
}
if (sel.owner !== player && target.owner === player) {
this.openAttackModal(tid, sel.id);
return;
}
}
}
this.selectedTid = tid;
this.orderMode = null;
this.renderAll();
}
handleMapHover(e) {
const gEl = e.target.closest('.terr');
const tip = document.getElementById('map-tooltip');
const stage = document.getElementById('map-stage');
if (!tip || !stage) return;
if (!gEl) { this.hideTooltip(); return; }
const t = this.game.territory(gEl.dataset.id);
if (!t) return;
const owner = t.owner ? this.game.faction(t.owner) : null;
const rect = stage.getBoundingClientRect();
const x = e.clientX - rect.left, y = e.clientY - rect.top;
const sel = this.selectedTid ? this.game.territory(this.selectedTid) : null;
let hint = '';
if (sel && sel.id !== t.id && sel.adjacentTo.includes(t.id)) {
if (sel.owner === this.game.state.playerFactionId) hint = t.owner === this.game.state.playerFactionId ? '點擊:行軍至此' : '點擊:發動進攻';
else if (t.owner === this.game.state.playerFactionId) hint = '點擊:由此進攻';
} else if (!sel) {
hint = t.owner === this.game.state.playerFactionId ? '點擊:查看並下達軍令' : '點擊:查看疆域';
}
tip.innerHTML = `
${esc(t.name)}
${esc(owner?.name || '無主之地')} · ${TERR_CN[t.type]}
${ico('army')} 駐軍${t.armies}
${ico('gold')} 產出+${t.resources.gold + (t.buildings.includes('market') ? 2 : 0)}
${t.buildings.length ? `${ico('build')} 建築${t.buildings.map((b) => BUILDINGS[b].label).join('、')}
` : ''}
${hint ? `${hint}
` : ''}`;
tip.style.left = `${x}px`;
tip.style.top = `${y}px`;
tip.classList.add('show');
}
hideTooltip() {
const tip = document.getElementById('map-tooltip');
if (tip) tip.classList.remove('show');
}
// ── council ───────────────────────────────────────────────────────────────
renderCouncil() {
const el = document.getElementById('council-panel');
if (!el) return;
const t = this.selectedTid ? this.game.territory(this.selectedTid) : null;
el.innerHTML = t ? this.terrCouncil(t) : this.defaultCouncil();
this.bindCouncil(el);
}
defaultCouncil() {
const g = this.game;
const map = MAPS[g.state.mapId];
return `
${esc(map.name)}
${esc(g.state.identity.nationName)}的下一步
選擇地圖上的疆域,或從下方召集議事。每一道命令都將消耗一次行動。
軍令提示:先點選你自己的疆域,再點擊相鄰疆域即可招募、行軍或進攻。市場能增加金幣收入,兵營能降低徵兵成本。
勢力圖騰
${Object.values(g.state.factions).filter((f) => f.id !== g.player.id).map((f) => `
${crestSvg(f)}
${esc(f.name)}
${f.territories.length} 域
${g.relationWith(f.id)?.status === 'allied' ? '同盟' : g.relationWith(f.id)?.status === 'peace' ? '停戰' : '中立'}
`).join('')}
`;
}
terrCouncil(t) {
const g = this.game;
const player = g.state.playerFactionId;
const owner = t.owner ? g.faction(t.owner) : null;
const fc = owner?.color || '#7b8494';
const ours = t.owner === player;
const terrBonus = TERRAIN[t.type].bonus + (t.buildings.includes('walls') ? 0.3 : 0);
const inc = t.resources;
const isAdjToOurs = !ours && g.playerTerritories().some((p) => p.adjacentTo.includes(t.id));
const selAdjEnemy = ours ? t.adjacentTo.filter((id) => g.territory(id)?.owner !== player) : [];
const selAdjFriend = ours ? t.adjacentTo.filter((id) => g.territory(id)?.owner === player) : [];
const fromOptions = !ours ? g.playerTerritories().filter((p) => p.adjacentTo.includes(t.id)) : [];
const canAct = g.state.actionsLeft > 0 && !g.state.isOver;
let modeBlock = '';
if (this.orderMode === 'march' && ours) {
modeBlock = `
選擇行軍目的地
${selAdjFriend.length ? selAdjFriend.map((id) => {
const x = g.territory(id);
return `
`;
}).join('') : `
沒有相鄰的友軍疆域可供行軍。
`}
`;
} else if (this.orderMode === 'attack' && ours) {
modeBlock = `
選擇進攻目標
${selAdjEnemy.length ? selAdjEnemy.map((id) => {
const x = g.territory(id);
const o = x.owner ? g.faction(x.owner) : null;
return `
`;
}).join('') : `
附近沒有可進攻的疆域。
`}
`;
} else if (!ours) {
modeBlock = `
${t.owner ? '從相鄰疆域進攻' : '派兵占領'}
${fromOptions.length ? fromOptions.map((p) => `
`).join('') : `
你的疆域皆不與此地相鄰。先攻下鄰近土地吧。
`}
`;
}
const actions = ours ? `
軍令
` : `
軍令
`;
const buildingSlots = Object.values(BUILDINGS).map((b) => {
const built = t.buildings.includes(b.id);
return ``;
}).join('');
return `
${ours ? '你的領地' : '異邦疆域'}
${ours ? '領地議會' : '異邦疆域'}
${TERR_CN[t.type]}
${ours ? '我方領地' : `${t.owner ? '敵對勢力' : '無主之地'}`}
${esc(t.name)}
${esc(owner?.name || '無主之地')}·${ico('army')} ${t.armies} 駐軍
${owner ? crestSvg(owner) : ico('flag')}
${ico('army')} 駐軍
${t.armies} 單位
${ico('shield')} 防禦倍率
×${terrBonus.toFixed(1)}
地形 x${TERRAIN[t.type].bonus}${t.buildings.includes('walls') ? ' · 城牆 +0.3' : ''}
${ico('gold')} 金幣產出
+${inc.gold + (t.buildings.includes('market') ? 2 : 0)} /回合
${ico('food')} 糧食產出
+${inc.food} /回合
木 +${inc.wood} · 石 +${inc.stone}
建築
${buildingSlots}
${actions}
${modeBlock}
`;
}
bindCouncil(el) {
const g = this.game;
el.querySelectorAll('[data-action="save"]').forEach((b) => b.addEventListener('click', () => {
g.save(); this.toast('進度已保存至本機', 'good', 'save');
}));
el.querySelectorAll('[data-deselect]').forEach((b) => b.addEventListener('click', () => {
this.selectedTid = null; this.orderMode = null; this.renderAll();
}));
el.querySelectorAll('[data-cancel-mode]').forEach((b) => b.addEventListener('click', () => {
this.orderMode = null; this.renderAll();
}));
el.querySelectorAll('[data-order]').forEach((b) => b.addEventListener('click', () => {
const order = b.dataset.order;
const t = g.territory(this.selectedTid);
if (order === 'recruit') this.openRecruitModal(t.id);
if (order === 'build') this.openBuildModal(t.id);
if (order === 'march') { this.orderMode = this.orderMode === 'march' ? null : 'march'; this.renderAll(); }
if (order === 'attack') {
if (t.owner !== g.state.playerFactionId) {
const options = g.playerTerritories().filter((p) => p.adjacentTo.includes(t.id));
if (options.length === 1) this.openAttackModal(options[0].id, t.id);
else { this.orderMode = this.orderMode === 'attack' ? null : 'attack'; this.renderAll(); }
} else { this.orderMode = this.orderMode === 'attack' ? null : 'attack'; this.renderAll(); }
}
}));
el.querySelectorAll('[data-attack]').forEach((b) => b.addEventListener('click', () => {
this.openAttackModal(this.selectedTid, b.dataset.attack);
}));
el.querySelectorAll('[data-attack-from]').forEach((b) => b.addEventListener('click', () => {
this.openAttackModal(b.dataset.attackFrom, this.selectedTid);
}));
el.querySelectorAll('[data-move]').forEach((b) => b.addEventListener('click', () => {
this.openMoveModal(this.selectedTid, b.dataset.move);
}));
el.querySelectorAll('[data-build]').forEach((b) => b.addEventListener('click', () => {
const t = g.territory(this.selectedTid);
if (t && !t.buildings.includes(b.dataset.build)) this.openBuildModal(t.id, b.dataset.build);
}));
}
// ── modals ────────────────────────────────────────────────────────────────
openModal({ art, eyebrow, title, subtitle, body, actions, size = '', onMount, onClose }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
${art ? `
${eyebrow ? `
${esc(eyebrow)}
` : ''}
${title}
${subtitle ? `
${subtitle}
` : ''}
` : ''}
${body}
${actions ? `
${actions}
` : ''}
`;
root.classList.add('open');
root.querySelectorAll('[data-close]').forEach((b) => b.addEventListener('click', () => this.closeModal()));
if (this._escHandler) document.removeEventListener('keydown', this._escHandler);
this._escHandler = (e) => { if (e.key === 'Escape') this.closeModal(); };
document.addEventListener('keydown', this._escHandler);
this._closeCallback = onClose || null;
if (onMount) onMount(root.querySelector('.modal'));
return root.querySelector('.modal');
}
closeModal() {
const root = document.getElementById('modal-root');
if (!root) return;
root.classList.remove('open');
root.innerHTML = '';
if (this._escHandler) { document.removeEventListener('keydown', this._escHandler); this._escHandler = null; }
this.tradeTarget = null;
this.hideTooltip();
const cb = this._closeCallback;
this._closeCallback = null;
if (cb) setTimeout(cb, 60);
}
toast(msg, tone = '', icon = 'spark') {
const root = document.getElementById('toast-root');
if (!root) return;
const el = document.createElement('div');
el.className = `toast ${tone}`;
el.innerHTML = `${ico(icon)}${esc(msg)}`;
root.appendChild(el);
setTimeout(() => { el.classList.add('out'); setTimeout(() => el.remove(), 260); }, 3200);
}
// ── recruit ───────────────────────────────────────────────────────────────
openRecruitModal(tid) {
const g = this.game;
const t = g.territory(tid);
if (!t) return;
const max = g.maxRecruit(tid);
const cost = g.recruitCost(tid);
let qty = Math.max(1, Math.min(3, max || 1));
const art = 'assets/images/barracks.png';
const renderBody = () => `
在 ${esc(t.name)} 升起徵兵旗。每名士兵需 ${cost.gold} 金幣與 2 糧食${t.buildings.includes('barracks') ? ',兵營已為你降低徵募成本。' : '。建造兵營可將金幣成本降至 2。'}
${ico('gold')}
總計花費
${costItems({ gold: cost.gold * qty, food: 2 * qty }, g.player)}
`;
const modal = this.openModal({
art, eyebrow: '徵集兵員', title: '徵募新兵',
subtitle: `${esc(t.name)} · ${TERR_CN[t.type]}`,
body: renderBody(),
actions: ``,
size: 'medium',
});
const update = () => {
const range = modal.querySelector('#qty-range');
if (range) range.value = qty;
modal.querySelector('#qty-val').textContent = qty;
modal.querySelector('#cost-row').innerHTML = costItems({ gold: cost.gold * qty, food: 2 * qty }, g.player);
modal.querySelector('#after-army').textContent = t.armies + qty;
const btn = modal.querySelector('#do-recruit');
if (btn) btn.innerHTML = `${ico('army')}徵募 ${qty} 名士兵`;
};
modal.querySelector('#qty-range')?.addEventListener('input', (e) => { qty = parseInt(e.target.value, 10) || 1; update(); });
modal.querySelector('#qty-minus')?.addEventListener('click', () => { qty = Math.max(1, qty - 1); update(); });
modal.querySelector('#qty-plus')?.addEventListener('click', () => { qty = Math.min(Math.max(1, max), qty + 1); update(); });
modal.querySelector('#qty-max')?.addEventListener('click', () => { qty = Math.max(1, max); update(); });
modal.querySelector('#do-recruit')?.addEventListener('click', () => {
const res = g.recruit(tid, qty);
if (!res.ok) { this.toast(res.msg, 'bad', 'help'); return; }
this.closeModal();
this.toast(`在 ${t.name} 徵募了 ${res.n} 名士兵`, 'good', 'army');
});
}
// ── move ──────────────────────────────────────────────────────────────────
openMoveModal(fromId, toId) {
const g = this.game;
const from = g.territory(fromId), to = g.territory(toId);
if (!from || !to) return;
const max = Math.max(0, from.armies - 2);
if (max < 1) { this.toast(`必須在 ${from.name} 留下 2 名守軍`, 'bad', 'shield'); return; }
let qty = Math.min(max, Math.max(1, Math.floor(from.armies / 2)));
const body = () => `
從 ${esc(from.name)} 抽調兵力進駐 ${esc(to.name)}。出發地必須留下至少 2 名守軍。
出征地
${esc(from.name)}
${from.armies}駐軍
${ico('flag')}
目的地
${esc(to.name)}
${to.armies}駐軍
${ico('flag')}
行軍後
${from.name} ${from.armies - qty} · ${to.name} ${to.armies + qty}
`;
const modal = this.openModal({
art: 'assets/images/map-parchment.png', eyebrow: '行軍號令', title: '行軍',
subtitle: `${esc(from.name)} → ${esc(to.name)}`, body: body(),
actions: ``,
size: 'medium',
});
const update = () => {
modal.querySelector('#qty-range').value = qty;
modal.querySelector('#qty-val').textContent = qty;
modal.querySelector('#after-row').innerHTML = `${esc(from.name)} ${from.armies - qty} · ${esc(to.name)} ${to.armies + qty}`;
modal.querySelector('#do-move').innerHTML = `${ico('flag')}行軍 ${qty} 兵`;
};
modal.querySelector('#qty-range')?.addEventListener('input', (e) => { qty = parseInt(e.target.value, 10) || 1; update(); });
modal.querySelector('#qty-minus')?.addEventListener('click', () => { qty = Math.max(1, qty - 1); update(); });
modal.querySelector('#qty-plus')?.addEventListener('click', () => { qty = Math.min(max, qty + 1); update(); });
modal.querySelector('#qty-max')?.addEventListener('click', () => { qty = max; update(); });
modal.querySelector('#do-move')?.addEventListener('click', () => {
const res = g.move(fromId, toId, qty);
if (!res.ok) { this.toast(res.msg, 'bad', 'help'); return; }
this.closeModal();
this.toast(`${res.n} 兵自 ${from.name} 進駐 ${to.name}`, 'good', 'flag');
});
}
// ── attack ────────────────────────────────────────────────────────────────
openAttackModal(fromId, toId) {
const g = this.game;
const from = g.territory(fromId), to = g.territory(toId);
if (!from || !to) return;
if (from.owner !== g.state.playerFactionId) return;
const defender = to.owner ? g.faction(to.owner) : null;
let qty = from.armies;
const renderForecast = () => {
const f = g.forecast(fromId, toId, qty);
return `
預期戰果${f.label}
我軍戰力 ${f.atk.toFixed(1)}守軍戰力 ${f.def.toFixed(1)}地形 ×${(TERRAIN[to.type].bonus + (to.buildings.includes('walls') ? 0.3 : 0)).toFixed(1)}
`;
};
const body = () => `
進攻方
${esc(from.name)}
${esc(g.player.name)}
${qty}投入兵力
對
防守方
${esc(to.name)}
${esc(defender?.name || '無主之地')}
${to.armies}駐軍
${renderForecast()}
戰鬥為決定性推演:兵力、地形與城牆共同決定勝負,不會有隨機骰子。壓倒性優勢可將損失降到最低。
`;
const modal = this.openModal({
art: 'assets/images/battle.png', eyebrow: to.owner ? '宣戰' : '佔領荒野', title: to.owner ? '發動進攻' : '占領無主之地',
subtitle: `${esc(from.name)} → ${esc(to.name)}`, body: body(),
actions: ``,
size: 'medium',
});
const update = () => {
modal.querySelector('#qty-range').value = qty;
modal.querySelector('#qty-val').textContent = qty;
modal.querySelector('#atk-units').innerHTML = `${qty}投入兵力`;
modal.querySelector('#forecast-wrap').innerHTML = renderForecast();
modal.querySelector('#do-attack').innerHTML = `${ico('sword')}${to.owner ? '發動進攻' : '派兵占領'} · ${qty} 兵`;
};
modal.querySelector('#qty-range')?.addEventListener('input', (e) => { qty = parseInt(e.target.value, 10) || 1; update(); });
modal.querySelector('#qty-minus')?.addEventListener('click', () => { qty = Math.max(1, qty - 1); update(); });
modal.querySelector('#qty-plus')?.addEventListener('click', () => { qty = Math.min(from.armies, qty + 1); update(); });
modal.querySelector('#qty-max')?.addEventListener('click', () => { qty = from.armies; update(); });
modal.querySelector('#do-attack')?.addEventListener('click', () => {
const res = g.attack(fromId, toId, qty);
if (!res.ok) { this.toast(res.msg, 'bad', 'help'); return; }
this.closeModal();
this.showBattleResult(res);
});
}
showBattleResult(res) {
const g = this.game;
const won = res.result.captured;
const outcomeTitle = res.result.outcome === 'uncontested' ? '不戰而得' : won ? '勝利' : '攻勢受挫';
const outcomeEn = res.result.outcome === 'uncontested' ? '不戰而得' : won ? '勝利' : '戰敗';
const body = `
${won ? ico('crown') : ico('skull')}
${outcomeTitle}
${won ? `你的旗幟在 ${esc(res.toId ? g.territory(res.toId).name : '')} 升起。` : `部隊自 ${esc(g.territory(res.fromId).name)} 撤退。`}
我軍傷亡
${res.result.attackerCasualties} 單位
倖存 ${res.survivors}
敵軍傷亡
${res.result.defenderCasualties} 單位
原守軍 ${res.defenderUnits}
${res.eliminated ? `${ico('skull')}
局勢劇變
${esc(res.eliminated.name)} 已被徹底消滅。
` : ''}
戰報
${res.result.log.map((l) => `- ${esc(l)}
`).join('')}
`;
this.openModal({
art: 'assets/images/battle.png', eyebrow: outcomeEn, title: outcomeTitle,
subtitle: `${res.defenderName || '無主之地'} · 第 ${g.state.turn} 回合`, body,
actions: ``,
size: 'medium',
onClose: () => { if (g.state.isOver) this.showGameOver(); },
});
}
// ── build ─────────────────────────────────────────────────────────────────
openBuildModal(tid, focus) {
const g = this.game;
const t = g.territory(tid);
if (!t) return;
const canAct = g.state.actionsLeft > 0;
const cards = Object.values(BUILDINGS).map((b) => {
const built = t.buildings.includes(b.id);
const afford = g.canAfford(b.cost);
return `
${built ? '
已建成' : ''}
${b.label}
${b.effect}
${esc(b.blurb)}
${built ? '效益已生效' : costItems(b.cost, g.player)}
`;
}).join('');
const modal = this.openModal({
art: 'assets/images/walls.png', eyebrow: '營造工程', title: '營造工程',
subtitle: `${esc(t.name)} · 每回合限建造一座 · 目前行動 ${g.state.actionsLeft}`,
body: `建築將永久附屬於這塊疆域,即使易主也會保留。城牆強化防禦,兵營降低徵兵成本,市場帶來穩定金流。
${cards}
${!canAct ? '本回合已無行動,請先結束回合。
' : ''}`,
actions: ``,
size: 'medium',
});
modal.querySelectorAll('[data-build-do]').forEach((b) => b.addEventListener('click', () => {
const type = b.dataset.buildDo;
const res = g.build(tid, type);
if (!res.ok) { this.toast(res.msg, 'bad', 'help'); return; }
this.closeModal();
this.toast(`${BUILDINGS[type].label} 在 ${t.name} 落成`, 'good', 'build');
}));
}
// ── diplomacy ─────────────────────────────────────────────────────────────
openDiplomacy() {
const g = this.game;
const canAct = g.state.actionsLeft > 0 && !g.state.isOver;
const rows = Object.values(g.state.factions).filter((f) => f.id !== g.player.id).map((f) => {
const rel = g.relationWith(f.id);
const status = rel?.status || 'neutral';
const label = status === 'allied' ? '同盟' : status === 'peace' ? `停戰 ${rel.turnsRemaining} 回合` : '中立';
const dead = f.territories.length === 0;
const selected = this.tradeTarget === f.id;
return `
${crestSvg(f)}
${esc(f.name)}
${PERS_CN[f.personality]} · ${f.territories.length} 疆域 · ${dead ? '已滅亡' : label}
`;
}).join('');
let tradeHtml = '';
if (this.tradeTarget) {
const f = g.faction(this.tradeTarget);
tradeHtml = `
與 ${esc(f.name)} 貿易
商業勢力匯率最佳(0.8),侵略勢力最差(1.5)。
`;
}
const modal = this.openModal({
art: 'assets/images/council.png', eyebrow: '外交議會', title: '外交議會',
subtitle: `本回合尚餘 ${g.state.actionsLeft} 個行動 · 每次交涉消耗 1 次`,
body: `在刀劍之外,言語同樣能改變疆界。同盟能阻止戰火,停戰爭取喘息,貿易則讓國庫充盈。
${rows}
${tradeHtml}`,
actions: ``,
size: 'medium',
});
const reopen = () => this.openDiplomacy();
modal.querySelectorAll('[data-ally]').forEach((b) => b.addEventListener('click', () => {
const res = g.proposeAlliance(b.dataset.ally);
if (!res.ok) { this.toast(res.msg, 'bad', 'help'); return; }
this.toast(res.msg, res.accepted ? 'good' : '', res.accepted ? 'handshake' : 'dove');
reopen();
}));
modal.querySelectorAll('[data-peace]').forEach((b) => b.addEventListener('click', () => {
const res = g.proposePeace(b.dataset.peace);
if (!res.ok) { this.toast(res.msg, 'bad', 'help'); return; }
this.toast(res.msg, res.accepted ? 'good' : '', 'dove');
reopen();
}));
modal.querySelectorAll('[data-trade]').forEach((b) => b.addEventListener('click', () => {
this.tradeTarget = this.tradeTarget === b.dataset.trade ? null : b.dataset.trade;
reopen();
}));
modal.querySelector('#trade-submit')?.addEventListener('click', () => {
const giveRes = modal.querySelector('#trade-give-res').value;
const giveAmt = parseInt(modal.querySelector('#trade-give-amt').value, 10);
const getRes = modal.querySelector('#trade-get-res').value;
const res = g.proposeTrade(this.tradeTarget, giveRes, giveAmt, getRes);
if (!res.ok) { this.toast(res.msg, 'bad', 'help'); return; }
this.toast(res.msg, res.accepted ? 'good' : '', res.accepted ? 'trade' : 'dove');
this.tradeTarget = null;
reopen();
});
}
// ── chronicle ─────────────────────────────────────────────────────────────
openChronicle() {
const g = this.game;
const logs = g.state.log.slice(-80).reverse();
const body = logs.length ? `${logs.map((l) => `
- 第 ${l.turn} 回合${esc(l.text)}
`).join('')}
`
: `${ico('scroll')}編年史尚為空白。你的第一道命令將成為傳說的開端。
`;
this.openModal({
art: 'assets/images/map-parchment.png', eyebrow: '王朝編年史', title: '王朝編年史',
subtitle: `第 ${g.state.turn} 回合 · ${g.state.identity.nationName}`,
body, actions: ``,
size: 'medium',
});
}
// ── help ──────────────────────────────────────────────────────────────────
openHelp() {
const body = `
你是一位新登基的君主。目標只有一個:征服地圖上的所有疆域。每回合有 3 個行動,謹慎分配於軍事、建設與外交。
核心循環
每個回合
1 · 下達軍令3 個行動
2 · 結束回合收取資源
3 · 敵軍行動招募與進攻
4 · 新回合行動重置
四種資源
金幣 · 徵兵與建造${ico('gold')}
糧食 · 徵兵與軍隊維持${ico('food')}
木材 · 建造建築${ico('wood')}
石料 · 建造與城防${ico('stone')}
戰鬥推演
戰鬥沒有隨機數。戰力 = 兵力 × 士氣,防守方再乘以地形與城牆加成。平原 ×1.0、森林 ×1.2、城市 ×1.3、山地 ×1.5;城牆額外 +0.3。
2:1 以上為壓倒性勝利,1.2:1 為勝利,1:1 為慘勝,低於 0.8:1 則潰敗。
建築與外交
城牆提升防禦;兵營讓徵兵金幣成本從 3 降至 2;市場每回合 +2 金幣。外交可提出同盟、停戰或貿易,成功率取決於對方性格,弱小的勢力更容易接受。
介面提示
先點選你自己的疆域,再點擊相鄰疆域:友軍→行軍,敵軍或無主地→進攻。右側議會提供完整軍令與建築卡。所有進度會自動保存在瀏覽器。
`;
this.openModal({
art: 'assets/images/council.png', eyebrow: '征戰指南', title: '征戰指南',
subtitle: '從一位君主,到一個帝國。',
body, actions: ``,
size: 'medium',
});
}
// ── turn cycle ────────────────────────────────────────────────────────────
endTurn() {
const g = this.game;
if (g.state.isOver) return;
const report = g.endTurn();
if (!report) return;
this.selectedTid = null;
this.orderMode = null;
this.renderAll();
this.showTurnReport(report);
}
showTurnReport(report) {
const g = this.game;
const net = report.net || { gold: 0, food: 0, wood: 0, stone: 0 };
const ai = report.aiLogs || [];
const diplo = report.diploLogs || [];
const events = [
...report.starvation.map((t) => ({ icon: 'food', text: t })),
...ai.map((t) => ({ icon: 'sword', text: t })),
...diplo.map((t) => ({ icon: 'dove', text: t })),
];
const body = `
第 ${report.turn} 回合落幕。使者與軍隊同時抵達,帝國的帳冊與戰報如下。
本回合收支
${ico('gold')} 金幣收入+${report.income.gold}
${ico('food')} 糧食收入+${report.income.food}
${ico('wood')} 木材收入+${report.income.wood}
${ico('stone')} 石料收入+${report.income.stone}
軍隊糧食維持-${report.upkeep.food}
淨收益${fmt(net.gold)}金 · ${fmt(net.food)}糧
勢力動向
${events.length ? `
${events.slice(0, 7).map((e) => `- ${ico(e.icon)}${esc(e.text)}
`).join('')}
` : `
列強按兵不動。這是暴風雨前的寧靜。
`}
`;
this.openModal({
art: 'assets/images/throne.png', eyebrow: '回合落幕', title: `第 ${report.turn} 回合結束`,
subtitle: events.length ? `${events.length} 件大事發生` : '帝國在沉默中醞釀下一步',
body,
actions: ``,
size: 'medium',
onClose: () => { if (g.state.isOver) this.showGameOver(); },
});
}
confirmMenu() {
this.openModal({
eyebrow: '離開戰爭地圖', title: '返回主選單?',
body: `目前的進度會保留在本機瀏覽器中。你可以隨時從主選單點擊「繼續王朝」回到第 ${this.game.state.turn} 回合。
`,
actions: ``,
size: 'small',
});
document.getElementById('confirm-menu')?.addEventListener('click', () => {
this.game.save();
this.closeModal();
this.showScreen('menu');
});
}
// ── game over ─────────────────────────────────────────────────────────────
showGameOver() {
if (document.querySelector('.gameover')) return;
const g = this.game;
const won = g.state.winner === g.state.playerFactionId;
const winner = g.state.winner ? g.faction(g.state.winner) : null;
const el = document.createElement('div');
el.className = 'gameover open';
el.innerHTML = `
${won ? '天下歸一' : '王冠墜落'}
${won ? '勝利' : '敗亡'}
${won ? '天下歸一' : '王朝傾覆'}
${won
? `第 ${g.state.turn} 回合,${esc(g.state.identity.nationName)} 的旗幟插遍每一寸土地。${esc(g.state.identity.leaderName)} 的名字將與神話並列。`
: `第 ${g.state.turn} 回合,${esc(winner?.name || '敵軍')} 的鐵蹄踏碎了你的王座。但歷史從不止於一場敗仗。`}
${g.state.turn}回合
${g.state.stats.battlesWon}勝仗
${g.state.stats.captured}征服
${g.state.stats.recruited}徵兵
`;
document.body.appendChild(el);
el.querySelector('#over-again').addEventListener('click', () => {
el.remove();
this.game.clearSave();
this.showScreen('setup');
});
el.querySelector('#over-menu').addEventListener('click', () => {
el.remove();
this.showScreen('menu');
this.refreshMenu();
});
}
}