feedback5 WN: 补位 display follows the official game (the owner's recall of 2026-10-06) — a chess the player does not own is shown as its stand-in wherever the player's copy appears: shop and reward cards (the stand-in's portrait, name, class and backup skill; the chess's bonds, tier and price; a small 「替补」 mark), the hand and 临时整备区 pieces (the stand-in's model, a 「替补」 tag; the board already was), the detail card (the stand-in's portrait, name, skill, talents and module with the chess's bonds and 特质, 「银灰的替补」 under the name [ASSUMED]), bond popups (own and a teammate's by UnitInfo standInFor through ownerBoard / ownerOps), the result lineup (m.result standInFor), prep scouting of the bench, the equip-replace dialog, the elite and gift tickers; rules unchanged (bonds, price, tier, merges, pools and 特质 stay the chess's); new strings through t() with English; tests updated (standin-ui 15, standin-ownership 7, watch-bonds wiring) and the e2e now ends on the result screen (fastServer SP_FINISH_AFTER); golden unchanged

This commit is contained in:
sganggs committed 2026-10-06 02:40:38 +08:00
1 parent 78251f499c
commit 0e8004a382
32 files changed
+518 -181

No files matched your search

+9 -5
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, unitLoadout, deployedRecord,
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord,
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord, ownStandIn,
} from '../ui/gameLogic.js';
import { toast } from '../ui/toasts.js';
import { BriefingScreen } from './briefing.js';
@@ -240,6 +240,10 @@ function MatchScreen() {
// the own pieces' records: a DIY slot the player filled is its 自选 operator (0.2.0, m.private.diy — position, range,
// name; gameLogic/diy.js), every other chess its data record
const ownChess = (id) => { const c = gd.chess(id); return ownDiyRecord(c, live.current.priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; };
// what the own pieces show (names and art of the facing wheel, the underframe, the equip-replace dialog): a chess the
// player does not own is its stand-in (0.2.0 补位 — the owner's recall of the official mode, 2026-10-06); rules keep
// ownChess (the composed record carries the chess's identity anyway)
const ownShown = (id) => { const c = ownChess(id); return ownStandIn(c, live.current.priv, data.get('backups')) || c; };
const placeCtx = useMemo(() => placementContext({
priv, stage: gd.stage(pub?.stageId), editable, field: deployField,
getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; },
@@ -729,7 +733,7 @@ function MatchScreen() {
const L = live.current;
if (intent.confirmReplace) {
// both slots used: the player picks the equipped item to destroy (cancel ⇒ nothing is sent)
const request = replaceRequest(L.placeCtx, intent, ownChess, gd.item);
const request = replaceRequest(L.placeCtx, intent, ownShown, gd.item);
if (request) {
const uid = await openReplaceRef.current(request);
if (!Number.isInteger(uid)) { audio.sfx('back', { volume: 0.5 }); return; }
@@ -755,7 +759,7 @@ function MatchScreen() {
/** Open the direction wheel for a legal board drop (the piece stays on the tile meanwhile). */
const openFacing = (entry, t) => {
const piece = entry.piece;
const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : ownChess(piece.id);
const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : ownShown(piece.id);
holdPiece(view, piece.uid, { row: t.row, col: t.col });
setSel(null);
setFacing({ uid: piece.uid, piece, row: t.row, col: t.col, grid: previewGrid(lookups, piece), name: rec?.name || '' });
@@ -1279,7 +1283,7 @@ function MatchScreen() {
${bondPop ? html`<${BondPopup} bondId=${bondPop.bondId} entry=${bondPop.entry} priv=${bondPop.priv} banned=${pub?.bannedChess || []} owner=${bondPop.name}
off=${offBonds.has(bondPop.bondId)}
place=${bpPlace} over=${!!resolved && bpPlace === dSide}
onClose=${() => setBondOpen(null)} onMember=${(id, items) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null })} />` : null}
onClose=${() => setBondOpen(null)} onMember=${(id, items, standInFor) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null, standInFor: standInFor || null })} />` : null}
${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }}
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats}
@@ -1287,7 +1291,7 @@ function MatchScreen() {
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
row=${pieceTile(selEntry)?.row} col=${pieceTile(selEntry)?.col} actions=${underframeActions(placeCtx, sel.uid)} busy=${selBusy}
name=${(selEntry.piece.kind === 'item' ? gd.item(selEntry.piece.id) : selEntry.piece.kind === 'token' ? gd.token(selEntry.piece.id) : ownChess(selEntry.piece.id))?.name || ''}
name=${(selEntry.piece.kind === 'item' ? gd.item(selEntry.piece.id) : selEntry.piece.kind === 'token' ? gd.token(selEntry.piece.id) : ownShown(selEntry.piece.id))?.name || ''}
onRetreat=${retreatSel} onSell=${sellSel} onDestroy=${sellSel} />` : null}
</div>
+11 -11
View File
@@ -1,20 +1,21 @@
// public/js/screens/game/standInTags.js — 0.2.0 补位: the 「替补:X」 tag under the player's own prep pieces (hand,
// 临时整备区, board) of a chess they do not own (m.private.standIns). The hand keeps the chess's own model (the card shows
// the original operator); on the board the model is the stand-in's (render/app.js pieceInfo) — the tag says which
// chess it is either way. 0.2.0 自选编队: the 「自选」 tag under the player's own pieces of a DIY slot it filled
// public/js/screens/game/standInTags.js — 0.2.0 补位: the small 「替补」 tag under the player's own prep pieces (hand,
// 临时整备区, board) of a chess they do not own (m.private.standIns): the model is the stand-in's everywhere
// (render/app.js pieceInfo — the owner's recall of the official mode, 2026-10-06), the tag says it fields for another
// chess (the detail card names it). 0.2.0 自选编队: the 「自选」 tag under the player's own pieces of a DIY slot it filled
// (m.private.diy) — the model is the operator's everywhere. Anchored to each piece's drawn body (view.pieceScreenRect,
// followed every frame like the direction wheel follows its tile); never takes the pointer, so the pieces stay
// draggable.
import { useEffect, useRef, useState } from '../../../vendor/hooks.module.js';
import { html } from '../../ui/components.js';
import { fieldsStandIn, standInOf, standInLabel, ownDiyRecord } from '../../ui/gameLogic.js';
import { ownStandIn, standInLabel, standInTip, ownDiyRecord } from '../../ui/gameLogic.js';
import { t } from '../../../../shared/i18n.js';
const rawOf = (view) => (view && view.raw) || view || null;
/**
* The player's own prep pieces that fight as stand-ins: [{ uid, label, name, area }] (hand, temp, board).
* The player's own prep pieces that fight as stand-ins: [{ uid, label, name, area, tip }] (hand, temp, board; `name` =
* the stand-in's, `tip` = which chess it fields for).
* @param {any} priv m.private @param {(id: string) => any} getChess @param {any} backups data/backups.json
*/
export function standInPieces(priv, getChess, backups) {
@@ -23,9 +24,8 @@ export function standInPieces(priv, getChess, backups) {
const add = (p, area) => {
if (!p || p.kind !== 'chess' || !Number.isInteger(p.uid)) return;
const c = getChess(p.id);
if (!fieldsStandIn(priv, c)) return;
const si = standInOf(c, backups);
if (si) out.push({ uid: p.uid, label: standInLabel(si), name: si.name, area });
const si = ownStandIn(c, priv, backups);
if (si) out.push({ uid: p.uid, label: standInLabel(si), name: si.name, area, tip: standInTip(si, c.name) });
};
for (const p of Array.isArray(priv.hand) ? priv.hand : []) add(p, 'hand');
for (const p of Array.isArray(priv.temp) ? priv.temp : []) add(p, 'temp');
@@ -85,8 +85,8 @@ export function StandInTags({ view, priv, getChess, backups, diyData = null }) {
return html`<div class="sitags" aria-hidden="true" data-testid="standin-tags">
${items.map((it) => {
const p = pos[it.uid];
return p ? html`<span key=${it.uid} class=${`sitag sitag--${it.area}${it.diy ? ' sitag--diy' : ''}`} data-uid=${it.uid} data-diy=${it.diy ? '1' : undefined}
title=${it.diy ? t('自选编队:{name}', { name: it.name }) : `未持有:由 ${it.name} 上场`}
return p ? html`<span key=${it.uid} class=${`sitag sitag--${it.area}${it.diy ? ' sitag--diy' : ''}`} data-uid=${it.uid} data-diy=${it.diy ? '1' : undefined} data-standin=${it.diy ? undefined : '1'}
title=${it.diy ? t('自选编队:{name}', { name: it.name }) : it.tip}
style=${`left:${p.x.toFixed(1)}px;top:${p.y.toFixed(1)}px`}>${it.label}</span>` : null;
})}
</div>`;
+6 -3
View File
@@ -1,8 +1,10 @@
// 干员持有 (operator ownership, 0.2.0 补位 — the approved plan, owner's decision 2026-10-05): the second tab of the
// 干员调配 overlay (screens/loadout.js). The 53 NORMAL chess of this season's shop, grouped by tier — each card shows the
// operator and its official stand-in (avatar, name, the skill it fields) and switches between 持有 and 未持有 with a
// tap. A chess marked 未持有 keeps its shop / hand card (name, bonds, price, with a 「替补:X」 badge) and is deployed
// as the stand-in (model, skills, talents, range). PRESET chess always field their own operator and are not listed.
// tap. A chess marked 未持有 is shown as its stand-in in the match — shop and reward cards, hand, board, detail card,
// result (portrait, name, model, skills, talents, range; a small 「替补」 mark — the owner's recall of the official mode,
// 2026-10-06) — while its bonds, 特质, tier, price and merge stay the chess's. PRESET chess always field their own
// operator and are not listed.
// Default: everything owned; 全部持有 resets. Out of match: the next match takes the list (in co-op it only affects the
// player's own pieces). The list lives in ui/loadoutSync.js (localStorage + room.ownership); the model is
// ui/ownershipModel.js. Styles: css/screens/loadout.css (own-*).
@@ -14,6 +16,7 @@ import { chessAvatarUrl } from '../ui/assetUrls.js';
import { data } from '../data.js';
import { PROF_NAME } from '../ui/loadoutModel.js';
import { ownershipRoster, rosterByTier, isOwned, standInSummary } from '../ui/ownershipModel.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI'];
@@ -62,7 +65,7 @@ export function OwnershipPanel({ m, roster, notOwned, onToggle }) {
const groups = rosterByTier(list);
return html`<main class="own" data-testid="ownership">
<div class="own__bar">
<p class="own__lead">未持有的干员由官方指定的<b>替补干员</b>上场:棋子的名字、盟约、特质、阶级与价格不变,上场的是替补干员(模型、技能、天赋、攻击范围)。预设干员无论是否持有都由本人上场,不在此列。</p>
<p class="own__lead">${t('未持有的干员由官方指定的替补干员上场:商店、整备区、作战区与结算都显示替补干员(模型、技能、天赋、攻击范围),盟约、特质、阶级、价格与合成仍按原干员。预设干员无论是否持有都由本人上场,不在此列。')}</p>
<button type="button" class=${cx('lo-toggle', offOnly && 'is-on')} aria-pressed=${offOnly ? 'true' : 'false'} onClick=${() => setOffOnly(!offOnly)}>
<i class="lo-toggle__box"><${Icon} name="check" /></i>仅看未持有</button>
</div>
+17 -4
View File
@@ -8,7 +8,9 @@
// title: 'comment_1' | { id, name? } | null,
// lineup: [{ id /* chessId, golden id if elite */, golden?, tier?, items?: [itemId],
// diy? /* 0.2.0 自选编队: a DIY slot's pick { charId, skillIndex, uniEquipId } — the card
// draws the operator (gameLogic diyRecordFor) */ }],
// draws the operator (gameLogic diyRecordFor) */,
// standInFor? /* 0.2.0 补位: the replaced operator's charId — the card draws the stand-in
// with a small 「替补」 mark (gameLogic standInOf; the owner's recall, 2026-10-06) */ }],
// bonds?: [{ bondId, layers, active }],
// stats: { dmgDealt, kills, leaks, gold /* funds SPENT */, refreshes, merges, bossDamage?, itemsEquipped?,
// activatedLayers?, lpLost?, perfectRounds? } }] }
@@ -16,7 +18,7 @@
import { useEffect } from '../../vendor/hooks.module.js';
import { html, Button, Icon, MicroLabel, DifficultyTag } from '../ui/components.js';
import { useGameData, Img, UnitThumb, BandIcon, PlayerAvatar, BondGlyph, LpTower, Sprite } from '../ui/gameComponents.js';
import { normalizeResult, fmtNum, diyRecordFor } from '../ui/gameLogic.js';
import { normalizeResult, fmtNum, diyRecordFor, cardStandIn, standInForText } from '../ui/gameLogic.js';
import { data } from '../data.js';
import { enemyIconUrl, titleIconUrl, uiUrl } from '../ui/assetUrls.js';
import { store, useStore, emptyMatch } from '../store.js';
@@ -32,6 +34,18 @@ const STAT_ROWS = [
['refreshes', '刷新次数'], ['leaks', '未击倒'], ['lpLost', '损失生命'],
];
/**
* One unit of a final lineup: a 自选 piece draws its operator; a 补位 piece (`standInFor`) its stand-in, with the small
* 「替补」 mark — the title names the chess it fielded for.
*/
export function LineupThumb({ u, gd }) {
const chess = u.kind === 'token' ? null : gd.chess(u.id);
const dr = u.diy && chess ? diyRecordFor(chess, u.diy, { chess: data.get('chess'), backups: data.get('backups') }) : null;
const si = !dr && chess ? cardStandIn(chess, { unit: u, backups: gd.backups }) : null;
return html`<${UnitThumb} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm" rec=${dr || si}
title=${si ? `${si.name}(${standInForText(chess.name)})` : undefined} />`;
}
function PlayerCard({ p, myId, titles, best, solo = false }) {
const gd = useGameData();
const titleRec = p.title ? titles.find((t) => t.id === p.title.id) || null : null;
@@ -49,8 +63,7 @@ function PlayerCard({ p, myId, titles, best, solo = false }) {
</div>
<div class="rcard__mid">
<div class="rcard__lineup">
${lineup.length ? lineup.map((u, i) => html`<${UnitThumb} key=${i} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm"
rec=${u.diy ? diyRecordFor(gd.chess(u.id), u.diy, { chess: data.get('chess'), backups: data.get('backups') }) : null} />`)
${lineup.length ? lineup.map((u, i) => html`<${LineupThumb} key=${i} u=${u} gd=${gd} />`)
: html`<span class="rcard__noinfo">${p.alive === false ? '阵容已撤离' : 'NO INFO'}</span>`}
</div>
${bonds.length ? html`<div class="rcard__bonds">${bonds.map((b) => html`<span key=${b.bondId} class=${cx('rbond', b.active && 'is-on')} title=${gd.bond(b.bondId)?.name || b.bondId}>