Watching a teammate shows their hand, 临时整备区, equipment, strategy and effects (#129)
The prep scout of another player's board carries their 整备区 hand and 临时整备区 as units on the bench rows (like the own prep bench), the equipped items of their operators, their strategy in the drawer and their effects column (the official mode shows a teammate's hand too). The scout signature includes the hand / temp slots, so a moved card refreshes the watcher. Closes #128. Contributed by @2321Robin.
This commit is contained in:
1 parent
bd892a4dd6
commit
bce182703b
15 files changed
+521
-20
No files matched your search
@@ -9,6 +9,7 @@
|
||||
// * eliminated: anything.
|
||||
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
import { data } from '../data.js';
|
||||
|
||||
const isObj = (v) => !!v && typeof v === 'object';
|
||||
const COMBAT = new Set([PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]);
|
||||
@@ -130,3 +131,32 @@ export function layerCamera(field, layer, mySide = 'L') {
|
||||
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;
|
||||
}
|
||||
@@ -65,6 +65,7 @@
|
||||
import { net as appNet } from '../net.js';
|
||||
import { store as appStore } from '../store.js';
|
||||
import { unitStatsEntry, fxForm } from '../../../shared/protocol.js';
|
||||
import { spectateEffects } from './observe.js';
|
||||
|
||||
const TICK = 1 / 30;
|
||||
/** Fast-forward budget per frame (ticks) when far behind. */
|
||||
@@ -561,6 +562,8 @@ export function createBattleRunner(deps) {
|
||||
const field = {
|
||||
t: 'm.field', ...meta, fieldId: e.fieldId, kind: e.kind, rect: meta.rect ?? e.spec.rect, stageId: meta.stageId ?? e.spec.stageId,
|
||||
live: !e.done, battleId: e.battleId, players: e.members.slice(), local: true, speed: e.speed,
|
||||
// the watched player's effects column (user playtest #2; undefined for 联防 / boss pairs and server-run fields)
|
||||
effects: spectateEffects(e.spec, e.members),
|
||||
// which half each player holds (联防: the first helper takes the right half; boss pairs: L / R)
|
||||
sides: Object.fromEntries((e.spec.players || []).filter((p) => p && p.playerId).map((p) => [p.playerId, p.side === 'R' || Number(p.colOffset) >= 8 ? 'R' : 'L'])),
|
||||
};
|
||||
|
||||
+22
-1
@@ -294,6 +294,10 @@ export function renderInfo(u) {
|
||||
// tap hands them to the detail card (a teammate's unit shows its owner's skill / module)
|
||||
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined,
|
||||
moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined,
|
||||
// the ally's equipped item ids (UnitInfo.items, DESIGN §16 / §21.11): the detail card needs them for a teammate's
|
||||
// unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own
|
||||
// unit takes its items from the piece instead, so only other players' boards ever read this field
|
||||
items: Array.isArray(u.items) ? u.items.filter((x) => typeof x === 'string') : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1445,12 +1449,29 @@ export async function createFieldView(host, options = {}) {
|
||||
return info;
|
||||
}
|
||||
|
||||
// a hand item on a scouted prep board (UnitInfo kind 'item'): the plate's icon and colour resolve client-side,
|
||||
// exactly like the own prep bench (pieceInfo)
|
||||
function scoutItemInfo(info) {
|
||||
const rec = data.item(info.defId);
|
||||
const tier = rec?.tier || info.tier || 1;
|
||||
return { ...info,
|
||||
icon: assets.itemIcon ? assets.itemIcon(rec ? { trapId: rec.trapId, iconId: rec.iconId } : info.defId) : null,
|
||||
color: (info.golden || rec?.isGolden) ? 0xffc600 : TIER_COLORS[tier] || TIER_COLORS[1] };
|
||||
}
|
||||
|
||||
function battleView(id) {
|
||||
let v = views.get(id);
|
||||
if (v) return v;
|
||||
const info = infos.get(id);
|
||||
if (!info || gone.has(id)) return null;
|
||||
v = info.kind === 'device' ? new DeviceView(ctx, info) : new UnitView(ctx, info, { prep: !!battleMeta?.prep && info.side === 'ally' });
|
||||
v = info.kind === 'device' ? new DeviceView(ctx, info)
|
||||
: info.kind === 'item' ? new ItemView(ctx, scoutItemInfo(info))
|
||||
: new UnitView(ctx, info, { prep: !!battleMeta?.prep && info.side === 'ally' });
|
||||
// a teammate's operator shows its equipped items like the own prep bench does (item pips; user playtest #2:
|
||||
// at the unit, not only in the detail card) — prep surfaces only, the battle HUD stays as it is
|
||||
if (v.setItems && battleMeta?.prep && Array.isArray(info.items) && info.items.length) {
|
||||
v.setItems(info.items.map((it) => { const r = data.item(it); return assets.itemIcon ? assets.itemIcon(r ? { trapId: r.trapId, iconId: r.iconId } : it) : null; }));
|
||||
}
|
||||
v.setWorld(info.x, info.y, 0);
|
||||
v._seen = false;
|
||||
v._born = performance.now();
|
||||
|
||||
@@ -96,7 +96,7 @@ import {
|
||||
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
|
||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
|
||||
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId,
|
||||
} from '../ui/gameLogic.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
import { BriefingScreen } from './briefing.js';
|
||||
@@ -234,6 +234,10 @@ function MatchScreen() {
|
||||
const alive = spectator ? false : priv ? priv.alive !== false : meP?.alive !== false;
|
||||
const home = homeFieldId(pub, myId);
|
||||
const watchingOther = !!watching && watching !== home && watching !== ownFieldId(myId);
|
||||
// watching a teammate's prep board: the 本局信息 drawer shows THEIR 策略 in place of one's own (user playtest #2 item 2)
|
||||
const scoutPid = watchingOther && field?.prep && typeof field.fieldId === 'string' && field.fieldId.startsWith('n:') ? field.fieldId.slice(2) : null;
|
||||
const scoutBandId = scoutPid ? ownerBandId(pub, scoutPid) : null;
|
||||
const scoutBandOwner = scoutPid ? (players.find((p) => p.playerId === scoutPid)?.name || null) : null;
|
||||
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther;
|
||||
const showShop = !!priv && alive && (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && !watchingOther;
|
||||
const layersDisabled = phase === PHASE.UNITE || isBossPhase(phase);
|
||||
@@ -404,7 +408,9 @@ function MatchScreen() {
|
||||
evBufRef.current.delete(field.fieldId);
|
||||
const earlySnap = snapBufRef.current.get(field.fieldId);
|
||||
snapBufRef.current.delete(field.fieldId);
|
||||
const kind = field.kind === 'hidden' ? 'boss' : field.kind || 'normal';
|
||||
// a scouted prep board frames like the own prep with the shop folded (the bench row included, app.js camRect);
|
||||
// 'prep' expands the rect's top to the hand row
|
||||
const kind = field.prep ? 'prep' : (field.kind === 'hidden' ? 'boss' : field.kind || 'normal');
|
||||
const pf = (Array.isArray(pub?.fields) ? pub.fields : []).find((f) => f && f.fieldId === field.fieldId);
|
||||
const members = Array.isArray(pf?.players) ? pf.players : Array.isArray(field.players) ? field.players : [];
|
||||
const sides = field.sides && typeof field.sides === 'object' ? field.sides : null;
|
||||
@@ -415,7 +421,7 @@ function MatchScreen() {
|
||||
// a lone player's boss field (solo modes, the odd player of a co-op Final Assault: the `_s` templates route every
|
||||
// enemy to the left objective) is framed on its own half like the ‹ › half view; pairs start on 全景
|
||||
const lone = kind === 'boss' && members.length === 1;
|
||||
setCam(kind, lone ? { rect: field.rect, side, half: true } : { rect: field.rect, side });
|
||||
setCam(kind, lone ? { rect: field.rect, side, half: true } : { rect: field.rect, side, ...(field.prep ? { shop: false } : {}) });
|
||||
audio.setFieldUnits(field.units);
|
||||
if (early && early.length) {
|
||||
// replay state-bearing events only (a burst of stale hit sparks / damage numbers would look wrong)
|
||||
@@ -1246,7 +1252,7 @@ function MatchScreen() {
|
||||
self=${Number.isFinite(priv?.lp) ? { lp: priv.lp, pending: liveLpNow.pending, unite: liveLpNow.unite, left: liveLpNow.left } : null}
|
||||
observe=${cc ? { canObserve: (p) => observeTarget(p, pub, myId, { observing: watchingOther, ownDone: localDone }), observing: watchingOther, onBack: backHome } : null} />
|
||||
|
||||
<div class="gm__effects"><${EffectsList} effects=${priv?.effects} /></div>
|
||||
<div class="gm__effects"><${EffectsList} effects=${watchingOther && field ? (field.effects ?? null) : priv?.effects} /></div>
|
||||
|
||||
${watchingOther && !combat ? html`<div class="gm__watching" role="status">
|
||||
<${GIcon} name="eye" /><span>正在查看 <b>${watchedName}</b> 的阵地(只读)</span>
|
||||
@@ -1283,6 +1289,7 @@ function MatchScreen() {
|
||||
</div>
|
||||
|
||||
${drawer ? html`<${EnemyDrawer} tab=${drawer} onTab=${setDrawer} pub=${pub} priv=${priv} onClose=${() => setDrawer(null)}
|
||||
bandId=${scoutBandId} bandOwner=${scoutBandOwner}
|
||||
onEnemy=${(k, n) => setDetail({ kind: 'enemy', id: k, count: n })} onChess=${(id) => setDetail({ kind: 'chess', id })} />` : null}
|
||||
|
||||
${bondPop ? html`<${BondPopup} bondId=${bondPop.bondId} entry=${bondPop.entry} priv=${bondPop.priv} banned=${pub?.bannedChess || []} owner=${bondPop.name}
|
||||
|
||||
@@ -629,6 +629,8 @@ export function resolveDetail(target, pieces) {
|
||||
const u = target.unit || {};
|
||||
const own = Number.isInteger(u.uid) ? pieces?.get(u.uid) : null;
|
||||
if (u.side === 'enemy') { const en = data.lookup('enemies', u.defId); return en ? { type: 'enemy', enemy: en, unitId: u.id } : null; }
|
||||
// a hand item on a scouted prep board (m.field units, kind 'item'): the item's own card
|
||||
if (u.kind === 'item') { const it = data.lookup('items', u.defId); return it ? { type: 'item', item: it } : null; }
|
||||
const c = data.lookup('chess', u.defId);
|
||||
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null };
|
||||
const t = data.lookup('tokens', u.defId);
|
||||
|
||||
@@ -59,19 +59,20 @@ function EnemiesTab({ pub, priv, onEnemy }) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function InfoTab({ pub, priv, onChess }) {
|
||||
function InfoTab({ pub, priv, onChess, bandId = null, bandOwner = null }) {
|
||||
const { bonds, banned, perBond, stateOf } = matchInfoModel(pub, {
|
||||
bonds: data.list('bonds'), chess: (id) => data.lookup('chess', id), mode: data.get('config')?.modes?.[pub?.modeId],
|
||||
});
|
||||
const disabled = new Set(bonds.filter((b) => stateOf(b.bondId)).map((b) => b.bondId));
|
||||
const band = priv?.bandId ? data.lookup('bands', priv.bandId) : null;
|
||||
// while scouting a teammate's prep board the drawer shows THEIR 策略 in place of one's own (user playtest #2 item 2)
|
||||
const band = (bandId || priv?.bandId) ? data.lookup('bands', bandId || priv.bandId) : null;
|
||||
const stage = pub?.stageId ? data.lookup('stages', pub.stageId) : null;
|
||||
const withBans = bonds.filter((b) => disabled.has(b.bondId) || (perBond.get(b.bondId) || 0) > 0)
|
||||
.sort((a, b) => (disabled.has(b.bondId) - disabled.has(a.bondId)) || ((perBond.get(b.bondId) || 0) - (perBond.get(a.bondId) || 0)));
|
||||
return html`<div class="edrawer__body">
|
||||
${band ? html`<div class="iband">
|
||||
<${BandIcon} bandId=${band.bandId} size="md" />
|
||||
<div><${MicroLabel} tone="mint">STRATEGY // 我的策略</${MicroLabel}><b>${band.name} <small class="t-lo">${band.effectName}</small></b>
|
||||
<div><${MicroLabel} tone="mint">STRATEGY // ${bandOwner ? `${bandOwner} 的策略` : '我的策略'}</${MicroLabel}><b>${band.name} <small class="t-lo">${band.effectName}</small></b>
|
||||
<${RichText} text=${band.descRaw || band.desc} class="iband__desc" /></div>
|
||||
</div>` : null}
|
||||
${stage ? html`<p class="istage"><${Icon} name="rook" />战场:<b>${stage.name || stage.id}</b></p>` : null}
|
||||
@@ -88,14 +89,17 @@ function InfoTab({ pub, priv, onChess }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ tab:'enemies'|'info', onTab:(t:string)=>void, pub:any, priv:any, onClose:Function, onEnemy:(key:string, count:number)=>void, onChess:(id:string)=>void }} props
|
||||
* @param {{ tab:'enemies'|'info', onTab:(t:string)=>void, pub:any, priv:any, onClose:Function, onEnemy:(key:string, count:number)=>void, onChess:(id:string)=>void,
|
||||
* bandId?: string|null, bandOwner?: string|null }} props
|
||||
* bandId / bandOwner: while scouting a teammate's prep board, the watched player's 策略 (m.public players[].bandId)
|
||||
* replaces one's own in the 本局信息 tab (game.js scoutBandId; user playtest #2 item 2)
|
||||
*/
|
||||
export function EnemyDrawer({ tab, onTab, pub, priv, onClose, onEnemy, onChess }) {
|
||||
export function EnemyDrawer({ tab, onTab, pub, priv, onClose, onEnemy, onChess, bandId = null, bandOwner = null }) {
|
||||
return html`<div class="edrawer brackets" role="dialog" aria-label=${tab === 'info' ? '本局信息' : '敌方情报'}>
|
||||
<div class="edrawer__top">
|
||||
<${Tabs} size="sm" value=${tab} onChange=${onTab} items=${[{ id: 'info', label: '本局信息' }, { id: 'enemies', label: '敌方情报' }]} />
|
||||
<button type="button" class="edrawer__close tapx" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
|
||||
</div>
|
||||
${tab === 'info' ? html`<${InfoTab} pub=${pub} priv=${priv} onChess=${onChess} />` : html`<${EnemiesTab} pub=${pub} priv=${priv} onEnemy=${onEnemy} />`}
|
||||
${tab === 'info' ? html`<${InfoTab} pub=${pub} priv=${priv} onChess=${onChess} bandId=${bandId} bandOwner=${bandOwner} />` : html`<${EnemiesTab} pub=${pub} priv=${priv} onEnemy=${onEnemy} />`}
|
||||
</div>`;
|
||||
}
|
||||
@@ -150,6 +150,13 @@ export function boardTileOf(field, r, c) {
|
||||
return [r - BOSS_ROW_SHIFT, field === 'bossR' ? MAX_COL - c : c];
|
||||
}
|
||||
|
||||
/** The band (策略) a player picked, from m.public.players[].bandId (Match.js marksPublic) — the detail card shows it
|
||||
* on a teammate's unit (user playtest #2 item 2: watching a teammate revealed nothing about their 策略). */
|
||||
export function ownerBandId(pub, ownerId) {
|
||||
const p = Array.isArray(pub?.players) ? pub.players.find((x) => x && x.playerId === ownerId) : null;
|
||||
return typeof p?.bandId === 'string' && p.bandId ? p.bandId : null;
|
||||
}
|
||||
|
||||
/** Banner shown when a phase starts: { title, sub?, tone } or null. */
|
||||
export function phaseBanner(phase, pub) {
|
||||
const r = int(pub?.round, 0);
|
||||
|
||||
Reference in new issue
Block a user