// 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, 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). // 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). 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 } from './gameLogic.js'; import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js'; import { data } from '../data.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 ? '资金不足' : tone === 'discount' ? `折扣价(原价 ${slot.basePrice})` : tone === 'premium' ? `加价(原价 ${slot.basePrice})` : '价格'} />`; } /** Data lookups for shopBlockReason (full-hand purchases that complete a merge stay allowed). */ 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) ? '精锐干员将出现在作战区原位置' : '精锐干员将进入整备区'; } /** The armed (first-tapped) card's confirm strip: 确认购买 / 确认选择, or 无法购买 + why. */ function ArmedTag({ reason, free }) { if (reason) return html`无法购买${reason}`; return html`${free ? '确认选择' : '确认购买'}再次点击`; } /** * 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 }) { const c = data.lookup('chess', slot.id); 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 = c ? chessLoadout(c, priv?.loadout, LOOKUPS.getChess) : null; const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'chess', hint); }; const card = html``; return reason && reason !== '已售出' && !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 }) { const m = data.get('assets'); const custom = !lo.defaultSkill; // 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 ? ` ${slot}` : ''}:${lo.skill.name || ''}${custom ? '(已调配)' : ''}${mod ? ` · 模组:${mod.name}` : lo.module?.none ? ' · 未装备模组' : ''}`; 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, 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 !== '已售出' && !armed ? html`<${Tooltip} text=${reason} block=${true} class="scard-wrap">${card}` : card; } function SoldCard({ item = false }) { return html`
<${MicroLabel}>SOLD OUT${item ? '已购买' : '已招募'}
`; } 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 ('lv', 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]; } /** * Promotion reward (research 00 §3): after a merge the operator cards of the bar are replaced by 3 free operators of * tier min(level+1, 6); pick 1 (g.reward idx) or put it off (稍后选择 → the normal shop + a reminder pill). */ function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, armed, onTap }) { return html`
<${Icon} name="crown" class="rwtag__icon" /> 晋升奖励 PROMOTION 免费选择 1 名
${offer.slots.map((s, i) => (s && !s.sold ? html`<${ChessCard} key=${`rw${i}:${s.id}`} slot=${{ ...s, price: 0 }} idx=${i} priv=${priv} free=${true} armed=${armed === armKey('r', i, s)} onTap=${(idx) => onTap('r', idx, s, 'chess', shopBlockReason('reward', { priv, editable, slot: s, ...LOOKUPS }), onPick)} reason=${shopBlockReason('reward', { priv, editable, slot: s, ...LOOKUPS })} onBuy=${onPick} onDetail=${onDetail} />` : html`
已选择
`))}
`; } /** * 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 }} props */ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef, reward = null, onReward, onRewardLater, onArm = 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); // two-tap: the keys that may stay armed right now const keys = new Set(); if (!collapsed) { if (!lvReason) keys.add('lv'); 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 !== 'lv') { setArmed('lv'); return; } setArmed(null); onLevel(); }; if (collapsed) { return html`
<${CoinGlyph} />${funds}
`; } return html`
剩余可放置角色:${remaining}
<${LevelCard} shop=${shop} reason=${lvReason} armed=${armed === 'lv'} onTap=${tapLevel} /> ${showReward ? html`<${RewardCards} offer=${reward} priv=${priv} editable=${editable} onPick=${onReward} onDetail=${onDetail} onLater=${onRewardLater} armed=${armed} onTap=${tapCard} />` : html`
${chessSlots.map(({ s, i }) => { if (!s || 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=${frozen} onBuy=${onBuy} onDetail=${onDetail} 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=${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}
目前资金
`; }