// 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,
// 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,
// effect; tokens — the owner's variant (a golden owner's summon: its `_b` stats / skill), how a placed summon takes
// the field (shared/constants.js SKILL_SUMMON_START_DEPLOY), its token skill and talents; 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).
// Live stats (user playtest #4 item 7 — the card used to show the fixed record numbers): `live` = the unit's current
// stats (shared/protocol.js unitStatsEntry + `src`) — in battle the browser's own sim (battle/runner.js unitStats; a
// getter re-read 4× a second: current HP, max HP, ATK, DEF, RES, attack interval, block), in prep the stats the own
// board's units start their next battle with (m.unitStats: equipment, bonds / layers, 特质, band and 机变 effects). Each
// value is coloured against the unit's base like the official card — green when it helps (higher, or a shorter attack
// interval) with the difference beside it, red when it hurts — and a 实时 / 开战时 tag says which it is.
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 { 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 { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.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: '被动' };
/** Fallback type icon by trigger, for a garrison record without its official `eventTypeIcon` (garrisonTypeIconKey). */
const EVENT_ICON = { IN_BATTLE: 's_icon_battle', SERVER_GAIN: 's_icon_bond', SERVER_PREP_START: 's_icon_bond', SERVER_PREP_FIN: 's_icon_bond', 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, tone = null, title }) {
return html`
${k}${v}${sub ? html`${sub}` : null}
`;
}
/** Tolerance below which a live stat counts as its base (display rounding). */
const STAT_EPS = { interval: 0.005, res: 0.05, moveSpeed: 0.005 };
/**
* How a live stat compares with the unit's base (the official card's colours): 'up' (green) when it helps — higher,
* or a shorter attack interval —, 'down' (red) when it hurts, null when equal or unknown.
* @param {string} key maxHp | atk | def | res | interval | blockCnt | moveSpeed
* @param {any} cur @param {any} base
* @returns {'up'|'down'|null}
*/
export function statTone(key, cur, base) {
if (!Number.isFinite(cur) || !Number.isFinite(base)) return null;
const d = cur - base;
if (Math.abs(d) < (STAT_EPS[key] ?? 0.5)) return null;
return (key === 'interval' ? d < 0 : d > 0) ? 'up' : 'down';
}
/**
* One stat of the card: the live value (when `live` has it) coloured against `live.base`, with the difference as the
* small text; else the record value.
* @param {any} live unitStatsEntry (+ src) or null
* @param {string} key unitStatsEntry key
* @param {any} fallback record value
* @param {(v: any) => any} [fmt]
* @returns {{ v: any, tone: 'up'|'down'|null, sub: string|null, title: string|undefined }}
*/
export function liveStat(live, key, fallback, fmt = fmtNum) {
const cur = live && Number.isFinite(live[key]) ? live[key] : null;
if (cur == null) return { v: fallback == null ? '—' : fmt(fallback), tone: null, sub: null, title: undefined };
const base = live.base && Number.isFinite(live.base[key]) ? live.base[key] : null;
const tone = statTone(key, cur, base);
let sub = null;
if (tone) {
const d = cur - base;
sub = key === 'interval' ? `${d > 0 ? '+' : '−'}${Math.abs(d).toFixed(2)}` : `${d > 0 ? '+' : '−'}${key === 'res' || key === 'moveSpeed' ? Math.round(Math.abs(d) * 10) / 10 : fmtNum(Math.abs(d))}`;
}
return { v: fmt(cur), tone, sub, title: base != null ? `基础 ${fmt(base)}` : undefined };
}
/** The tag of a live stats block: 实时 (battle) / 开战时 (the prep preview). */
function LiveTag({ live }) {
if (!live) return null;
const battle = live.src === 'battle';
return html`${battle ? '实时' : '开战时'}`;
}
const fmtInterval = (v) => (Number.isFinite(v) && v > 0 ? `${v.toFixed(2)}s` : '—');
const fmtRes = (v) => (Number.isFinite(v) ? String(Math.round(v * 10) / 10) : '0');
/** HP bar of the card header: the live HP in battle, else the snapshot's. */
function hpOf(live, snapHp) {
if (live && live.src === 'battle' && Number.isFinite(live.hp) && Number.isFinite(live.maxHp)) return { hp: live.hp, max: live.maxHp };
return snapHp || null;
}
function Section({ title, micro, children, class: cls }) {
return html`
`;
}
/**
* 特性 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;
}
/**
* Order of an operator card's blocks (user playtest #3 item 8): the operator's own effect (特质 — garrison: its trigger
* such as 休整期结束时 and what it does) right under the header, visible without scrolling; the class trait (特性) and
* the stats next; then the skill, the elite's module, the equipped items (the player's own build) and the talents.
*/
export const CHESS_SECTIONS = Object.freeze(['head', 'garrison', 'trait', 'stats', 'skill', 'module', 'equip', 'talents', 'actions']);
/**
* Sprite key (ui `garrisonTypeIcon/…`, small variant) of a 特质's type chip: the garrison's own official
* `eventTypeIcon` — icon_battle 作战能力 / icon_gold 整备能力 / icon_bond 持续叠加·单次叠加 / icon_support 特异化, the
* icon that goes with its `eventTypeDesc` — never a guess from the trigger (that put the spoked 特异化 glyph, which reads
* like a loading spinner, before the text of every <休整期开始时 / 结束时> 特质: user playtest #3 item 9).
* @param {{ eventTypeIcon?: string|null, eventType?: string }|null} garrison
*/
export function garrisonTypeIconKey(garrison) {
const k = garrison && typeof garrison.eventTypeIcon === 'string' ? garrison.eventTypeIcon : '';
if (/^icon_[a-z]+$/.test(k)) return `s_${k}`;
return EVENT_ICON[garrison?.eventType] || 's_icon_bond';
}
/** The operator's own effect (特质, garrisons.json): trigger chip + description, compact. */
function GarrisonBlock({ garrison, m }) {
return html`
` : null;
const out = CHESS_SECTIONS.map((k) => blocks[k]).filter(Boolean);
// a merge-completing shop / reward card: where the elite goes (shopBar mergeHint), right under the header
if (hint) out.splice(1, 0, html`
<${Icon} name="info" />可晋升:${hint}
`);
return out;
}
/**
* An item's card (hand / temp / shop / equipped). An effect-only item (items.json `shopExcluded`: the special 维式重锤,
* 突变细胞 — user playtest #4 item 5) says it is never sold and where it comes from (`shopExcludedBy`).
*/
export function ItemDetail({ item, piece, editable, onDestroy }) {
const m = data.get('assets');
return 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}`;
}
/**
* How a summon piece placed in the prep phase takes the field (user playtest #6; sim/content/tokens.js): a talent
* summon deploys with the board; a skill's summon (赫默's 医疗探机, 巫恋's 诅咒娃娃) once at the battle start and again
* with each skill (`startDeploy`: shared/constants.js SKILL_SUMMON_START_DEPLOY, the PRTS reading the user settled) —
* or, with the switch off, only when its owner's skill fires. null for tokens that are no hand piece.
* @param {any} token tokens.json record
* @param {boolean} [startDeploy] the sim's switch (tests pass both values)
*/
export function summonDeployHint(token, startDeploy = SKILL_SUMMON_START_DEPLOY) {
if (!token || token.kind !== 'summon' || token.placeable !== true) return null;
const talent = Object.values(token.variants || {}).some((v) => (v?.sources || []).includes('talent'));
if (talent) return '作战开始时在摆放的位置部署';
return startDeploy
? '作战开始时在摆放的位置部署一次,之后所属干员每次发动技能时再次出现(未摆放则不会出现)'
: '所属干员发动技能时才在摆放的位置出现(未摆放则不会出现)';
}
/**
* The token variant of the summon's owner (tokens.json `variants`, keyed by owner chess id): a golden owner's `_b`
* entry (精锐 赫默's drone ATK 114, 精锐 巫恋's doll −30%), else its normal `_a` entry, else the first one.
* @param {any} token tokens.json record
* @param {string|null} ownerId the owner's chess id (null: unknown, e.g. a teammate's summon)
*/
export function tokenVariantFor(token, ownerId = null) {
const vs = token?.variants || {};
if (typeof ownerId === 'string') {
const v = vs[ownerId] || vs[ownerId.replace(/_b$/, '_a')];
if (v) return v;
}
return Object.values(vs)[0] || null;
}
/** Chess id of the operator owning a token piece (`ownerUid`), from the player's own pieces (indexPieces). */
function tokenOwnerId(piece, pieces) {
if (!piece || piece.kind !== 'token' || !Number.isInteger(piece.ownerUid)) return null;
const owner = pieces?.get(piece.ownerUid)?.piece;
return owner && owner.kind === 'chess' ? owner.id : null;
}
export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live = null }) {
const m = data.get('assets');
// the owner's variant: its stats, talents and token skill (a golden owner's summon is stronger)
const v0 = tokenVariantFor(token, ownerId);
const s = v0?.stats || token.stats || {};
const hp = hpOf(live, snapHp);
const st = {
maxHp: liveStat(live, 'maxHp', s.maxHp), atk: liveStat(live, 'atk', s.atk), def: liveStat(live, 'def', s.def),
blockCnt: liveStat(live, 'blockCnt', s.blockCnt, (v) => String(v)),
};
// what the summon does lives in its talent (凯瑟琳's 支援装置: "使攻击范围内一名友方干员获得…屏障") or token skill (诅咒娃娃)
const talents = (v0?.talents || []).filter((t) => t && t.name && t.desc);
const skill = v0?.skill && v0.skill.desc && !/^skcom_withdraw/.test(String(v0.skill.skillId || '')) ? v0.skill : null;
const hint = piece ? summonDeployHint(token) : null;
return html`