// 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); then 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), talents, 特质 (garrison), equipped items; items — icon, tier,
// effect; tokens; enemies — stats, rank, faction tags, abilities. Selling / destroying is the underframe's job in the
// match (research 09 §5, ui/underframe.js): the panel's own 出售 / 销毁 buttons only render for callers that pass
// `editable` + handlers. `side` 'right' docks the panel at the right edge (the game screen picks the side away from a
// selected unit's underframe, gameLogic panelSide).
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog } from './components.js';
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier } 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';
import { moduleBadge } from './loadoutModel.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const PROF_NAME = { PIONEER: '先锋', WARRIOR: '近卫', TANK: '重装', SNIPER: '狙击', CASTER: '术师', MEDIC: '医疗', SUPPORT: '辅助', SPECIAL: '特种', TOKEN: '召唤物' };
const SP_TYPE = { INCREASE_WITH_TIME: '自动回复', INCREASE_WHEN_ATTACK: '攻击回复', INCREASE_WHEN_TAKEN_DAMAGE: '受击回复', ON_DEPLOY: '被动' };
const SKILL_TYPE = { MANUAL: '自动触发', AUTO: '自动触发', PASSIVE: '被动' };
const EVENT_ICON = { IN_BATTLE: 's_icon_battle', SERVER_GAIN: 's_icon_bond', SERVER_PREP_START: 's_icon_support', SERVER_PREP_FIN: 's_icon_support', SERVER_CHESS_SOLD: 's_icon_gold', SERVER_PRICE: 's_icon_gold', SERVER_REFRESH_SHOP: 's_icon_gold' };
const RANK = { NORMAL: '普通', ELITE: '精英', BOSS: '领袖' };
const DMG = { phys: '物理', arts: '法术', heal: '治疗', true: '真实', none: '无' };
/** Mini range map. */
export function RangeGrid({ grid, class: cls }) {
const box = rangeGridBox(grid);
if (!box.cells.size) return html`—`;
const cells = [];
for (let r = box.r0; r > box.r0 - box.rows; r--) {
for (let c = box.c0; c < box.c0 + box.cols; c++) {
const self = r === 0 && c === 0;
cells.push(html``);
}
}
return html`
${cells}
`;
}
function Stat({ k, v, sub }) {
return html`${k}${v}${sub ? html`${sub}` : null}
`;
}
function Section({ title, micro, children, class: cls }) {
return html`
${title}${micro ? html`<${MicroLabel}>${micro}${MicroLabel}>` : null}
${children}
`;
}
function ItemRow({ itemId }) {
const it = data.lookup('items', itemId);
return html`
<${UnitThumb} kind="item" id=${itemId} size="sm" />
${it?.name || itemId}<${RichText} text=${it?.descRaw || it?.desc || ''} class="ditem__desc" />
`;
}
/**
* 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
*/
export function BondChips({ bondIds, bonds = [], onBond = null }) {
const ids = Array.isArray(bondIds) ? bondIds.filter((b) => typeof b === 'string') : [];
if (!ids.length) return null;
const mine = new Map((Array.isArray(bonds) ? bonds : []).filter((b) => b && typeof b.bondId === 'string').map((b) => [b.bondId, b]));
return html`
${ids.map((id) => {
const rec = data.lookup('bonds', id);
const e = mine.get(id) || null;
const th = Array.isArray(e?.thresholds) && e.thresholds.length ? e.thresholds : Array.isArray(rec?.thresholds) ? rec.thresholds : [];
const count = Number.isFinite(e?.count) ? e.count : 0;
const tier = Number.isFinite(e?.tier) ? e.tier : bondTier(count, th, rec?.maxCount ?? 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 body = html`
<${BondGlyph} bondId=${id} class="dbond__icon" />
${rec?.name || id}
0 && 'is-max')}>${count}${cap != null ? html`/${cap}` : null}
${th.length ? html`${th.map((_, i) => html``)}` : null}`;
return onBond
? html``
: html`${body}`;
})}
`;
}
/**
* 特性 text of the record the unit fights with (DESIGN §16: `lo.record` = the chosen module's traitOverride, or the
* no-module traitBase for 不装备 — data `trait` is the default module's).
*/
function traitText(c, golden, lo) {
const t = (lo?.record || c).trait || {};
const base = t.descRaw || t.desc || '';
if (!golden || lo?.record !== c) return base;
return t.moduleDescRaw || base;
}
function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond }) {
const m = data.get('assets');
const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id));
const c = chess;
// stats / talents the unit fights with: the chosen module's (or none — statsBase) for an elite (DESIGN §16)
const fr = lo?.record || c;
const s = fr.stats || {};
const golden = !!(c.isGolden || piece?.golden);
const interval = attackInterval(s.bat, s.aspd);
const sk = lo?.skill || c.skill || null;
// a chosen skill the manifest has no icon for (only the default skills' icons are fetched): its slot letter
const skIcon = sk && lo && !lo.defaultSkill ? skillRecordIconUrl(m, sk, { empty: false }) : skillIconUrl(m, c);
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 : [];
const sell = c.sellPrice ?? 1;
return html`
<${Img} src=${chessPortraitUrl(m, c)} fallback=${html`<${UnitThumb} kind="chess" id=${c.chessId} size="lg" />`} />
<${TierChip} tier=${c.tier} golden=${golden} size="lg" />
${golden ? html`精锐` : null}
${piece?.kind === 'token' ? html`召唤物` : null}
${c.name}
${c.appellation || ''}
<${Img} src=${profIconUrl(m, c.profession)} class="dhead__prof" />
${PROF_NAME[c.profession] || c.profession || ''}
<${Img} src=${subProfIconUrl(m, c)} class="dhead__sub" />
${c.subProfessionName || ''}
${c.position === 'MELEE' ? '近战位' : '远程位'}
${snapHp ? html`
${fmtNum(snapHp.hp)} / ${fmtNum(snapHp.max)}
` : null}
<${BondChips} bondIds=${c.bonds} bonds=${bonds} onBond=${onBond} />
${c.trait?.desc ? html`<${Icon} name="info" /><${RichText} text=${traitText(c, golden, lo)} />
` : null}
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} />
<${Stat} k="攻击" v=${fmtNum(s.atk)} />
<${Stat} k="防御" v=${fmtNum(s.def)} />
<${Stat} k="法术抗性" v=${s.res ?? 0} />
<${Stat} k="攻击间隔" v=${interval ? `${interval.toFixed(2)}s` : '—'} />
<${Stat} k="阻挡数" v=${s.blockCnt ?? '—'} />
<${Stat} k="部署费用" v=${s.cost ?? '—'} />
<${Stat} k="再部署" v=${s.respawnTime != null ? `${s.respawnTime}s` : '—'} />
攻击范围<${RangeGrid} grid=${attackRangeGrid(fr) || c.rangeGrid} />
${sk ? html`<${Section} title="技能" micro="SKILL" class="dsec--skill">
<${Img} src=${skIcon} class="dskill__icon" fallback=${html`
${skSlot ? html`${skSlot}` : null}`} />
<${RichText} as="p" text=${sk.descRaw || sk.desc} class="dtext" />
/>` : null}
${golden && lo?.module ? html`<${Section} title="模组" micro="MODULE" class="dsec--module">
${!lo.module.none && lo.module.typeName ? html`
<${Img} src=${moduleTypeIconUrl(data.get('local'), lo.module.typeName)} fallback=${html`${moduleBadge(lo.module)}`} />` : null}
${lo.module.name}
${lo.module.typeName ? html`${lo.module.typeName}` : null}
${!lo.defaultModule ? html`已调配` : null}
/>` : null}
${Array.isArray(fr.talents) && fr.talents.some((t) => t && t.name && !t.hidden) ? html`<${Section} title="天赋" micro="TALENT">
${fr.talents.filter((t) => t && t.name && !t.hidden).map((t, i) => html`${t.name}<${RichText} text=${t.descRaw || t.desc} class="dtext" />
`)}
/>` : null}
${garrison ? html`<${Section} title="特质" micro="GARRISON">
<${Img} src=${uiUrl(m, `garrisonTypeIcon/${EVENT_ICON[garrison.eventType] || 's_icon_bond'}`)} class="dgarrison__icon" />
${garrison.eventTypeDesc || ''}
<${RichText} as="p" text=${garrison.descRaw || garrison.desc} class="dtext" />
/>` : null}
${piece?.kind === 'chess' ? html`<${Section} title="装备" micro=${`EQUIP ${items.length}/2`}>
${items.length ? items.map((it) => html`<${ItemRow} key=${it.uid} itemId=${it.id} />`) : html`拖拽装备至该干员以配发(最多 2 件)
`}
/>` : null}
${piece && editable && piece.kind !== 'item' ? html`
<${Button} variant="amber" icon="close" class="dpanel__sell" onClick=${() => onSell(piece, c)}>出售+${sell}/>
` : null}`;
}
function ItemDetail({ item, piece, editable, onDestroy }) {
const m = data.get('assets');
return html`
<${Img} src=${itemIconUrl(m, item)} fallback=${html`<${GIcon} name="bolt" />`} />
<${TierChip} tier=${item.tier} golden=${item.isGolden} size="lg" />${item.isGolden ? html`进阶` : null}
${item.itemType === 'MAGIC' ? '奇术' : '装备'}
${item.name}
${item.flavor ? html`
${item.flavor}` : null}
<${Section} title="效果" micro="EFFECT"><${RichText} as="p" text=${item.descRaw || item.desc} class="dtext" />/>
${item.itemType === 'MAGIC'
? html`<${Icon} name="info" />将其拖拽至战场上的格子使用
`
: html`<${Icon} name="info" />拖拽至干员身上进行配发(每名干员最多 2 件,配发后无法取下)${item.mergeable ? ';2 件相同装备自动合成进阶装备' : ''}
`}
${piece && editable ? html`<${Button} variant="danger" onClick=${() => onDestroy(piece, item)}>销毁道具/>
` : null}`;
}
function EnemyDetail({ enemy, snapHp, count }) {
const m = data.get('assets');
const s = enemy.stats || {};
const types = Array.isArray(enemy.acTypes) ? enemy.acTypes : enemy.acType ? [enemy.acType] : [];
const factions = data.get('factions')?.types || {};
const imm = Object.entries(s.immunities || {}).filter(([, v]) => v).map(([k]) => ({ stun: '晕眩', silence: '沉默', sleep: '沉睡', frozen: '冻结', levitate: '浮空' }[k] || k));
const interval = attackInterval(s.bat, s.aspd);
return html`
<${Img} src=${enemyIconUrl(m, enemy.key)} fallback=${html`<${GIcon} name="skull" />`} />
${RANK[enemy.rank] || '普通'}
${s.motion === 'FLY' ? '空中' : '地面'}
${count ? html`×${count}` : null}
${enemy.name}
${types.map((t) => html`<${Img} src=${factionIconUrl(m, factions[t]?.icon)} />${factions[t]?.name || t}`)}
${snapHp ? html`
${fmtNum(snapHp.hp)} / ${fmtNum(snapHp.max)}
` : null}
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} />
<${Stat} k="攻击" v=${fmtNum(s.atk)} sub=${DMG[s.dmgType] || ''} />
<${Stat} k="防御" v=${fmtNum(s.def)} />
<${Stat} k="法术抗性" v=${s.res ?? 0} />
<${Stat} k="移动速度" v=${s.moveSpeed ?? '—'} />
<${Stat} k="攻击间隔" v=${interval ? `${interval.toFixed(1)}s` : '—'} />
<${Stat} k="攻击范围" v=${s.rangeRadius > 0 ? s.rangeRadius : '近战'} />
<${Stat} k="目标价值" v=${s.lpr ?? 1} />
${imm.length ? html`<${Icon} name="shield" />免疫:${imm.join('、')}
` : null}
${Array.isArray(enemy.abilities) && enemy.abilities.length ? html`<${Section} title="能力" micro="ABILITIES">
${enemy.abilities.map((a, i) => html`- <${RichText} text=${typeof a === 'string' ? a : a.textRaw || a.text} />
`)}
/>` : enemy.descRaw || enemy.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${enemy.descRaw || enemy.desc} class="dtext" />/>` : null}`;
}
function TokenDetail({ token, piece }) {
const m = data.get('assets');
const s = token.stats || {};
return html`
<${Img} src=${tokenAvatarUrl(m, token.tokenId)} fallback=${html`<${GIcon} name="target" />`} />
召唤物${piece?.count > 1 ? html`×${piece.count}` : null}
${token.name}
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} /><${Stat} k="攻击" v=${fmtNum(s.atk)} />
<${Stat} k="防御" v=${fmtNum(s.def)} /><${Stat} k="阻挡数" v=${s.blockCnt ?? '—'} />
${token.descRaw || token.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${token.descRaw || token.desc} class="dtext" />/>` : null}`;
}
/**
* Resolve what a detail target shows.
* @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token', id?:string, uid?:number, unit?:any, count?:number }} target
* @param {Map} pieces indexPieces(priv)
*/
export function resolveDetail(target, pieces) {
if (!target) return null;
if (target.kind === 'piece') {
const e = pieces?.get(target.uid);
if (!e) return null;
const p = e.piece;
if (p.kind === 'item') { const it = data.lookup('items', p.id); return it ? { type: 'item', item: it, piece: p } : null; }
if (p.kind === 'token') { const t = data.lookup('tokens', p.id); return t ? { type: 'token', token: t, piece: p } : null; }
const c = data.lookup('chess', p.id);
return c ? { type: 'chess', chess: c, piece: p } : null;
}
if (target.kind === 'chess') { const c = data.lookup('chess', target.id); return c ? { type: 'chess', chess: c } : null; }
if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; }
if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; }
if (target.kind === 'token') { const t = data.lookup('tokens', target.id); return t ? { type: 'token', token: t } : null; }
if (target.kind === 'unit') {
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; }
const c = data.lookup('chess', u.defId);
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id };
const t = data.lookup('tokens', u.defId);
if (t) return { type: 'token', token: t, unitId: u.id };
const en = data.lookup('enemies', u.defId);
return en ? { type: 'enemy', enemy: en, unitId: u.id } : null;
}
return null;
}
/**
* The panel.
* @param {{ detail:any, editable:boolean, snapHp?:{hp:number,max:number}|null, onClose:Function, onSell:(piece:any)=>void, onDestroy:(piece:any)=>void,
* bonds?: any[], loadout?: any, onBond?: (bondId:string)=>void, side?: 'left'|'right', shopOpen?: boolean }} props
* bonds: the owner's m.private.bonds (counts / tiers of the bond chips); loadout: m.private.loadout (DESIGN §16) for
* the player's own operators and shop cards; a teammate's unit gets its owner's choice (gameLogic unitLoadout); null
* = the defaults
*/
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], loadout = null, onBond = null, side = 'left', shopOpen = false }) {
if (!detail) return null;
const sellIt = async (piece, chess) => {
const golden = piece.golden || chess?.isGolden;
if (golden) {
const ok = await confirmDialog({ title: '出售精锐干员', text: `确定要出售精锐干员「${chess?.name || ''}」吗?出售后获得 ${chess?.sellPrice ?? 1} 资金。`, okText: '出售', danger: true });
if (!ok) return;
}
onSell(piece);
};
const destroyIt = async (piece, item) => {
const ok = await confirmDialog({ title: '销毁道具', text: `道具无法出售。确定要销毁「${item?.name || ''}」吗?`, okText: '销毁', danger: true });
if (ok) onDestroy(piece);
};
return html``;
}