// 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; a bond the mode never activates reads 本局禁用, gameLogic
// modeOffBonds; a bond its 变形同构体 pairing grants (gameLogic pieceBondIds: its piece's items, a teammate's unit:
// UnitInfo `items`, a bond popup's 同构 row: the wearer's) 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 (read-only from those `items` when the card has no own piece), talents
// (CHESS_SECTIONS); items — icon, tier,
// effect; 变形同构体 (`canGiveBond`) also its 天赋栏 list (MorphPairings, gameLogic morphPairings: per bond the items that
// make the wearer its member, 本局禁用 marked; on a wearer's card the pairing it wears highlighted, 生效中 — GitHub issue
// #1, DESIGN §21.26) and a bond item (`giveBondId`) the line "与变形同构体一同装备时,携带者视为【X】成员" (MorphGrantLine);
// 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. The 攻击范围
// mini-map follows the live entry's `range` too (cardRangeGrid: the grid the unit attacks with now — a running skill's
// range such as 烛煌 S3's 4-11, rangeExtend included; community report E1 after 0.1.0, it used to stay the base grid);
// a grid larger than the box (RANGE_FIT) draws smaller cells (rangeGridStyle), a whole-field one reads 全场.
// The stats block (chessStatsBlock), the 特性 text (traitText) and the talent list (chessTalents) are exported: the 干员调配
// screen's 局内数值 section draws the same ones for the chosen skill / module, without a live entry (GitHub issue #64).
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, briefingBondTip, pieceBondIds, grantedBonds, morphPairings } from './gameLogic.js';
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
import { abilityRows } from './abilityLines.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: '无' };
/** A range grid of at least this many tiles covers the field (纯烬艾雅法拉 S3 "攻击范围扩大至整个战场"): named, not drawn. */
export const FIELD_WIDE_CELLS = 400;
/**
* Columns × rows of the mini-map's own cells (.14rem, 2px apart) the card's range box holds without growing: the widest
* record attack range (灰毫's 6 columns) and the tallest live one (银灰 S3's 7 rows). A larger grid — a live skill range
* such as 远牙 S3's line to the field's edge (21 tiles) — draws smaller cells, edge to edge, in that space, so the box keeps
* its size and the stats beside it stay readable.
*/
export const RANGE_FIT = Object.freeze({ cols: 6, rows: 7 });
/** Inline style of the mini-map (rangeGridBox `box`): its columns, and smaller cells when the grid exceeds RANGE_FIT. */
export function rangeGridStyle(box) {
const cols = `grid-template-columns:repeat(${box.cols}, var(--rg))`;
if (box.cols <= RANGE_FIT.cols && box.rows <= RANGE_FIT.rows) return cols;
const fit = (n, k) => `calc((${k} * .14rem + ${(k - 1) * 2}px) / ${n})`;
return `${cols};gap:0;--rg:max(1px, min(.14rem, ${fit(box.cols, RANGE_FIT.cols)}, ${fit(box.rows, RANGE_FIT.rows)}))`;
}
/**
* The grid the card's 攻击范围 shows: the live entry's `range` (shared/protocol.js unitStatsEntry — what the unit attacks
* with now: a running skill's range, rangeExtend included), else the loadout record's attack range at deployment
* (shared/loadoutRecord.js attackRangeGrid: an elite's module grid, a passive range skill, the 特性's 攻击距离 — the same
* tiles as the board overlay and the deploy wheel), else the record's own.
* @param {any} live unitStatsEntry (+ src) or null @param {any} rec loadout-resolved record @param {any} chess
* @returns {number[][]|null}
*/
export function cardRangeGrid(live, rec, chess) {
if (live && Array.isArray(live.range) && live.range.length) return live.range;
return attackRangeGrid(rec) || chess?.rangeGrid || null;
}
/** Mini range map. */
export function RangeGrid({ grid, class: cls }) {
if (Array.isArray(grid) && grid.length >= FIELD_WIDE_CELLS) return html`全场`;
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`
${children}
`;
}
const isOffIn = (off, bondId) => !!(off && typeof off.has === 'function' && off.has(bondId));
/**
* The 变形同构体's 天赋栏 (gameLogic morphPairings): "搭配以下装备时,携带者视为对应盟约的成员:", then one line per bond — 【bond】
* and the items that pair with it; a bond the mode never activates (`off`, gameLogic modeOffBonds) struck through with
* 本局禁用. `carried` (the wearer's items — the card shows the item on an operator): the pairing it wears is highlighted
* (生效中; 已搭配 when that bond is off), and a wearer without one reads 暂未生效.
* @param {{ off?: Set|null, carried?: Array|null }} props
*/
export function MorphPairings({ off = null, carried = null }) {
const rows = morphPairings(data.list('items'), data.list('bonds'), { off, carried });
if (!rows.length) return null;
const wearer = Array.isArray(carried);
return html`
`;
}
/** An equipped item of the card: icon, name, effect — and for 变形同构体 / a bond item its pairing (`carried`: the wearer's items). */
function ItemRow({ itemId, carried = null, off = null }) {
const it = data.lookup('items', itemId);
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). Also the 干员调配 screen's (screens/loadout.js).
*/
export function traitText(c, golden, lo) {
const t = (lo?.record || c).trait || {};
const base = t.descRaw || t.desc || '';
if (!golden) return base;
// the record's own module line, also for a record cloned for another skill or module (a 不装备 record carries none) —
// until 0.1.2 a clone fell back to the class trait (Grok review of GitHub #64; the in-match card had it too)
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`
<${RichText} as="p" text=${garrison.descRaw || garrison.desc} class="dgarrison__text" />
`;
}
/** The talents the card lists (天赋): the loadout record's named, not hidden ones. */
export function chessTalents(rec) {
return (Array.isArray(rec?.talents) ? rec.talents : []).filter((t) => t && t.name && !t.hidden);
}
/**
* The card's stats block (block key "stats"): the eight stats and the 攻击范围 mini-map. `rec` = the loadout-resolved
* record the unit fights with (chessLoadout `.record`: the chosen module's stats, the chosen skill's passive range),
* `chess` the chess record (the range's fallback), `live` the live / start-of-battle entry (liveStat) or null — the
* record's own numbers, untagged. A plain function (its root node keeps the key), shared by the detail card and the
* 干员调配 screen (screens/loadout.js 局内数值, GitHub issue #64): one block, so the two never disagree.
* @param {{ rec: any, chess: any, live?: any }} p
*/
export function chessStatsBlock({ rec, chess, live = null }) {
const s = rec?.stats || {};
const interval = attackInterval(s.bat, s.aspd);
// live (battle) / start-of-battle (prep) values against the base, else the record's (liveStat)
const st = {
maxHp: liveStat(live, 'maxHp', s.maxHp), atk: liveStat(live, 'atk', s.atk), def: liveStat(live, 'def', s.def),
res: liveStat(live, 'res', s.res ?? 0, fmtRes), interval: liveStat(live, 'interval', interval, fmtInterval),
blockCnt: liveStat(live, 'blockCnt', s.blockCnt, (v) => String(v)),
};
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 / reward card). 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`); a rule the
* official text leaves out (items.json `note`: 突变细胞 returns to the hand after each use) is shown under the effect.
* 变形同构体 lists its pairings (its 天赋栏: MorphPairings, `offBonds` marks 本局禁用); a bond item says which bond it gives
* a 变形同构体 wearer (MorphGrantLine). (An equipped item is shown on its wearer's card: ChessDetail's 装备 rows.)
*/
export function ItemDetail({ item, piece, editable, onDestroy, offBonds = null }) {
const m = data.get('assets');
return html`
/>` : 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`