empire-web / js /game.js
HelloSun's picture
全面繁體中文化
31075a3 verified
Raw History Blame Contribute Delete
29.1 kB
import { MAPS, BUILDINGS, TERRAIN, RESOURCES } from './data.js';
export const MAX_ACTIONS = 3;
const SAVE_KEY = 'empire-web-save-v1';
const DEFAULT_MORALE = 80;
const TERRAIN_BONUS = { plains: 1.0, forest: 1.2, mountain: 1.5, city: 1.3 };
const ACCEPT_RATES = {
aggressive: [0.15, 0.20, 0.40],
defensive: [0.50, 0.70, 0.50],
mercantile: [0.40, 0.50, 0.80],
diplomatic: [0.80, 0.85, 0.70],
};
const TRADE_MARKUP = { aggressive: 1.5, defensive: 1.2, mercantile: 0.8, diplomatic: 1.0 };
const clone = (x) => JSON.parse(JSON.stringify(x));
const clamp0 = (n) => Math.max(0, n);
export class Game {
constructor() {
this.state = null;
this._handlers = new Set();
this._id = 0;
}
on(fn) { this._handlers.add(fn); return () => this._handlers.delete(fn); }
emit(type = 'change', payload = null) {
for (const fn of this._handlers) fn(this.state, type, payload);
}
commit(type = 'change', payload = null) {
this.save();
this.emit(type, payload);
}
uid(prefix) { return `${prefix}_${Date.now().toString(36)}_${this._id++}`; }
newGame({ mapId, factionId, identity }) {
const map = MAPS[mapId] || MAPS.mainland;
const territories = {};
for (const t of map.territories) {
territories[t.id] = { ...clone(t), buildings: [] };
}
const factions = {};
for (const f of map.factions) {
const tpl = clone(f);
factions[f.id] = {
...tpl,
territories: tpl.territories.filter((id) => territories[id]),
};
}
for (const f of Object.values(factions)) {
f.totalArmies = f.territories.reduce((s, id) => s + territories[id].armies, 0);
}
const faction = factions[factionId] || map.factions[0];
const ident = {
leaderName: (identity?.leaderName || '').trim() || '君主',
nationName: (identity?.nationName || '').trim() || faction.name,
slogan: (identity?.slogan || '').trim() || '榮耀就在前方!',
};
this.state = {
version: 1,
turn: 1,
mapId: map.id,
actionsLeft: MAX_ACTIONS,
playerFactionId: faction.id,
identity: ident,
territories,
factions,
diplomacy: [],
log: [],
isOver: false,
winner: null,
stats: { battlesWon: 0, battlesLost: 0, recruited: 0, captured: 0 },
startedAt: Date.now(),
};
this.addLog(`${ident.leaderName} 登基,成為 ${ident.nationName} 的君主。格言:「${ident.slogan}」`, 'story');
this.commit('new', { mapId: map.id, factionId: faction.id });
return this.state;
}
loadFromStorage() {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return null;
const data = JSON.parse(raw);
if (!data || !data.territories || !data.factions) return null;
for (const t of Object.values(data.territories)) {
if (!Array.isArray(t.buildings)) t.buildings = [];
if (!t.resources) t.resources = { gold: 0, food: 0, wood: 0, stone: 0 };
}
for (const f of Object.values(data.factions)) {
f.territories = (f.territories || []).filter((id) => data.territories[id]);
f.totalArmies = f.territories.reduce((s, id) => s + data.territories[id].armies, 0);
}
if (!Array.isArray(data.log)) data.log = [];
if (!Array.isArray(data.diplomacy)) data.diplomacy = [];
if (!data.stats) data.stats = { battlesWon: 0, battlesLost: 0, recruited: 0, captured: 0 };
if (typeof data.actionsLeft !== 'number') data.actionsLeft = MAX_ACTIONS;
this.state = data;
this.emit('load');
return data;
} catch { return null; }
}
hasSave() {
try { return !!localStorage.getItem(SAVE_KEY); } catch { return false; }
}
save() {
if (!this.state) return;
try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.state)); } catch {}
}
clearSave() {
try { localStorage.removeItem(SAVE_KEY); } catch {}
}
get map() { return MAPS[this.state.mapId]; }
get player() { return this.state.factions[this.state.playerFactionId]; }
faction(id) { return this.state.factions[id] || null; }
territory(id) { return this.state.territories[id] || null; }
isPlayer(id) { return id === this.state.playerFactionId; }
ownerName(id) { return id ? (this.faction(id)?.name || '未知') : '無主之地'; }
ownedByPlayer(id) { return this.territory(id)?.owner === this.state.playerFactionId; }
addLog(text, kind = 'info') {
this.state.log.push({ turn: this.state.turn, text, kind });
if (this.state.log.length > 200) this.state.log = this.state.log.slice(-200);
}
syncArmies(fid) {
const f = this.faction(fid);
if (!f) return;
f.totalArmies = this.factionTerritories(fid).reduce((sum, t) => sum + t.armies, 0);
}
playerTerritories() {
return this.player.territories.map((id) => this.territory(id)).filter(Boolean);
}
factionTerritories(fid) {
const f = this.faction(fid);
return f ? f.territories.map((id) => this.territory(id)).filter(Boolean) : [];
}
income(fid = this.state.playerFactionId) {
const out = { gold: 0, food: 0, wood: 0, stone: 0 };
for (const t of this.factionTerritories(fid)) {
out.gold += t.resources.gold + (t.buildings.includes('market') ? 2 : 0);
out.food += t.resources.food;
out.wood += t.resources.wood;
out.stone += t.resources.stone;
}
return out;
}
upkeep(fid = this.state.playerFactionId) {
const units = this.factionTerritories(fid).reduce((s, t) => s + t.armies, 0);
return { gold: 0, food: units, wood: 0, stone: 0 };
}
recruitCost(tid) {
const t = this.territory(tid);
const gold = t?.buildings.includes('barracks') ? 2 : 3;
return { gold, food: 2 };
}
maxRecruit(tid) {
const t = this.territory(tid);
if (!t) return 0;
const cost = this.recruitCost(tid);
return Math.max(0, Math.min(
Math.floor(this.player.gold / cost.gold),
Math.floor(this.player.food / cost.food),
));
}
canAfford(cost) {
return (this.player.gold >= (cost.gold || 0))
&& (this.player.food >= (cost.food || 0))
&& (this.player.wood >= (cost.wood || 0))
&& (this.player.stone >= (cost.stone || 0));
}
spend(cost) {
for (const k of ['gold', 'food', 'wood', 'stone']) {
if (cost[k]) this.player[k] = clamp0(this.player[k] - cost[k]);
}
}
spendAction() {
this.state.actionsLeft = Math.max(0, this.state.actionsLeft - 1);
}
get outOfActions() { return this.state.actionsLeft <= 0; }
// ── Player orders ──────────────────────────────────────────────────────────
recruit(tid, n) {
const t = this.territory(tid);
if (!t) return { ok: false, msg: '找不到該疆域。' };
if (t.owner !== this.state.playerFactionId) return { ok: false, msg: `你並不擁有 ${t.name}。` };
if (this.outOfActions) return { ok: false, msg: '本回合已無行動,請結束回合。' };
n = Math.floor(n);
if (!n || n < 1) return { ok: false, msg: '至少徵募 1 名士兵。' };
const cost = this.recruitCost(tid);
if (!this.canAfford({ gold: cost.gold * n, food: cost.food * n })) {
return { ok: false, msg: `資源不足:需要 ${cost.gold * n} 金幣與 ${cost.food * n} 糧食。` };
}
this.spend({ gold: cost.gold * n, food: cost.food * n });
t.armies += n;
this.player.totalArmies += n;
this.state.stats.recruited += n;
this.spendAction();
this.addLog(`在 ${t.name} 徵募了 ${n} 名士兵。`, 'player');
this.commit('recruit', { tid, n });
return { ok: true, n, tid, cost, left: this.state.actionsLeft };
}
move(fromId, toId, n) {
const from = this.territory(fromId);
const to = this.territory(toId);
if (!from || !to) return { ok: false, msg: '找不到該疆域。' };
if (from.owner !== this.state.playerFactionId) return { ok: false, msg: `你並不擁有 ${from.name}。` };
if (to.owner !== this.state.playerFactionId) return { ok: false, msg: `你並未控制 ${to.name}。` };
if (!from.adjacentTo.includes(toId)) return { ok: false, msg: `${to.name} 與 ${from.name} 並不相鄰。` };
if (this.outOfActions) return { ok: false, msg: '本回合已無行動,請結束回合。' };
const movable = from.armies - 2;
if (movable <= 0) return { ok: false, msg: `${from.name} 至少需留守 2 名士兵。` };
n = n == null ? movable : Math.floor(n);
n = Math.min(n, movable);
if (n < 1) return { ok: false, msg: '沒有可調動的士兵。' };
from.armies -= n;
to.armies += n;
this.spendAction();
this.addLog(`從 ${from.name} 調動 ${n} 名士兵前往 ${to.name}。`, 'player');
this.commit('move', { fromId, toId, n });
return { ok: true, n, fromId, toId, left: this.state.actionsLeft };
}
resolveCombat(attackerUnits, defenderUnits, terrain, defenderTerritory) {
const terrainBonus = TERRAIN_BONUS[terrain] ?? 1;
const wallBonus = defenderTerritory?.buildings.includes('walls') ? 0.3 : 0;
const totalDefBonus = terrainBonus + wallBonus;
const attackPower = attackerUnits * (DEFAULT_MORALE / 100);
const defensePower = defenderUnits * (DEFAULT_MORALE / 100) * totalDefBonus;
const ratio = attackPower / (defensePower || 1);
const log = [];
log.push(`進攻戰力 ${attackPower.toFixed(1)},防守戰力 ${defensePower.toFixed(1)}。`);
log.push(`${TERRAIN[terrain]?.label || terrain}地形提供 ×${terrainBonus} 防禦。`);
if (wallBonus > 0) log.push(`城牆額外提供 +${wallBonus} 防禦。`);
let outcome; let attackerCasualties; let defenderCasualties; let captured = false;
if (defenderUnits <= 0) {
outcome = 'uncontested';
attackerCasualties = 0;
defenderCasualties = 0;
captured = true;
log.push('前方沒有守軍阻擋。');
} else if (ratio >= 2.0) {
outcome = 'decisive_victory';
attackerCasualties = Math.floor(attackerUnits * 0.1);
defenderCasualties = defenderUnits;
captured = true;
log.push('壓倒性勝利!守軍全面潰散。');
} else if (ratio >= 1.2) {
outcome = 'victory';
attackerCasualties = Math.floor(attackerUnits * 0.25);
defenderCasualties = Math.floor(defenderUnits * 0.6);
captured = true;
log.push('勝利!疆域已落入你手。');
} else if (ratio >= 0.8) {
outcome = 'pyrrhic_victory';
attackerCasualties = Math.floor(attackerUnits * 0.5);
defenderCasualties = Math.floor(defenderUnits * 0.4);
captured = ratio >= 1.0;
log.push(captured ? '慘勝——戰場屬於你,但代價沉重。' : '血戰僵持,進攻方被迫撤退。');
} else {
outcome = 'defeat';
attackerCasualties = Math.floor(attackerUnits * 0.6);
defenderCasualties = Math.floor(defenderUnits * 0.15);
captured = false;
log.push('戰敗!攻勢在守軍面前崩潰。');
}
log.push(`我軍傷亡 ${attackerCasualties},敵軍傷亡 ${defenderCasualties}。`);
return { outcome, attackerCasualties, defenderCasualties, captured, ratio, log };
}
attack(fromId, toId, n) {
const from = this.territory(fromId);
const to = this.territory(toId);
if (!from || !to) return { ok: false, msg: '找不到該疆域。' };
if (from.owner !== this.state.playerFactionId) return { ok: false, msg: `你並不擁有 ${from.name}。` };
if (to.owner === this.state.playerFactionId) return { ok: false, msg: `你已掌握 ${to.name}。` };
if (!from.adjacentTo.includes(toId)) return { ok: false, msg: `${to.name} 與 ${from.name} 並不相鄰。` };
if (this.outOfActions) return { ok: false, msg: '本回合已無行動,請結束回合。' };
if (from.armies < 1) return { ok: false, msg: `${from.name} 已無駐軍。` };
n = n == null ? from.armies : Math.floor(n);
n = Math.min(Math.max(1, n), from.armies);
if (n < 1) return { ok: false, msg: '至少派出 1 名士兵。' };
const defenderFactionId = to.owner;
const defenderName = this.ownerName(defenderFactionId);
const preDef = to.armies;
const result = this.resolveCombat(n, preDef, to.type, to);
const sent = n;
const survivors = Math.max(0, sent - result.attackerCasualties);
from.armies = Math.max(0, from.armies - sent);
this.player.totalArmies = Math.max(0, this.player.totalArmies - result.attackerCasualties);
let eliminated = null;
if (result.captured) {
if (defenderFactionId) {
const old = this.faction(defenderFactionId);
if (old) {
to.armies = Math.max(0, to.armies - result.defenderCasualties);
old.territories = old.territories.filter((id) => id !== toId);
old.totalArmies = Math.max(0, old.totalArmies - result.defenderCasualties);
if (old.territories.length === 0) {
eliminated = old;
this.addLog(`${old.name} 已從地圖上滅亡。`, 'critical');
}
}
}
to.owner = this.state.playerFactionId;
to.armies = survivors;
this.player.territories.push(toId);
this.state.stats.battlesWon += 1;
this.state.stats.captured += 1;
this.addLog(`你從 ${defenderName} 手中奪下 ${to.name},${survivors} 名士兵倖存。`, 'victory');
} else {
to.armies = Math.max(0, to.armies - result.defenderCasualties);
if (defenderFactionId) {
const old = this.faction(defenderFactionId);
if (old) old.totalArmies = Math.max(0, old.totalArmies - result.defenderCasualties);
}
this.state.stats.battlesLost += 1;
this.addLog(`對 ${to.name} 的攻勢失敗,損失 ${result.attackerCasualties} 名士兵。`, 'defeat');
}
this.spendAction();
this.syncArmies(this.state.playerFactionId);
const payload = {
fromId, toId, sent, defenderUnits: preDef, defenderName,
result, survivors, eliminated,
};
const win = this.checkWinner();
if (win) this.finish(win);
this.commit('battle', payload);
return { ok: true, ...payload, win, left: this.state.actionsLeft };
}
build(tid, type) {
const t = this.territory(tid);
const def = BUILDINGS[type];
if (!t || !def) return { ok: false, msg: '未知的建築。' };
if (t.owner !== this.state.playerFactionId) return { ok: false, msg: `你並不擁有 ${t.name}。` };
if (this.outOfActions) return { ok: false, msg: '本回合已無行動,請結束回合。' };
if (t.buildings.includes(type)) return { ok: false, msg: `${t.name} 已建有${def.label}。` };
if (!this.canAfford(def.cost)) {
const need = Object.entries(def.cost).map(([k, v]) => `${v} ${RESOURCES[k].label}`).join('、');
return { ok: false, msg: `資源不足:需要 ${need}。` };
}
this.spend(def.cost);
t.buildings.push(type);
this.spendAction();
this.addLog(`在 ${t.name} 建成${def.label}。`, 'player');
this.commit('build', { tid, type });
return { ok: true, tid, type, left: this.state.actionsLeft };
}
// ── Diplomacy ─────────────────────────────────────────────────────────────
relation(a, b) {
const [fa, fb] = a < b ? [a, b] : [b, a];
return this.state.diplomacy.find((r) => r.factionA === fa && r.factionB === fb) || null;
}
relationStatus(a, b) { return this.relation(a, b)?.status || 'neutral'; }
setRelation(a, b, status, turns, initiatedBy) {
const [fa, fb] = a < b ? [a, b] : [b, a];
const idx = this.state.diplomacy.findIndex((r) => r.factionA === fa && r.factionB === fb);
const rel = { factionA: fa, factionB: fb, status, turnsRemaining: turns, initiatedBy };
if (idx >= 0) this.state.diplomacy[idx] = rel; else this.state.diplomacy.push(rel);
}
otherFaction(fid) {
const f = this.faction(fid);
if (!f || f.id === this.state.playerFactionId) return null;
return f;
}
proposeAlliance(fid) {
const f = this.otherFaction(fid);
if (!f) return { ok: false, msg: '無效的勢力。' };
if (this.outOfActions) return { ok: false, msg: '本回合已無行動,請結束回合。' };
const status = this.relationStatus(this.state.playerFactionId, fid);
if (status === 'allied') return { ok: false, msg: '你們已是同盟。' };
let chance = (ACCEPT_RATES[f.personality] || [0.5])[0];
if (f.territories.length <= 1) chance += 0.3;
this.spendAction();
if (Math.random() < chance) {
this.setRelation(this.state.playerFactionId, fid, 'allied', -1, this.state.playerFactionId);
this.addLog(`${f.name} 接受你的同盟提案。`, 'diplomacy');
this.commit('diplomacy');
return { ok: true, accepted: true, msg: `${f.name} 接受你的同盟提案。` };
}
this.addLog(`${f.name} 拒絕你的同盟提案。`, 'diplomacy');
this.commit('diplomacy');
return { ok: true, accepted: false, msg: `${f.name} 拒絕了你的提案。` };
}
proposePeace(fid) {
const f = this.otherFaction(fid);
if (!f) return { ok: false, msg: '無效的勢力。' };
if (this.outOfActions) return { ok: false, msg: '本回合已無行動,請結束回合。' };
const status = this.relationStatus(this.state.playerFactionId, fid);
if (status === 'peace') return { ok: false, msg: '你們已處於停戰狀態。' };
if (status === 'allied') return { ok: false, msg: '同盟關係已約束雙方。' };
let chance = (ACCEPT_RATES[f.personality] || [0.5, 0.5])[1];
if (f.territories.length <= 1) chance += 0.3;
const turns = 3 + Math.floor(Math.random() * 3);
this.spendAction();
if (Math.random() < chance) {
this.setRelation(this.state.playerFactionId, fid, 'peace', turns, this.state.playerFactionId);
this.addLog(`與 ${f.name} 達成 ${turns} 回合停戰。`, 'diplomacy');
this.commit('diplomacy');
return { ok: true, accepted: true, turns, msg: `${f.name} 同意停戰 ${turns} 回合。` };
}
this.addLog(`${f.name} 拒絕停戰。`, 'diplomacy');
this.commit('diplomacy');
return { ok: true, accepted: false, msg: `${f.name} 拒絕停戰。` };
}
proposeTrade(fid, giveRes, giveAmount, getRes) {
const f = this.otherFaction(fid);
if (!f) return { ok: false, msg: '無效的勢力。' };
if (this.outOfActions) return { ok: false, msg: '本回合已無行動,請結束回合。' };
if (!['gold', 'food', 'wood', 'stone'].includes(giveRes) || !['gold', 'food', 'wood', 'stone'].includes(getRes)) {
return { ok: false, msg: '無效的資源。' };
}
if (giveRes === getRes) return { ok: false, msg: '請選擇兩種不同的資源。' };
giveAmount = Math.floor(giveAmount);
if (!giveAmount || giveAmount < 1) return { ok: false, msg: '至少提供 1 單位資源。' };
if (this.player[giveRes] < giveAmount) return { ok: false, msg: `你只有 ${this.player[giveRes]} ${RESOURCES[giveRes].label}。` };
const chance = (ACCEPT_RATES[f.personality] || [0.5, 0.5, 0.5])[2];
const markup = TRADE_MARKUP[f.personality] || 1;
const getAmount = Math.max(1, Math.round(giveAmount / markup));
if (f[getRes] < getAmount) return { ok: false, msg: `${f.name} 沒有足夠的${RESOURCES[getRes].label}。` };
this.spendAction();
if (Math.random() < chance) {
this.player[giveRes] -= giveAmount;
this.player[getRes] += getAmount;
f[giveRes] += giveAmount;
f[getRes] -= getAmount;
this.addLog(`以 ${giveAmount} ${RESOURCES[giveRes].label} 向 ${f.name} 換得 ${getAmount} ${RESOURCES[getRes].label}。`, 'diplomacy');
this.commit('diplomacy');
return { ok: true, accepted: true, getAmount, msg: `${f.name} 接受交易:以 ${giveAmount} ${RESOURCES[giveRes].label} 換取 ${getAmount} ${RESOURCES[getRes].label}。` };
}
this.addLog(`${f.name} 拒絕你的貿易提案。`, 'diplomacy');
this.commit('diplomacy');
return { ok: true, accepted: false, getAmount: 0, msg: `${f.name} 拒絕了這筆交易。` };
}
getRelations() {
return this.state.diplomacy.filter((r) => r.factionA === this.state.playerFactionId || r.factionB === this.state.playerFactionId);
}
relationWith(fid) { return this.relation(this.state.playerFactionId, fid); }
// ── Turn cycle ────────────────────────────────────────────────────────────
endTurn() {
if (this.state.isOver) return null;
const report = { turn: this.state.turn, income: null, upkeep: null, net: null, aiLogs: [], diploLogs: [], starvation: [], playerBefore: null };
const player = this.player;
// income & upkeep for every faction
for (const f of Object.values(this.state.factions)) {
if (f.territories.length === 0) continue;
const inc = this.income(f.id);
const up = this.upkeep(f.id);
f.gold += inc.gold - up.gold;
f.food += inc.food - up.food;
f.wood += inc.wood - up.wood;
f.stone += inc.stone - up.stone;
if (f.food < 0) {
f.food = 0;
if (f.id === this.state.playerFactionId) report.starvation.push('糧倉已空,軍隊吃掉了最後的存糧。');
}
if (f.gold < 0) f.gold = 0;
if (f.id === this.state.playerFactionId) {
report.income = inc;
report.upkeep = up;
report.net = { gold: inc.gold - up.gold, food: inc.food - up.food, wood: inc.wood, stone: inc.stone };
report.playerBefore = { gold: player.gold, food: player.food };
}
}
this.addLog(`第 ${this.state.turn} 回合結束。國庫收取 ${report.income.gold} 金幣、${report.income.food} 糧食、${report.income.wood} 木材、${report.income.stone} 石料。`, 'turn');
// AI factions act
for (const f of Object.values(this.state.factions)) {
if (f.id === this.state.playerFactionId) continue;
if (f.territories.length === 0) continue;
const logs = this.runAI(f);
report.aiLogs.push(...logs);
}
// diplomacy countdown
for (let i = this.state.diplomacy.length - 1; i >= 0; i--) {
const rel = this.state.diplomacy[i];
if (rel.status === 'peace' && rel.turnsRemaining > 0) {
rel.turnsRemaining -= 1;
if (rel.turnsRemaining <= 0) {
const a = this.faction(rel.factionA)?.name || rel.factionA;
const b = this.faction(rel.factionB)?.name || rel.factionB;
this.state.diplomacy.splice(i, 1);
report.diploLogs.push(`${a} 與 ${b} 的停戰協議已到期。`);
}
}
}
report.aiLogs.forEach((l) => this.addLog(l, 'ai'));
report.diploLogs.forEach((l) => this.addLog(l, 'diplomacy'));
// player elimination
if (player.territories.length === 0) {
const winner = Object.values(this.state.factions).find((f) => f.territories.length > 0);
this.finish(winner?.id || null);
this.commit('turn', report);
return report;
}
this.state.turn += 1;
this.state.actionsLeft = MAX_ACTIONS;
const win = this.checkWinner();
if (win) this.finish(win);
for (const f of Object.values(this.state.factions)) this.syncArmies(f.id);
this.commit('turn', report);
return report;
}
runAI(faction) {
const logs = [];
const squadTerr = this.factionTerritories(faction.id)[0];
if (!squadTerr) return logs;
// recruit
if (faction.gold >= 6 && faction.food >= 4) {
const n = Math.min(Math.floor(faction.gold / 3), Math.floor(faction.food / 2), 3);
if (n > 0) {
faction.gold -= n * 3;
faction.food -= n * 2;
squadTerr.armies += n;
faction.totalArmies += n;
logs.push(`${faction.name} 在 ${squadTerr.name} 徵募 ${n} 名士兵。`);
}
}
// desperate alliances break
if (faction.territories.length <= 1 && faction.gold < 5 && faction.personality === 'aggressive') {
const rels = this.state.diplomacy.filter((r) => (r.factionA === faction.id || r.factionB === faction.id) && r.status === 'allied');
for (const r of rels) {
this.state.diplomacy = this.state.diplomacy.filter((x) => x !== r);
logs.push(`${faction.name} 在絕境中撕毀同盟。`);
}
}
// attack one adjacent target
for (const tid of [...faction.territories]) {
const src = this.territory(tid);
if (!src || src.armies < 2) continue;
for (const adjId of src.adjacentTo) {
const target = this.territory(adjId);
if (!target || target.owner === faction.id) continue;
if (target.owner) {
const status = this.relationStatus(faction.id, target.owner);
if (status === 'allied' || status === 'peace') continue;
}
if (src.armies < target.armies && faction.personality !== 'aggressive') continue;
const pre = src.armies;
const result = this.resolveCombat(pre, target.armies, target.type, target);
src.armies = Math.max(0, pre - result.attackerCasualties);
faction.totalArmies = Math.max(0, faction.totalArmies - result.attackerCasualties);
logs.push(`${faction.name} 自 ${src.name} 進攻 ${target.name}(${pre} 對 ${target.armies})。`);
if (result.captured) {
const oldOwnerId = target.owner;
if (oldOwnerId) {
const old = this.faction(oldOwnerId);
target.armies = Math.max(0, target.armies - result.defenderCasualties);
if (old) {
old.territories = old.territories.filter((id) => id !== adjId);
old.totalArmies = Math.max(0, old.totalArmies - result.defenderCasualties);
if (old.territories.length === 0) {
logs.push(`${old.name} 已從地圖上滅亡。`);
if (old.id === this.state.playerFactionId) {
target.owner = faction.id;
target.armies = src.armies;
src.armies = 0;
faction.territories.push(adjId);
this.addLog(`${faction.name} 占領了 ${target.name}。`, 'critical');
return logs;
}
}
}
}
const survivors = src.armies;
src.armies = 0;
target.owner = faction.id;
target.armies = survivors;
faction.territories.push(adjId);
logs.push(`${faction.name} 以 ${survivors} 名倖存者占領 ${target.name}。`);
} else {
target.armies = Math.max(0, target.armies - result.defenderCasualties);
if (target.owner) {
const old = this.faction(target.owner);
if (old) old.totalArmies = Math.max(0, old.totalArmies - result.defenderCasualties);
}
logs.push(`${faction.name} 對 ${target.name} 的攻勢被擊退。`);
}
return logs;
}
}
return logs;
}
checkWinner() {
const total = Object.keys(this.state.territories).length;
for (const f of Object.values(this.state.factions)) {
if (f.territories.length >= total) return f.id;
if (f.territories.length > 0) {
const owned = Object.values(this.state.territories).filter((t) => t.owner === f.id).length;
if (owned >= total) return f.id;
}
}
return null;
}
finish(winnerId) {
if (this.state.isOver) return;
this.state.isOver = true;
this.state.winner = winnerId;
const name = winnerId ? this.faction(winnerId)?.name : null;
this.addLog(winnerId === this.state.playerFactionId
? `勝利!${name} 統治了世界。`
: `${name || '某個對手'} 征服了世界。`, 'critical');
}
// forecast for attack modal
forecast(fromId, toId, n) {
const from = this.territory(fromId);
const to = this.territory(toId);
if (!from || !to) return null;
const units = n == null ? from.armies : n;
const terrainBonus = TERRAIN_BONUS[to.type] ?? 1;
const wallBonus = to.buildings.includes('walls') ? 0.3 : 0;
const atk = units * 0.8;
const def = to.armies * 0.8 * (terrainBonus + wallBonus);
const ratio = atk / (def || 1);
let label = '絕望';
let tone = 'bad';
if (to.armies <= 0 || ratio >= 2) { label = '壓倒性'; tone = 'great'; }
else if (ratio >= 1.2) { label = '有利'; tone = 'good'; }
else if (ratio >= 1.0) { label = '勢均力敵'; tone = 'warn'; }
else if (ratio >= 0.8) { label = '冒險'; tone = 'warn'; }
else { label = '恐將失敗'; tone = 'bad'; }
return { ratio, label, tone, atk, def, terrainBonus, wallBonus };
}
factionStanding(fid) {
const f = this.faction(fid);
if (!f) return null;
return {
territories: f.territories.length,
armies: this.factionTerritories(fid).reduce((s, t) => s + t.armies, 0),
personality: f.personality,
color: f.color,
};
}
}