Files
ark-stronghold-protocol/public/js/battle/observe.js
T
xcdoge b4bc89ad4e feedback7 UIBACK: the top-bar capsule counts like the official — resolved = killed + leaked among the enemies the round scheduled (opening 0/3, a leak 1/3, a kill 2/3, the splitter 3/3); a split child, a summon and a boss summon are in neither number but still cost LP and break 完美作战 (PR #308 by @xcdoge, the capsule half of PR #157 — authored by them: the sim / protocol / HUD hunks applied as reviewed): inTotal / killedInTotal / leakedInTotal / resolved, b.snap / BattleResult / b.progress (optional field) / m.public progress carry resolved, killed may exceed total (validation bounds it by the content-spawn allowance); dropped the PR's stray design-doc line — it had replaced the S→C b.end bullet of docs/design/network.md, which is back; the 联防 live-counter change mark follows the enemy list now that a spawn no longer moves total (uniteLeftMark + test); golden: only total / players.*.total / snaps move, outcomes bit-identical — roster 23 (roster-002 003 007 008 009 010 011 012 013 015 017 018 020 023 024 025 026 027 037 045 047 048 049), bonds 2 (bond-maniShip-low, bond-soloShip-high), fields 11 (boss-boss_1 3 5 pair+solo, boss-boss_6-solo, hidden-boss_8 10 pair+solo), diy 11 (diy-036 040 056 062 068 073 083 091 095 114 115), matches 12 (solo-FUNNY-1 NORMAL-1 HARD-1 ABYSS-1 ABYSS-2, coop2-NORMAL-3 coop2-HARD-4 coop3-ABYSS-5 coop4-ABYSS-7 coop2-NORMAL-8-serverrun solo-HARD-9-boosted coop2-NORMAL-70-diy: the total column of 36 round rows), standins unchanged
2026-10-08 19:30:36 +08:00

184 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Observing rules and labels of client-side combat (research 09 §3.1 / §6.3, DESIGN §14 "Spectating") — pure helpers
// for the game screen, the team panel and the combat HUD (mirror of server/match/match/watch.js _watchClient):
// * prep (休整期): tap a teammate → 前往查看 → their board (read-only);
// * own normal battle running: no observing ("当前无法查看");
// * own battle over: "⌛ 作战结束,等待队友完成作战" + the teammates' progress; tap a teammate → 前往查看 → a local
// replica of their battle; 返回战场 goes back;
// * 联防 / 最终攻势: the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half of the own field; the other pair's
// boss field is never shown to a fighting player;
// * eliminated: anything — each phase reset starts on the player it follows (the server's prep scout / b.start,
// followedScout).
import { PHASE } from '../../../shared/constants.js';
import { data } from '../data.js';
import { t } from '../../../shared/i18n.js';
const isObj = (v) => !!v && typeof v === 'object';
const COMBAT = new Set([PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]);
/** The server runs client-side combat (m.public.combatMode). */
export const isClientCombat = (pub) => !!pub && pub.combatMode === 'client';
const players = (pub) => (Array.isArray(pub?.players) ? pub.players.filter(isObj) : []);
const fields = (pub) => (Array.isArray(pub?.fields) ? pub.fields.filter(isObj) : []);
/** The field listing a player, or null. */
export function fieldOf(pub, playerId) {
return fields(pub).find((f) => Array.isArray(f.players) && f.players.includes(playerId)) || null;
}
/** Display name of a player id ('队友' when unknown). */
export function nameOf(pub, playerId) {
return players(pub).find((p) => p.playerId === playerId)?.name || t('队友');
}
/**
* What tapping a team row does: `{ back: true }` (own row while observing), `{ fieldId }` to observe, or `{ reason }`.
* @param {any} p m.public player row
* @param {any} pub
* @param {string} myId
* @param {{ observing?: boolean, ownDone?: boolean }} [o] ownDone: the local simulation of the own battle already ended
* (its result is on the way to the server)
*/
export function observeTarget(p, pub, myId, { observing = false, ownDone = false } = {}) {
if (!isObj(p)) return { reason: t('无效的目标') };
if (p.playerId === myId) return observing ? { back: true } : { reason: null };
if (p.alive === false || p.status === 'left') return { reason: t('该队友已被淘汰,无法查看') };
const phase = pub?.phase;
const me = players(pub).find((x) => x.playerId === myId) || null;
const meAlive = me ? me.alive !== false : true;
if (!COMBAT.has(phase)) return { fieldId: `n:${p.playerId}` };
const target = fieldOf(pub, p.playerId);
if (!target) return { reason: t('该队友当前没有战场') };
const own = fieldOf(pub, myId);
if (!meAlive || !own) return { fieldId: target.fieldId };
if (own.fieldId === target.fieldId) return { reason: t('队友与你在同一战场,使用 ‹ › 切换视角') };
if (target.kind === 'boss' || target.kind === 'hidden') return { reason: t('无法查看另一组队友的战场') };
if (own.kind === 'normal' && own.live !== false && !ownDone) return { reason: t('作战中无法查看队友,作战结束后可前往查看') };
return { fieldId: target.fieldId };
}
/**
* A screen reloaded (or a socket reconnected) while it watched a teammate's battle after the own one: the server
* resends the watched field (Match._resendBattle, b.start `watch: true`), but a fresh screen starts at home — so the
* observing pill, 返回战场 and the own row would be missing. Decided once per battle, at the first sight of its
* battleId (`seen` = the battleId already decided): adopt `fieldId` as the watched field when it is a teammate's normal
* battle in 各自行动 shown to a living player who watches nothing. Never for a battle first seen while watching (a
* 前往查看 under way — and its 返回战场 must not bring it back while the own b.start is on its way), the own field, a
* 联防 leaker's field or an eliminated player's auto-observed one (those keep their own rules). A loading battle (its
* state carries no `watch` yet) is decided once it runs, unless the screen already watches (then it is marked seen).
* @param {any} b battleRunner.state() @param {{ pub?: any, myId?: string, alive?: boolean, watching?: string|null,
* seen?: string|null }} [o]
* @returns {{ seen: string|null, fieldId: string|null }} seen: the new value to remember
*/
export function resumedWatch(b, { pub = null, myId = '', alive = true, watching = null, seen = null } = {}) {
const keep = { seen, fieldId: null };
if (!isObj(pub) || !isObj(b) || typeof b.battleId !== 'string' || !b.battleId || b.battleId === seen) return keep;
if (b.loading && !watching) return keep;
const done = { seen: b.battleId, fieldId: null };
if (b.loading || watching || !alive || !isClientCombat(pub) || pub.phase !== PHASE.COMBAT) return done;
if (!b.watch || b.kind !== 'normal' || typeof b.fieldId !== 'string' || !b.fieldId) return done;
const f = fields(pub).find((x) => x.fieldId === b.fieldId);
if (!f || f.kind !== 'normal' || !Array.isArray(f.players) || f.players.includes(myId)) return done;
return { seen: b.battleId, fieldId: b.fieldId };
}
/**
* The prep board the server pushed to a viewer that follows a player (an eliminated player or a spectator seat:
* Match._followScout — the player it last watched, else the first player still in; community report of 2026-10-06,
* item 56, the idea of PR #189), to adopt as the watched board like a 前往查看 tap: `field` is such a scout (m.field
* `prep`, an `n:<pid>` id other than the own) and the screen watches nothing (`watching` null — a 返回战场 this phase is
* not overridden: the caller adopts a board once per phase). Null for a living player: its phase resets keep its own
* board.
* @param {{ field?: any, watching?: string|null, alive?: boolean, spectator?: boolean, myId?: string }} o
* @returns {string|null} the fieldId to watch
*/
export function followedScout({ field = null, watching = null, alive = true, spectator = false, myId = '' } = {}) {
if (watching || (alive && !spectator) || !isObj(field) || !field.prep) return null;
const fid = field.fieldId;
if (typeof fid !== 'string' || !fid.startsWith('n:') || fid === `n:${myId}`) return null;
return fid;
}
/** Teammates' progress for the waiting pill: [{ playerId, name, killed, resolved, total, done, isBot }]. */
export function teammateProgress(pub, myId) {
const out = [];
for (const f of fields(pub)) {
if (f.kind !== 'normal' || !Array.isArray(f.players)) continue;
const pid = f.players[0];
if (pid === myId) continue;
const p = players(pub).find((x) => x.playerId === pid);
const pr = isObj(f.progress) ? f.progress : null;
out.push({
playerId: pid, name: p?.name || t('队友'), isBot: !!p?.isBot,
killed: Number.isFinite(pr?.killed) ? pr.killed : null,
resolved: Number.isFinite(pr?.resolved) ? pr.resolved : (Number.isFinite(pr?.killed) ? pr.killed : null),
total: Number.isFinite(pr?.total) ? pr.total : null,
done: f.live === false || !!pr?.done,
});
}
return out;
}
/** Side of each player of a local field meta (`sides` from the runner, else seat order). */
export function sidesOf(field) {
if (isObj(field?.sides)) return field.sides;
const ps = Array.isArray(field?.players) ? field.players : [];
return Object.fromEntries(ps.map((pid, i) => [pid, i === 1 ? 'R' : 'L']));
}
/**
* The ‹ › camera layers of a 联防 / boss field: LEFT half, 全景, RIGHT half with captions "你自己" / "👁 name" /
* "全景" / "无人在家". Empty for normal fields and single-player boss fields.
*/
export function cameraLayers(field, pub, myId) {
if (!isObj(field) || (field.kind !== 'unite' && field.kind !== 'boss' && field.kind !== 'hidden')) return [];
const sides = sidesOf(field);
const at = (side) => Object.keys(sides).find((pid) => sides[pid] === side) || null;
const label = (pid) => (!pid ? t('无人在家') : pid === myId ? t('你自己') : nameOf(pub, pid));
const left = at('L');
const right = at('R');
if ((field.kind === 'boss' || field.kind === 'hidden') && (!left || !right)) return [];
return [
{ key: 'L', label: label(left), self: left === myId, watch: !!left && left !== myId },
{ key: 'ALL', label: t('全景'), self: false, watch: false },
{ key: 'R', label: label(right), self: right === myId, watch: !!right && right !== myId },
];
}
/** Camera options of a layer for view.setCamera(kind, …). */
export function layerCamera(field, layer, mySide = 'L') {
const rect = field?.rect;
if (layer === 'L' || layer === 'R') return { rect, side: layer, half: true };
return { rect, side: mySide };
}
/**
* The watched player's effects column for a battle watched as a display replica (user playtest #2: while spectating,
* the right column shows the spectated player's effects, not one's own). The spec's raw playerEffects (Battle spec:
* `{ id, source = iconKind, counter, … }`) resolved client-side — a band effect through bands.json (its effectId),
* everything else through effects.json; `effectIconUrl`'s per-kind fallbacks cover the missing icon ids. Undefined
* where whose column would be ambiguous (联防 / boss pairs) or there are no effects data (a server-run field's meta
* comes from the server without effects — the column stays empty rather than showing one's own).
*/
export function spectateEffects(spec, members) {
if (!Array.isArray(members) || members.length !== 1 || !isObj(spec)) return undefined;
const p = Array.isArray(spec.players) ? spec.players.find((x) => isObj(x) && x.playerId === members[0]) : null;
if (!p || !Array.isArray(p.playerEffects)) return undefined;
const out = [];
for (const pe of p.playerEffects) {
if (!isObj(pe) || typeof pe.id !== 'string' || !pe.id) continue;
const counter = pe.counter != null ? { counter: pe.counter } : {};
if (pe.source === 'band') {
const band = data.list('bands').find((b) => b && b.effectId === pe.id);
if (band) {
out.push({ id: pe.id, name: band.effectName || band.name, desc: band.desc || '', iconKind: 'band', iconId: band.iconId || band.bandId, ...counter });
continue;
}
}
const rec = data.lookup('effects', pe.id);
out.push({ id: pe.id, name: rec?.name || pe.id, desc: rec?.desc || rec?.descRaw || '', iconKind: pe.source || 'choice', iconId: pe.id, ...counter });
}
return out;
}