// 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}` : 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`` : 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}`} />
${skSlot && (lo?.choices || 0) > 1 ? html`${skSlot}` : null}${sk.name}${lo && !lo.defaultSkill ? html`已调配` : null}
${SP_TYPE[sk.spType] || '技力'} ${SKILL_TYPE[sk.skillType] || '自动触发'} ${sk.spType !== 'ON_DEPLOY' && sk.skillType !== 'PASSIVE' ? html`<${GIcon} name="bolt" />初始 ${sk.initSp ?? 0} · 消耗 ${sk.spCost ?? 0}` : null} ${sk.duration > 0 ? html`持续 ${sk.duration}s` : null} ${sk.maxChargeTime > 1 ? html`充能 ${sk.maxChargeTime}` : 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.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``; }