// Bottom shop bar (research 06 §11.2 / D3): LEVEL card (upgrade price hex, 升级), 3–5 operator cards // (tier chip, price hex with discount/markup colours, portrait, bonds — a bond the mode never activates struck through, // 本局禁用 — class, frozen overlay, sold state, // merge progress), the item card, the funds card with ✕ 收起; above it 剩余可放置角色, 冻结/解冻 and 刷新. // Buying and upgrading take two taps (research 09 §5 / §6.5, official `EventOnFirstClick` → `EventOnConfirm` / // `EventOnUpgrade`): the first tap selects a card — it lifts and enlarges, its detail opens and it shows 确认购买 // (无法购买 + the reason when it cannot be bought) — and a second tap on the same card buys it; the LEVEL card's first // tap arms 确认升级 and the second upgrades (已满级 at level 6). A tap anywhere else, a shop change or the end of the // editable phase disarms. A tap anywhere on a card is the card's tap — its 确认购买 strip included (it takes no // pointer): there is no ⓘ corner (the official cards have none; user playtest #6 item 10 — on phones its invisible // 44 px touch area covered the card's bottom-right quarter, half the strip, and a second tap there only re-opened the // detail); a card that cannot be bought right now (the bar is not editable: ready, round start) opens its detail. The // D / R / F keys stay one-press shortcuts. There is no drag-to-sell zone: selling is the 出售 +N button of a tapped // unit's underframe (ui/underframe.js). After a promotion the operator cards are replaced by // the 晋升奖励 cards (3 free operators, pick 1 — also two taps) until picked or put off (稍后 → rewardOverlay.js pill); // a special refresh (凯瑟琳 定向投放's 3 items, 娜仁图亚, 寻呼模块, 信标, 松果) takes the same place under its own name, an // item slot drawn as an item card (player report #6 after 0.1.0). // Every operator card shows the skill it will fight with — the player's 干员调配 loadout (m.private.loadout, DESIGN // §16): the skill icon above the name, mint-framed with 已调配 in its title when it is not the default skill (and the // module type of an elite card, with its official type icon when the local-client art has it). A chess the player does // not own (0.2.0 补位, m.private.standIns) shows its stand-in — portrait, name, class and its backup skill, the one that // fights — with the chess's bonds, tier and price and a small ice 「替补」 mark (the owner's recall of the official mode, // 2026-10-06; the replaced operator's name is in the mark's title and on the detail card the first tap opens). A 自选 // piece (0.2.0, m.private.diy — a DIY slot the player filled, only ever in its own shop) shows the operator — name, // portrait, class, the bonds of its factions, the pick's skill and module — with a mint 「自选」 badge. import { useEffect, useState } from '../../vendor/hooks.module.js'; import { html, Icon, HexBadge, TierChip, Tooltip, MicroLabel } from './components.js'; import { Img, BondGlyph, CoinGlyph, GIcon, RichText } from './gameComponents.js'; import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip, ownStandIn, standInLoadout, standInLabel, standInTip, standInForText, ownDiyRecord, diyGetter } from './gameLogic.js'; import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js'; import { data } from '../data.js'; import { hotkeyLabelOf } from './settings.js'; import { t } from '../../../shared/i18n.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); function PriceHex({ slot, free, poor = false }) { const tone = priceTone(slot); const price = Number(slot?.price) || 0; if (free || price === 0) return html`FREE`; // like the original (img_bg_price_not_enough) the price turns grey while the funds don't cover it return html`<${HexBadge} value=${price} tone=${poor ? 'dark' : tone} size="md" class=${cx('scard__price', poor && 'is-poor')} title=${poor ? t('资金不足') : tone === 'discount' ? t('折扣价(原价 {basePrice})', { basePrice: slot.basePrice }) : tone === 'premium' ? t('加价(原价 {basePrice})', { basePrice: slot.basePrice }) : t('价格')} />`; } /** Data lookups (merge progress / target, the loadout; shopBlockReason ignores them — a full hand refuses every purchase). */ const LOOKUPS = { getChess: (id) => data.lookup('chess', id), getItem: (id) => data.lookup('items', id) }; /** * Where a merge-completing buy sends the elite (DESIGN §20.11), null when the card completes no merge: the 可晋升 tag's * title, and a line of the detail card the first tap opens — a touch screen never shows a title (QA 6b). */ export function mergeHint(priv, chessId) { const prog = mergeProgress(priv, chessId, LOOKUPS.getChess); if (!(prog.copies > 0 && prog.copies + 1 >= prog.need)) return null; return mergeTarget(priv, chessId, LOOKUPS.getChess) ? t('精锐干员将出现在作战区原位置') : t('精锐干员将进入整备区'); } /** * Whether a shop card shows its frost: its own slot's `frozen` (m.private shop.slots[i].frozen) — the 冻结 toggle copies onto * every unsold slot, and 梓兰's 猎头顾问 freezes ONE copied card with every active refresh while the toggle stays off * (GitHub #354: the bar drew only the toggle, so the frozen card showed no frost) — or the toggle itself (a frame without * per-slot flags). The 冻结 button and the bar's frame keep following the toggle alone. */ export const slotFrozen = (slot, toggle = false) => !!slot?.frozen || !!toggle; /** The armed (first-tapped) card's confirm strip: 确认购买 / 确认选择, or 无法购买 + why. */ function ArmedTag({ reason, free }) { if (reason) return html`${t('无法购买')}${reason}`; return html`${free ? t('确认选择') : t('确认购买')}${t('再次点击')}`; } /** * Operator card (shop and merge reward). `armed`: first tap done (second tap = `onBuy(idx)`). * @param {{ slot:any, idx:number, priv:any, frozen?:boolean, reason?:string|null, free?:boolean, armed?:boolean, * onTap?:(idx:number)=>void, onBuy:Function, onDetail:Function }} props */ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free = false, armed = false, onTap = null, onBuy, onDetail, offBonds = null }) { const c0 = data.lookup('chess', slot.id); // 0.2.0 自选编队: a DIY slot the player filled is its operator (shared/diy.js, the pick of m.private.diy) const diyData = { chess: data.get('chess'), backups: data.get('backups') }; const dr = c0 ? ownDiyRecord(c0, priv, diyData) : null; // 0.2.0 补位: the player's not-owned chess shows its stand-in (art, name, class, its backup skill); the composed record // keeps the chess's identity — tier, bonds, price (a DIY slot is never droppable: one or the other) const si = c0 && !dr ? ownStandIn(c0, priv, data.get('backups')) : null; const c = dr || si || c0; const m = data.get('assets'); const tier = c?.tier ?? 1; const prog = mergeProgress(priv, slot.id, (id) => data.lookup('chess', id)); const hint = mergeHint(priv, slot.id); const willMerge = !!hint; const bonds = Array.isArray(c?.bonds) ? c.bonds : []; const disabled = !!reason; const lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, dr ? diyGetter(LOOKUPS.getChess, priv, diyData) : LOOKUPS.getChess, { ops: priv?.ops ?? null, effects: data.get('effects') }) : null; const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'chess', hint); }; const name = `${c?.name || t('干员')}${si ? t('({note})', { note: standInForText(c0.name) }) : ''}`; const card = html``; return reason && reason !== t('已售出') && !armed ? html`<${Tooltip} text=${reason} block=${true} class="scard-wrap">${card}` : card; } /** The loadout's skill (icon; name + 已调配 in the tooltip) and an elite's module type, on an operator card. */ function SkillBadge({ chess, lo, standIn = false }) { const m = data.get('assets'); const custom = !lo.defaultSkill && !standIn; // a chosen skill without an icon in the manifest: its slot letter (S1–S3) instead of the blank skill sprite const src = custom ? skillRecordIconUrl(m, lo.skill, { empty: false }) : skillIconUrl(m, chess); const slot = Number.isInteger(lo.skill.index) ? `S${lo.skill.index + 1}` : null; const mod = lo.module && !lo.module.none ? lo.module : null; const tip = `${slot ? t('技能 {slot}:{name}', { slot, name: lo.skill.name || '' }) : t('技能:{name}', { name: lo.skill.name || '' })}${custom ? t('(已调配)') : ''}${mod ? t(' · 模组:{name}', { name: mod.name }) : lo.module?.none ? t(' · 未装备模组') : ''}`; return html` <${Img} src=${src} fallback=${slot ? html`${slot}` : html`<${GIcon} name="bolt" />`} /> ${mod && mod.typeName ? html`<${Img} src=${moduleTypeIconUrl(data.get('local'), mod.typeName)} class="scard__modicon" />${mod.typeName}` : null} `; } /** * Item card (the bar's item slot): the same two taps as an operator card. * @param {{ slot:any, idx:number, frozen?:boolean, reason?:string|null, armed?:boolean, onTap?:(idx:number)=>void, * onBuy:Function, onDetail:Function }} props */ export function ItemCard({ slot, idx, frozen = false, reason = null, free = false, armed = false, onTap = null, onBuy, onDetail }) { const it = data.lookup('items', slot.id); const m = data.get('assets'); const disabled = !!reason; const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'item'); }; const card = html``; return reason && reason !== t('已售出') && !armed ? html`<${Tooltip} text=${reason} block=${true} class="scard-wrap">${card}` : card; } function SoldCard({ item = false }) { return html`
<${MicroLabel}>SOLD OUT${item ? t('已购买') : t('已招募')}
`; } /** * A slot with no card in it: the one a 调度中心 upgrade has just opened (a `null` slot — server/match/player/economy.js * _openLevelSlots; GitHub #332 / PR #333: the official shop shows the new slot, empty, until the next refresh or round * start fills it). Never 已招募 / SOLD OUT: nothing was bought there. */ function EmptyCard({ item = false }) { // `scard--sold` too: the same inert frame (no hover lift, not a buyable card for every `:not(.scard--sold)` rule); `scard--empty` only restyles it return html``; } function LevelCard({ shop, reason, armed = false, onTap }) { const lv = shop?.level ?? 1; const max = lv >= (shop?.maxLevel ?? 6); const price = shop?.upgradePrice ?? 0; return html``; } /** Key of an armed shop card: kind + slot index + the id it showed (a reroll / sale changes it ⇒ disarm). */ export const armKey = (kind, idx, slot) => `${kind}:${idx}:${slot?.id ?? ''}`; /** * The slot an armed key names (`c` / `i` shop slots, `r` reward slots), or null (a level card, nothing armed, a stale key). * @param {string|null} key * @param {any[]} slots the shop's slots * @param {any[]|null} rewardSlots the shown reward offer's slots */ export function armedSlotOf(key, slots, rewardSlots = null) { const m = typeof key === 'string' ? /^([cir]):(\d+):(.*)$/.exec(key) : null; if (!m) return null; const list = m[1] === 'r' ? rewardSlots : slots; const s = Array.isArray(list) ? list[Number(m[2])] : null; return s && !s.sold && s.id === m[3] ? s : null; } /** * Two-tap state of the bar (first tap arms, second confirms). Disarms on a tap outside the shop / detail panel, on * Escape, when the armed card changes or disappears, and when the bar stops being editable. * @param {{ editable: boolean, keys: Set }} o the keys that are currently valid */ export function useTwoTap({ editable, keys }) { const [armed, setArmed] = useState(null); const valid = armed == null || (editable && keys.has(armed)); useEffect(() => { if (!valid) setArmed(null); }, [valid]); useEffect(() => { if (armed == null) return undefined; const onDown = (e) => { const t = e.target; if (t && t.closest && t.closest('.scard, .lvcard, .dpanel, .modal')) return; setArmed(null); }; const onKey = (e) => { if (e.key === 'Escape') setArmed(null); }; window.addEventListener('pointerdown', onDown, true); window.addEventListener('keydown', onKey); return () => { window.removeEventListener('pointerdown', onDown, true); window.removeEventListener('keydown', onKey); }; }, [armed]); return [valid ? armed : null, setArmed]; } /** * A free pick-one offer in place of the bar's operator cards (pick 1 — g.reward idx — or put it off: 稍后选择 → the * normal shop + a reminder pill): the promotion reward (research 00 §3: 3 free operators of tier min(level+1, 6)) and * the special refreshes of strategies, items and 特质 — e.g. 凯瑟琳 【定向投放】 "在调度中心刷新随机3件装备,可以选择并获得 * 其中1件". Each slot is drawn by its kind: an item slot is an item card (icon, name, tier, description, FREE) — player * report #6 after 0.1.0, the items used to be drawn as nameless operator cards — and the header names the offer * (gameLogic.offerHeader: 晋升奖励 / the strategy's effect name …; "之后还有 N 项" while more offers wait behind it). */ export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, armed, onTap, offBonds = null }) { const head = offerHeader(offer); return html`
<${Icon} name=${head.icon} class="rwtag__icon" /> ${head.title} ${head.micro} ${head.sub} ${head.more ? html`${head.more}` : null}
${offer.slots.map((s, i) => { if (!s || s.sold) return html`
${t('已选择')}
`; const kind = s.kind === 'item' ? 'item' : 'chess'; const reason = shopBlockReason('reward', { priv, editable, slot: s, ...LOOKUPS }); const props = { slot: { ...s, price: 0 }, idx: i, free: true, reason, armed: armed === armKey('r', i, s), onBuy: onPick, onDetail, onTap: (idx) => onTap('r', idx, s, kind, reason, onPick) }; return kind === 'item' ? html`<${ItemCard} key=${`rw${i}:${s.id}`} ...${props} />` : html`<${ChessCard} key=${`rw${i}:${s.id}`} priv=${priv} offBonds=${offBonds} ...${props} />`; })}
`; } /** * The bar. * @param {{ priv:any, editable:boolean, collapsed:boolean, onCollapse:(c:boolean)=>void, * onBuy:(i:number)=>void, onLevel:Function, onRefresh:Function, onFreeze:Function, onDetail:(id:string, kind?:string, hint?:string|null)=>void, * onDetailClose?: () => void, onRefuse?: (reason: string) => void, barRef:any, * reward?: any, onReward?: (idx:number)=>void, onRewardLater?: Function, offBonds?: Set|null }} props — offBonds: * the bonds this mode never activates (gameLogic modeOffBonds), struck through on the operator cards */ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef, reward = null, onReward, onRewardLater, onArm = null, offBonds = null }) { const shop = priv?.shop || {}; const slots = Array.isArray(shop.slots) ? shop.slots : []; const chessSlots = slots.map((s, i) => ({ s, i })).filter(({ s }) => !s || s.kind !== 'item'); const itemSlots = slots.map((s, i) => ({ s, i })).filter(({ s }) => s && s.kind === 'item'); const frozen = !!shop.frozen; const funds = Number(priv?.funds) || 0; const remaining = Math.max(0, (priv?.deployCap ?? 8) - (priv?.deployCount ?? 0)); const lvReason = shopBlockReason('levelUp', { priv, editable }); const refReason = shopBlockReason('refresh', { priv, editable }); const frzReason = editable ? null : shopBlockReason('freeze', { priv, editable }); const free = Number(shop.freeRefreshes) || 0; const showReward = !!(reward && Array.isArray(reward.slots) && reward.slots.length); // A completed upgrade changes the level, so its confirmation cannot carry into the next upgrade. const levelKey = `lv:${shop.level ?? 1}`; // two-tap: the keys that may stay armed right now const keys = new Set(); if (!collapsed) { if (!lvReason) keys.add(levelKey); slots.forEach((s, i) => { if (s && !s.sold) keys.add(armKey(s.kind === 'item' ? 'i' : 'c', i, s)); }); if (showReward) reward.slots.forEach((s, i) => { if (s && !s.sold) keys.add(armKey('r', i, s)); }); } const [armed, setArmed] = useTwoTap({ editable, keys }); // the armed card's slot (game.js lights the tile a merge's elite will take — gameLogic.mergeTarget); kept before the // collapsed tab's early return (hook order) const armedSlot = armedSlotOf(armed, slots, showReward ? reward.slots : null); const armedSig = armedSlot ? `${armedSlot.kind}:${armedSlot.id}` : ''; useEffect(() => { onArm?.(armedSlot ? { kind: armedSlot.kind === 'item' ? 'item' : 'chess', id: armedSlot.id } : null); }, [armedSig]); useEffect(() => () => onArm?.(null), []); /** First tap arms + opens the detail; the second buys (or says why it can't). */ const tapCard = (kind, idx, slot, detailKind, reason, buy) => { const key = armKey(kind, idx, slot); if (armed !== key) { setArmed(key); onDetail(slot.id, detailKind, detailKind === 'chess' ? mergeHint(priv, slot.id) : null); return; } if (reason) { onRefuse?.(reason); return; } setArmed(null); onDetailClose?.(); buy(idx); }; const tapLevel = () => { if (armed !== levelKey) { setArmed(levelKey); return; } setArmed(null); onLevel(); }; if (collapsed) { return html`
<${CoinGlyph} />${funds}
`; } const hk = { refresh: hotkeyLabelOf('refresh'), freeze: hotkeyLabelOf('freeze') }; // the player's keys (设置 → 快捷键) return html`
${t('剩余可放置角色:')}${remaining}
<${LevelCard} shop=${shop} reason=${lvReason} armed=${armed === levelKey} onTap=${tapLevel} /> ${showReward ? html`<${RewardCards} offer=${reward} priv=${priv} editable=${editable} onPick=${onReward} onDetail=${onDetail} onLater=${onRewardLater} armed=${armed} onTap=${tapCard} offBonds=${offBonds} />` : html`
${chessSlots.map(({ s, i }) => { if (!s) return html`<${EmptyCard} key=${`e${i}`} />`; if (s.sold) return html`<${SoldCard} key=${`s${i}`} />`; const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS }); return html`<${ChessCard} key=${`c${i}:${s.id}`} slot=${s} idx=${i} priv=${priv} frozen=${slotFrozen(s, frozen)} onBuy=${onBuy} onDetail=${onDetail} offBonds=${offBonds} reason=${reason} armed=${armed === armKey('c', i, s)} onTap=${editable ? (idx) => tapCard('c', idx, s, 'chess', reason, onBuy) : null} />`; })}
`}
${itemSlots.length ? itemSlots.map(({ s, i }) => { if (s.sold) return html`<${SoldCard} key=${`is${i}`} item=${true} />`; const reason = shopBlockReason('buy', { priv, editable, slot: s, ...LOOKUPS }); return html`<${ItemCard} key=${`i${i}:${s.id}`} slot=${s} idx=${i} frozen=${slotFrozen(s, frozen)} onBuy=${onBuy} onDetail=${onDetail} reason=${reason} armed=${armed === armKey('i', i, s)} onTap=${editable ? (idx) => tapCard('i', idx, s, 'item', reason, onBuy) : null} />`; }) : html`<${SoldCard} item=${true} />`}
<${CoinGlyph} class="funds__coin" /> ${funds}
${t('目前资金')}
`; }