Files
ark-stronghold-protocol/public/js/ui/gameLogic/phases.js
T
sganggs eb04f5dfa4 feedback5 R5: split the in-match UI logic into modules
public/js/ui/gameLogic.js becomes a re-export barrel. The bodies move verbatim into public/js/ui/gameLogic/*.js (shared, phases, camera, watch, bonds, shop, placement, draft, enemies, format, shortcuts, settings, result, loadout, panel). The barrel re-exports the same 96 names in the same order. isObj and int gain export on shared.js so sibling modules can import them; they stay off the barrel. No importer changes.
2026-10-05 17:06:45 +08:00

123 lines
6.3 KiB
JavaScript

// ui/gameLogic/phases.js — phase families, banners and countdowns. Re-exported from ../gameLogic.js.
import { PHASE } from '../../../../shared/constants.js';
import { bossLevelSeconds } from '../matchStatus.js';
import { clamp, int, isObj, sortedPlayers } from './shared.js';
import { normalizeSp } from './draft.js';
// ---- phases ----------------------------------------------------------------------------------------
const COMBAT_PHASES = new Set([PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]);
const PREP_PHASES = new Set([PHASE.PREP, PHASE.SP_DRAFT, PHASE.ROUND_START]);
/**
* Which screen family a phase belongs to.
* @param {string} phase
* @returns {'loading'|'briefing'|'draft'|'boot'|'prep'|'combat'|'settle'|'result'}
*/
export function phaseMode(phase) {
if (!phase) return 'loading';
if (phase === PHASE.INFO_CHECK) return 'briefing';
if (phase === PHASE.BAND_DRAFT) return 'draft';
if (phase === PHASE.BATTLE_CHECK) return 'boot';
if (phase === PHASE.RESULT) return 'result';
if (phase === PHASE.SETTLE) return 'settle';
if (COMBAT_PHASES.has(phase)) return 'combat';
if (PREP_PHASES.has(phase)) return 'prep';
return 'prep';
}
export const isCombatPhase = (phase) => COMBAT_PHASES.has(phase);
/**
* The HUD's "你已被淘汰 · 可继续观战队友" pill of an eliminated player: outside combat only — in combat and in the SETTLE
* after it the combat HUD (screens/game.js CombatHud, rendered for mode 'settle' too) already says it, and two
* elimination banners never show at once.
* @param {boolean} alive
* @param {string|null|undefined} phase
*/
export const showDeadPill = (alive, phase) => !alive && !COMBAT_PHASES.has(phase) && phase !== PHASE.SETTLE;
export const isBossPhase = (phase) => phase === PHASE.FINAL_ASSAULT || phase === PHASE.HIDDEN_CORE;
/** Banner shown when a phase starts: { title, sub?, tone } or null. */
export function phaseBanner(phase, pub) {
const r = int(pub?.round, 0);
switch (phase) {
case PHASE.BATTLE_CHECK: return { title: '协议启动', micro: 'PROTOCOL START', tone: 'mint', sub: '模拟即将开始', duration: 2600 };
case PHASE.ROUND_START: return { title: `第 ${r} 回合`, micro: `ROUND ${String(r).padStart(2, '0')}`, tone: 'mint', sub: '资金已到账' };
case PHASE.SP_DRAFT: return { title: '机变阶段', micro: 'CONTINGENCY', tone: 'gold', sub: '依次选择机变' };
case PHASE.PREP: return { title: '休整期', micro: `ROUND ${String(r).padStart(2, '0')} // REST`, tone: 'mint', sub: '部署干员,准备迎敌' };
case PHASE.COMBAT: return { title: '作战开始', micro: 'COMBAT', tone: 'orange', sub: '各自行动阶段' };
case PHASE.UNITE: {
const names = new Map(sortedPlayers(pub).map((p) => [p.playerId, p.name || '博士']));
const helpers = Array.isArray(pub?.unite?.helpers) ? pub.unite.helpers.map((id) => names.get(id)).filter(Boolean) : [];
return { title: '联防阶段', micro: 'JOINT DEFENSE', tone: 'orange', sub: helpers.length ? `联防:${helpers.join('、')}` : '完美作战的博士迎战突破防线的敌人' };
}
case PHASE.FINAL_ASSAULT: return { title: '最终攻势', micro: 'FINAL ASSAULT', tone: 'red', sub: '击败敌方领袖' };
case PHASE.HIDDEN_CORE: return { title: '隐秘核心', micro: 'HIDDEN CORE', tone: 'red', sub: '被源石侵蚀的假想敌' };
case PHASE.SETTLE: return null;
default: return null;
}
}
/** Label of the prep capsule ("休息一下" in the original). */
export function prepCapsuleLabel(phase) {
if (phase === PHASE.SP_DRAFT) return '机变阶段';
if (phase === PHASE.ROUND_START) return '回合开始';
if (phase === PHASE.BATTLE_CHECK) return '协议启动';
if (phase === PHASE.SETTLE) return '回合结算';
return '休息一下';
}
// ---- countdown -------------------------------------------------------------------------------------
/**
* Countdown display state.
* @param {number|null|undefined} deadline server epoch ms (0/null = untimed)
* @param {number} now server-corrected epoch ms
* @param {number} [total] total seconds of the phase (for the 5-bar gauge)
* @param {number} [warnAt] seconds at/below which the digits turn orange
* @returns {{ remain: number|null, warn: boolean, bars: number, text: string, frac: number }}
*/
export function countdownState(deadline, now, total, warnAt = 10) {
if (!(Number.isFinite(deadline) && deadline > 0) || !Number.isFinite(now)) {
return { remain: null, warn: false, bars: 0, text: '--', frac: 0 };
}
const remain = Math.max(0, Math.ceil((deadline - now) / 1000));
const t = Number.isFinite(total) && total > 0 ? total : null;
const frac = t ? clamp(remain / t, 0, 1) : 1;
const bars = remain === 0 ? 0 : t ? clamp(Math.ceil(frac * 5), 0, 5) : 5;
return { remain, warn: remain <= warnAt, bars, text: String(Math.min(999, remain)).padStart(2, '0'), frac };
}
/**
* Nominal length (s) of the current timed phase from data/config.json, or null when unknown/untimed.
* @param {any} pub m.public
* @param {any} config data/config.json
* @param {string|null} [myId]
*/
export function phaseTotalSeconds(pub, config, myId = null) {
if (!isObj(pub)) return null;
const timers = isObj(config?.timers) ? config.timers : {};
const mode = isObj(config?.modes) ? config.modes[pub.modeId] : null;
const num = (v) => (Number.isFinite(v) && v > 0 ? v : null);
switch (pub.phase) {
case PHASE.INFO_CHECK: return num(timers.infoCheck) ?? 25;
// one countdown: the current turn's (m.public.draft.turnSeconds = Match.BAND_TURN_SECONDS; user playtest #4 item 4)
case PHASE.BAND_DRAFT: return num(pub.draft?.turnSeconds) ?? num(timers.bandTurn) ?? 30;
case PHASE.BATTLE_CHECK: return num(timers.battleCheck) ?? 3;
case PHASE.SP_DRAFT: {
const sp = normalizeSp(pub.sp, pub.players);
const first = !sp || sp.pickedCount === 0;
return first ? (num(timers.spFirst) ?? 30) : (num(timers.spTurn) ?? 16);
}
case PHASE.PREP: return num(mode?.rounds?.[String(pub.round)]?.prepTime);
case PHASE.COMBAT:
case PHASE.UNITE: return num(mode?.rounds?.[String(pub.round)]?.combatTimeLimit);
// 最终攻势 / 隐秘核心: m.public.deadline is the level's 120 s countdown (maxPlayTime; the battle goes on past it)
case PHASE.FINAL_ASSAULT:
case PHASE.HIDDEN_CORE: return bossLevelSeconds(pub, config);
default: return null;
}
}