feedback5: merge feedback4 — PR #129, the lobby ERR import

This commit is contained in:
sganggs committed 2026-10-05 16:48:02 +08:00
commit 8ab6d021da
15 files changed
+521 -20

No files matched your search

+30
View File
@@ -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;
}
+3
View File
@@ -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
View File
@@ -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();
+11 -4
View File
@@ -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}
+2
View File
@@ -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);
+10 -6
View File
@@ -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>`;
}
+7
View File
@@ -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);