feedback1c WK: 变形同构体 wearers in the bond popup's members and on their card
Player report: with 变形同构体 and the matching bond item the wearer gets the bond's effect, but the bond's member count leaves the converted operator out. The count that activates the bond already included the wearer on every server path (bondsMeta.pieceBonds: prep count, views, battle input; support.unitBonds: the battle). The client did not: the bond popup's member list (成员 x/y) and the wearer's card read only the chess records' bonds, so the popup said 在场 3 but 成员 2/10 without the wearer, and the card had no chip for the bond. - gameLogic.grantedBonds / pieceBondIds: the server's 变形同构体 rule on the client; bondMembers lists wearers (tagged 同构, one row per operator, on the board or owned), so 成员 x/y agrees with 在场. - detailPanel: the card's chips include the granted bond (dashed, 同构). - Teammates: UnitInfo carries an ally operator's item ids (sim unitInfo, Match.prepFieldMeta); ownerBoard / runner.ownerOps keep them. - Tests: every pairing on real matches (prep, views, battle input, the real battle), bench vs board, half pairs, distinct members; the client rule against the server's; popup / card rendering.
This commit is contained in:
1 parent
7f134737ce
commit
0c898bb524
13 files changed
+464
-34
No files matched your search
@@ -1,7 +1,8 @@
|
||||
// Bond strip (active-bond discs under the top bar) and the bond detail popup: the key facts first (user playtest #2
|
||||
// item 9: name, members in play / next threshold, layers, reached tier and its threshold row, the current effect with
|
||||
// layer-resolved numbers), then the full description and the member list with owned / on-board state. Opened from
|
||||
// the detail panel's bond chips it docks beside that panel (`beside`: the panel's side).
|
||||
// layer-resolved numbers), then the full description and the member list with owned / on-board state — operators that
|
||||
// are members through 变形同构体 included (tagged 同构, gameLogic bondMembers), so the list's count agrees with 在场. Opened
|
||||
// from the detail panel's bond chips it docks beside that panel (`beside`: the panel's side).
|
||||
// Research 06 §11.1: round mint discs, stack count over the disc, name below, sorted by stacks; grey =
|
||||
// present but inactive; in 联防 / boss rounds the strip is dimmed ("层数叠加已禁用").
|
||||
// Watching a teammate (DESIGN §20.15, ui/watchBonds.js) the strip and the popup show THAT player's bonds and layers:
|
||||
@@ -71,7 +72,7 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
|
||||
const th = Array.isArray(entry?.thresholds) && entry.thresholds.length ? entry.thresholds : b.thresholds || [];
|
||||
const tier = entry?.tier ?? bondTier(count, th, b.maxCount);
|
||||
const active = entry ? !!entry.active : tier > 0;
|
||||
const members = bondMembers(b, priv, banned, (id) => data.lookup('chess', id));
|
||||
const members = bondMembers(b, priv, banned, (id) => data.lookup('chess', id), (id) => data.lookup('items', id));
|
||||
const countsHand = entry?.countsHand ?? b.countsHand;
|
||||
const next = nextThreshold(count, th);
|
||||
const hasNow = !!(b.effectDescRaw || b.effectDesc);
|
||||
@@ -106,11 +107,13 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
|
||||
<section class="bpop__sec">
|
||||
<h4>成员 <small>${members.filter((x) => x.onBoard).length}/${members.length}</small></h4>
|
||||
<div class="bpop__members">
|
||||
${members.map((mb) => html`<button key=${mb.id} type="button" class=${cx('bpop__member', mb.onBoard && 'is-on', mb.owned && !mb.onBoard && 'is-owned', mb.banned && 'is-banned')}
|
||||
onClick=${() => onMember?.(mb.id)} title=${`${mb.name}${mb.banned ? '(本局禁用)' : mb.onBoard ? '(在场)' : mb.owned ? '(整备区)' : ''}`}>
|
||||
${members.map((mb) => html`<button key=${mb.id} type="button" class=${cx('bpop__member', mb.onBoard && 'is-on', mb.owned && !mb.onBoard && 'is-owned', mb.banned && 'is-banned', mb.granted && 'is-granted')}
|
||||
onClick=${() => onMember?.(mb.id)} data-granted=${mb.granted ? '1' : null}
|
||||
title=${`${mb.name}${mb.granted ? '(变形同构体:视为本盟约成员)' : ''}${mb.banned ? '(本局禁用)' : mb.onBoard ? '(在场)' : mb.owned ? '(整备区)' : ''}`}>
|
||||
<${UnitThumb} kind="chess" id=${mb.id} size="sm" dim=${!mb.owned || mb.banned} />
|
||||
<span class="bpop__mname">${mb.name}</span>
|
||||
${mb.banned ? html`<span class="bpop__ban"><${Icon} name="close" /></span>` : null}
|
||||
${mb.granted ? html`<span class="bpop__iso" aria-hidden="true">同构</span>` : null}
|
||||
</button>`)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+23
-13
@@ -1,8 +1,9 @@
|
||||
// Detail panel (click / right-click a piece, shop card, bond member, battle unit or previewed enemy):
|
||||
// operators — portrait, name, tier, elite, class/subclass and, right under them in the header's right column (no
|
||||
// scrolling, user playtest #2 item 9), the unit's bonds (阵营 / 盟约: icon, name, member count / next threshold,
|
||||
// reached tier, active state — tap one for its popup); right under the header the operator's own effect (特质 —
|
||||
// garrison: type chip (its official type icon + eventTypeDesc such as 整备能力, garrisonTypeIconKey) + description,
|
||||
// reached tier, active state — tap one for its popup; a bond its 变形同构体 pairing grants (gameLogic pieceBondIds: its
|
||||
// piece's items, a teammate's unit: UnitInfo `items`) has a dashed chip tagged 同构 — the wearer counts for it); right
|
||||
// under the header the operator's own effect (特质 — garrison: type chip (its official type icon + eventTypeDesc such as 整备能力, garrisonTypeIconKey) + description,
|
||||
// compact, visible without scrolling: user playtest #3 items 8 / 9), the class trait (特性), stats + range mini-map, skill (the one chosen in the loadout, DESIGN §16: icon, SP
|
||||
// info, rich description, 已调配 when not the default), elite module (模组: official type icon from the local-client
|
||||
// art, else its letter), equipped items, talents (CHESS_SECTIONS); items — icon, tier,
|
||||
@@ -21,7 +22,7 @@
|
||||
|
||||
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
|
||||
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
|
||||
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier } from './gameLogic.js';
|
||||
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, pieceBondIds } from './gameLogic.js';
|
||||
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
|
||||
import { data } from '../data.js';
|
||||
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
|
||||
@@ -129,11 +130,13 @@ function ItemRow({ itemId }) {
|
||||
|
||||
/**
|
||||
* The unit's bonds right under the header: icon, name, the player's member count / next threshold, reached tier.
|
||||
* @param {{ bondIds: string[], bonds?: any[], onBond?: (bondId: string) => void }} props
|
||||
* `granted`: the bonds among them the unit holds through 变形同构体 (dashed chip, 同构 tag).
|
||||
* @param {{ bondIds: string[], bonds?: any[], onBond?: (bondId: string) => void, granted?: string[] }} props
|
||||
*/
|
||||
export function BondChips({ bondIds, bonds = [], onBond = null }) {
|
||||
export function BondChips({ bondIds, bonds = [], onBond = null, granted = [] }) {
|
||||
const ids = Array.isArray(bondIds) ? bondIds.filter((b) => typeof b === 'string') : [];
|
||||
if (!ids.length) return null;
|
||||
const iso = new Set(Array.isArray(granted) ? granted : []);
|
||||
const mine = new Map((Array.isArray(bonds) ? bonds : []).filter((b) => b && typeof b.bondId === 'string').map((b) => [b.bondId, b]));
|
||||
return html`<div class="dbonds dbonds--top" role="list" aria-label="所属盟约">
|
||||
${ids.map((id) => {
|
||||
@@ -145,16 +148,18 @@ export function BondChips({ bondIds, bonds = [], onBond = null }) {
|
||||
const active = e ? !!e.active : tier > 0;
|
||||
const next = nextThreshold(count, th);
|
||||
const cap = next ?? th[th.length - 1] ?? null;
|
||||
const label = `${rec?.name || id}:在场 ${count}${cap != null ? `/${cap}` : ''}${active ? `,已激活 ${tier} 阶` : ',未激活'}`;
|
||||
const label = `${rec?.name || id}${iso.has(id) ? '(变形同构体)' : ''}:在场 ${count}${cap != null ? `/${cap}` : ''}${active ? `,已激活 ${tier} 阶` : ',未激活'}`;
|
||||
const body = html`
|
||||
<${BondGlyph} bondId=${id} class="dbond__icon" />
|
||||
<span class="dbond__name">${rec?.name || id}</span>
|
||||
${iso.has(id) ? html`<span class="dbond__iso">同构</span>` : null}
|
||||
<span class=${cx('dbond__count', 'num', next == null && count > 0 && 'is-max')}>${count}${cap != null ? html`<small>/${cap}</small>` : null}</span>
|
||||
${th.length ? html`<span class="dbond__tiers" aria-hidden="true">${th.map((_, i) => html`<i key=${i} class=${i < tier ? 'on' : ''}></i>`)}</span>` : null}`;
|
||||
return onBond
|
||||
? html`<button key=${id} type="button" role="listitem" class=${cx('dbond', active && 'is-active', rec?.isCore && 'is-core')} title=${label} aria-label=${label}
|
||||
data-bond=${id} onClick=${() => onBond(id)}>${body}</button>`
|
||||
: html`<span key=${id} role="listitem" class=${cx('dbond', active && 'is-active', rec?.isCore && 'is-core')} title=${label} data-bond=${id}>${body}</span>`;
|
||||
? html`<button key=${id} type="button" role="listitem" class=${cx('dbond', active && 'is-active', rec?.isCore && 'is-core', iso.has(id) && 'is-granted')} title=${label} aria-label=${label}
|
||||
data-bond=${id} data-granted=${iso.has(id) ? '1' : null} onClick=${() => onBond(id)}>${body}</button>`
|
||||
: html`<span key=${id} role="listitem" class=${cx('dbond', active && 'is-active', rec?.isCore && 'is-core', iso.has(id) && 'is-granted')} title=${label} data-bond=${id}
|
||||
data-granted=${iso.has(id) ? '1' : null}>${body}</span>`;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
@@ -204,7 +209,7 @@ function GarrisonBlock({ garrison, m }) {
|
||||
</section>`;
|
||||
}
|
||||
|
||||
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond, live = null, hint = null }) {
|
||||
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond, live = null, hint = null, unitItems = null }) {
|
||||
const m = data.get('assets');
|
||||
const hp = hpOf(live, snapHp);
|
||||
const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id));
|
||||
@@ -220,6 +225,11 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
|
||||
const skSlot = sk && Number.isInteger(sk.index) ? `S${sk.index + 1}` : null;
|
||||
const garrison = Array.isArray(c.garrisonIds) && c.garrisonIds[0] ? data.lookup('garrisons', c.garrisonIds[0]) : null;
|
||||
const items = Array.isArray(piece?.items) ? piece.items : [];
|
||||
// the bonds the unit counts for: its own + a 变形同构体 pairing's (its piece's items; a teammate's unit: UnitInfo items)
|
||||
const carried = piece ? items : (Array.isArray(unitItems) ? unitItems : []);
|
||||
const getItem = (id) => data.lookup('items', id);
|
||||
const bondIds = pieceBondIds(c, carried, getItem);
|
||||
const grantedIds = bondIds.filter((b) => !(Array.isArray(c.bonds) && c.bonds.includes(b)));
|
||||
const sell = c.sellPrice ?? 1;
|
||||
const blocks = {};
|
||||
blocks.head = html`
|
||||
@@ -244,7 +254,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
|
||||
<span class="dhead__pos">${c.position === 'MELEE' ? '近战位' : '远程位'}</span>
|
||||
</div>
|
||||
${hp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
|
||||
<${BondChips} bondIds=${c.bonds} bonds=${bonds} onBond=${onBond} />
|
||||
<${BondChips} bondIds=${bondIds} bonds=${bonds} onBond=${onBond} granted=${grantedIds} />
|
||||
</div>
|
||||
</div>`;
|
||||
blocks.garrison = garrison ? html`<${GarrisonBlock} key="garrison" garrison=${garrison} m=${m} />` : null;
|
||||
@@ -479,7 +489,7 @@ export function resolveDetail(target, pieces) {
|
||||
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; }
|
||||
const c = data.lookup('chess', u.defId);
|
||||
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id };
|
||||
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);
|
||||
if (t) return { type: 'token', token: t, unitId: u.id, ownerId: tokenOwnerId(own?.piece, pieces) };
|
||||
const en = data.lookup('enemies', u.defId);
|
||||
@@ -521,7 +531,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
|
||||
<button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
|
||||
<div class="dpanel__scroll">
|
||||
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
|
||||
bonds=${bonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} />` : null}
|
||||
bonds=${bonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null} />` : null}
|
||||
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} />` : null}
|
||||
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
|
||||
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
|
||||
|
||||
@@ -371,26 +371,76 @@ export function nextThreshold(count, thresholds) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Member rows of a bond popup: every visible member with owned / on-board / banned state.
|
||||
* Bonds granted by 变形同构体: an item with `canGiveBond` worn together with an item that has a `giveBondId` makes the
|
||||
* wearer a member of that other item's bond (band 变形者集群 "与特定装备一同装备时装备者将视为特定盟约成员"; the item's
|
||||
* talent, character_table trap_1073_acarm073, lists the 14 pairings). The same rule as the server's count
|
||||
* (server/match/bondsMeta.js pieceBonds) and the battle's (server/sim/content/support unitBonds).
|
||||
* @param {Array<string|{id:string}>|null|undefined} items the carried items (ids or { id } pieces)
|
||||
* @param {(id:string)=>any} [getItem] items.json lookup
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function grantedBonds(items, getItem = () => null) {
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
if (list.length < 2) return [];
|
||||
const recs = list.map((it) => getItem(typeof it === 'string' ? it : it?.id)).filter(isObj);
|
||||
if (!recs.some((r) => r.canGiveBond)) return [];
|
||||
const out = [];
|
||||
for (const r of recs) if (!r.canGiveBond && typeof r.giveBondId === 'string' && r.giveBondId && !out.includes(r.giveBondId)) out.push(r.giveBondId);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A chess piece's bonds: its record's + those its 变形同构体 pairing grants (grantedBonds) — the detail card's chips.
|
||||
* @param {any} chess chess.json record @param {Array<string|{id:string}>|null|undefined} items @param {(id:string)=>any} [getItem]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function pieceBondIds(chess, items, getItem = () => null) {
|
||||
const out = (Array.isArray(chess?.bonds) ? chess.bonds : []).filter((b) => typeof b === 'string');
|
||||
for (const b of grantedBonds(items, getItem)) if (!out.includes(b)) out.push(b);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Member rows of a bond popup: every visible member with owned / on-board / banned state, plus the player's operators
|
||||
* that are members through 变形同构体 (grantedBonds; `granted: true`, one row per operator — normal and elite copies are
|
||||
* one member, like the count's distinct members), so "成员 x/y" agrees with the count the server reports (在场).
|
||||
* @param {any} bond bonds.json record
|
||||
* @param {any} priv m.private (hand/board/temp)
|
||||
* @param {any} priv m.private (hand/board/temp) — or a teammate's field operators (ui/watchBonds.js ownerBoard)
|
||||
* @param {Set<string>|string[]} [banned] banned base chess ids
|
||||
* @param {(id:string)=>any} [getChess]
|
||||
* @param {(id:string)=>any} [getItem] items.json lookup (the 变形同构体 pairings)
|
||||
*/
|
||||
export function bondMembers(bond, priv, banned = [], getChess = () => null) {
|
||||
export function bondMembers(bond, priv, banned = [], getChess = () => null, getItem = () => null) {
|
||||
const bannedSet = banned instanceof Set ? banned : new Set(Array.isArray(banned) ? banned : []);
|
||||
const members = Array.isArray(bond?.visibleMembers) && bond.visibleMembers.length ? bond.visibleMembers : (Array.isArray(bond?.members) ? bond.members : []);
|
||||
const baseOf = (id) => getChess(id)?.baseId || (typeof id === 'string' ? id.replace(/_b$/, '_a') : id);
|
||||
const onBoard = new Set();
|
||||
const owned = new Set();
|
||||
for (const p of Array.isArray(priv?.board) ? priv.board : []) if (p?.kind === 'chess') { onBoard.add(baseOf(p.id)); owned.add(baseOf(p.id)); }
|
||||
for (const p of [...(Array.isArray(priv?.hand) ? priv.hand : []), ...(Array.isArray(priv?.temp) ? priv.temp : [])]) {
|
||||
if (p?.kind === 'chess') owned.add(baseOf(p.id));
|
||||
const memberSet = new Set(members);
|
||||
/** base id → on the board? — operators of the player that join this bond through 变形同构体 */
|
||||
const granted = new Map();
|
||||
const grants = (p) => typeof bond?.bondId === 'string' && grantedBonds(p.items, getItem).includes(bond.bondId);
|
||||
for (const p of Array.isArray(priv?.board) ? priv.board : []) {
|
||||
if (p?.kind !== 'chess') continue;
|
||||
const base = baseOf(p.id);
|
||||
onBoard.add(base); owned.add(base);
|
||||
if (!memberSet.has(base) && grants(p)) granted.set(base, true);
|
||||
}
|
||||
return members.map((id) => {
|
||||
for (const p of [...(Array.isArray(priv?.hand) ? priv.hand : []), ...(Array.isArray(priv?.temp) ? priv.temp : [])]) {
|
||||
if (p?.kind !== 'chess') continue;
|
||||
const base = baseOf(p.id);
|
||||
owned.add(base);
|
||||
if (!memberSet.has(base) && !granted.has(base) && grants(p)) granted.set(base, false);
|
||||
}
|
||||
const rows = members.map((id) => {
|
||||
const c = getChess(id);
|
||||
return { id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: onBoard.has(id), owned: owned.has(id), banned: bannedSet.has(id) };
|
||||
}).sort((a, b) => (b.onBoard - a.onBoard) || (b.owned - a.owned) || (a.tier - b.tier) || (a.id < b.id ? -1 : 1));
|
||||
});
|
||||
for (const [id, on] of granted) {
|
||||
const c = getChess(id);
|
||||
rows.push({ id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: on, owned: true, banned: false, granted: true });
|
||||
}
|
||||
return rows.sort((a, b) => (b.onBoard - a.onBoard) || (b.owned - a.owned) || (a.tier - b.tier) || (a.id < b.id ? -1 : 1));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -151,8 +151,9 @@ export function withLiveLayers(bonds, live) {
|
||||
* (gameLogic bondMembers): their operators on the field meta on screen (the prep scouting board, a server-run battle
|
||||
* field's units) and, under client-side combat, the battle on screen's (`extra` = battle/runner.js ownerOps: the meta
|
||||
* the runner publishes is taken before the operators deploy). Their hand is never sent — members count as owned only
|
||||
* when they are on the field. null without a field or battle.
|
||||
* @param {any} field @param {string} ownerId @param {any[]|null} [extra] UnitInfo-like { kind, ownerId, defId }
|
||||
* when they are on the field. Their operators keep the items the unit info carries (UnitInfo `items`), so a 变形同构体
|
||||
* wearer is listed as a member of the bond it grants (gameLogic grantedBonds). null without a field or battle.
|
||||
* @param {any} field @param {string} ownerId @param {any[]|null} [extra] UnitInfo-like { kind, ownerId, defId, items? }
|
||||
*/
|
||||
export function ownerBoard(field, ownerId, extra = null) {
|
||||
const fromField = isObj(field) && Array.isArray(field.units) ? field.units : null;
|
||||
@@ -160,7 +161,7 @@ export function ownerBoard(field, ownerId, extra = null) {
|
||||
if (!fromField && !fromBattle) return null;
|
||||
const board = [...(fromField || []), ...(fromBattle || [])]
|
||||
.filter((u) => isObj(u) && u.ownerId === ownerId && u.kind === 'op' && typeof u.defId === 'string')
|
||||
.map((u) => ({ kind: 'chess', id: u.defId }));
|
||||
.map((u) => (Array.isArray(u.items) && u.items.length ? { kind: 'chess', id: u.defId, items: u.items.filter((x) => typeof x === 'string') } : { kind: 'chess', id: u.defId }));
|
||||
return { board, hand: [], temp: [] };
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user