Online co-op browser remake of Arknights' Stronghold Protocol: Alliance (client-simulated combat, official waves/bonds/items/strategies, 3D board, facing wheel, operator loadout, official emotes, deployment scripts).
305 lines
19 KiB
JavaScript
305 lines
19 KiB
JavaScript
// 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. 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, 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`<span class="scard__free">FREE</span>`;
|
||
// 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) };
|
||
|
||
/** The armed (first-tapped) card's confirm strip: 确认购买 / 确认选择, or 无法购买 + why. */
|
||
function ArmedTag({ reason, free }) {
|
||
if (reason) return html`<span class="scard__confirm is-no" role="status"><b>无法购买</b><small>${reason}</small></span>`;
|
||
return html`<span class="scard__confirm" role="status"><b>${free ? '确认选择' : '确认购买'}</b><small>再次点击</small></span>`;
|
||
}
|
||
|
||
/**
|
||
* 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 willMerge = prog.copies + 1 >= prog.need && prog.copies > 0;
|
||
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); };
|
||
const card = html`<button type="button" class=${cx('scard', `scard--t${tier}`, frozen && 'is-frozen', disabled && 'is-disabled', willMerge && 'is-merge', armed && 'is-armed')}
|
||
onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id); }}
|
||
aria-label=${`${c?.name || '干员'},价格 ${slot.price}${armed ? (disabled ? ',无法购买' : ',再次点击确认') : ''}`} aria-disabled=${disabled && !onTap ? 'true' : 'false'}
|
||
aria-pressed=${onTap ? String(!!armed) : undefined}>
|
||
<span class="scard__bg" aria-hidden="true"></span>
|
||
<span class="scard__water" aria-hidden="true">${bonds[0] ? html`<${BondGlyph} bondId=${bonds[0]} />` : null}</span>
|
||
<${Img} src=${chessPortraitUrl(m, c)} class="scard__art" />
|
||
<span class="scard__top">
|
||
<${TierChip} tier=${tier} size="md" />
|
||
<${PriceHex} slot=${slot} free=${free} poor=${reason === '资金不足'} />
|
||
${prog.copies > 0 ? html`<span class="scard__pips" title=${`已拥有 ${prog.copies}/${prog.need}`}>
|
||
${Array.from({ length: prog.need }, (_, i) => html`<i key=${i} class=${i < prog.copies ? 'on' : ''}></i>`)}
|
||
</span>` : null}
|
||
</span>
|
||
<span class="scard__body">
|
||
${lo?.skill ? html`<${SkillBadge} chess=${c} lo=${lo} />` : null}
|
||
<span class="scard__name">${c?.name || slot.id}</span>
|
||
<span class="scard__bonds">
|
||
${bonds.slice(0, 3).map((b) => html`<span key=${b} class="scard__bond"><${BondGlyph} bondId=${b} /><span>${data.lookup('bonds', b)?.name || b}</span></span>`)}
|
||
</span>
|
||
<span class="scard__class">
|
||
<${Img} src=${profIconUrl(m, c?.profession)} class="scard__prof" />
|
||
<span>${c?.subProfessionName || ''}</span>
|
||
</span>
|
||
</span>
|
||
${willMerge ? html`<span class="scard__mergetag">可晋升</span>` : null}
|
||
${frozen ? html`<span class="scard__ice" aria-hidden="true"><${Icon} name="snow" /></span>` : null}
|
||
${armed ? html`<${ArmedTag} reason=${reason} free=${free} />` : null}
|
||
<span class="scard__detail" role="button" tabindex="-1" aria-label="详情" title="查看详情"
|
||
onClick=${(e) => { e.stopPropagation(); onDetail(slot.id); }}><${Icon} name="info" /></span>
|
||
</button>`;
|
||
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`<span class=${cx('scard__skill', custom && 'is-custom')} title=${tip} aria-label=${tip} data-skill=${lo.skill.skillId || ''}>
|
||
<${Img} src=${src} fallback=${slot ? html`<span class="scard__sglyph num">${slot}</span>` : html`<${GIcon} name="bolt" />`} />
|
||
${mod && mod.typeName ? html`<span class="scard__mod" data-type=${mod.typeName}><${Img} src=${moduleTypeIconUrl(data.get('local'), mod.typeName)} class="scard__modicon" />${mod.typeName}</span>` : null}
|
||
</span>`;
|
||
}
|
||
|
||
function ItemCard({ slot, idx, frozen, reason, 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); };
|
||
const card = html`<button type="button" class=${cx('scard', 'scard--item', frozen && 'is-frozen', disabled && 'is-disabled', armed && 'is-armed')}
|
||
onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id, 'item'); }}
|
||
aria-label=${`${it?.name || '装备'},价格 ${slot.price}${armed ? (disabled ? ',无法购买' : ',再次点击确认') : ''}`}
|
||
aria-pressed=${onTap ? String(!!armed) : undefined}>
|
||
<span class="scard__bg" aria-hidden="true"></span>
|
||
<span class="scard__top">
|
||
<${TierChip} tier=${it?.tier ?? 1} size="md" />
|
||
<${PriceHex} slot=${slot} poor=${reason === '资金不足'} />
|
||
</span>
|
||
<span class="scard__itemart"><${Img} src=${itemIconUrl(m, it)} fallback=${html`<${GIcon} name="bolt" />`} /></span>
|
||
<span class="scard__body">
|
||
<span class="scard__name">${it?.name || slot.id}</span>
|
||
<span class="scard__idesc"><${RichText} text=${it?.descRaw || it?.desc || ''} /></span>
|
||
</span>
|
||
${frozen ? html`<span class="scard__ice" aria-hidden="true"><${Icon} name="snow" /></span>` : null}
|
||
${armed ? html`<${ArmedTag} reason=${reason} />` : null}
|
||
<span class="scard__detail" role="button" tabindex="-1" aria-label="详情" onClick=${(e) => { e.stopPropagation(); onDetail(slot.id, 'item'); }}><${Icon} name="info" /></span>
|
||
</button>`;
|
||
return reason && reason !== '已售出' && !armed ? html`<${Tooltip} text=${reason} block=${true} class="scard-wrap">${card}<//>` : card;
|
||
}
|
||
|
||
function SoldCard({ item = false }) {
|
||
return html`<div class=${cx('scard', 'scard--sold', item && 'scard--item')} aria-label="已售出">
|
||
<span class="scard__soldtxt"><${MicroLabel}>SOLD OUT</${MicroLabel}><span>${item ? '已购买' : '已招募'}</span></span>
|
||
</div>`;
|
||
}
|
||
|
||
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`<button type="button" class=${cx('lvcard', max && 'is-max', reason && 'is-disabled', armed && 'is-armed')} onClick=${() => !reason && onTap()}
|
||
title=${reason || (armed ? `再次点击确认升级(${price} 资金)` : `升级调度中心(${price} 资金) · D`)} aria-disabled=${reason ? 'true' : 'false'}
|
||
aria-pressed=${String(!!armed)}>
|
||
${!max ? html`<${HexBadge} value=${price} tone=${reason && reason !== '调度中心已达最高等级' ? 'dark' : 'gold'} size="md" class="lvcard__price" />` : null}
|
||
<span class="lvcard__frame">
|
||
<span class="lvcard__micro">LEVEL</span>
|
||
<b class="lvcard__num num">${lv}</b>
|
||
</span>
|
||
<span class="lvcard__label">${max ? '已满级' : armed ? '确认升级' : '升级'}</span>
|
||
<kbd class="lvcard__key">D</kbd>
|
||
</button>`;
|
||
}
|
||
|
||
/** 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 ?? ''}`;
|
||
|
||
/**
|
||
* 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<string> }} 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`<div class="shopbar__reward" role="group" aria-label="晋升奖励">
|
||
<div class="rwtag">
|
||
<${Icon} name="crown" class="rwtag__icon" />
|
||
<b class="rwtag__title">晋升奖励</b>
|
||
<span class="rwtag__micro">PROMOTION</span>
|
||
<span class="rwtag__sub">免费选择 1 名</span>
|
||
<button type="button" class="rwtag__later" onClick=${onLater} title="稍后选择(回合结束后消失)"><${Icon} name="minus" />稍后</button>
|
||
</div>
|
||
<div class="shopbar__rwcards">
|
||
${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`<div key=${`rw${i}`} class="scard scard--sold"><span class="scard__soldtxt"><span>已选择</span></span></div>`))}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
/**
|
||
* 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)=>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 }) {
|
||
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 });
|
||
/** 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); 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`<div class="shopbar-tab" ref=${barRef}>
|
||
<div class="shopbar-tab__funds"><${CoinGlyph} /><b class="num">${funds}</b></div>
|
||
<button type="button" class="shopbar-tab__btn" onClick=${() => onCollapse(false)}><${Icon} name="chevronLeft" />展开商店</button>
|
||
</div>`;
|
||
}
|
||
|
||
return html`<section class=${cx('shopbar', frozen && 'is-frozen', !editable && 'is-locked', showReward && 'has-reward', armed && 'has-armed')} ref=${barRef} aria-label="调度中心">
|
||
<div class="shopbar__tools">
|
||
<span class="shopbar__remain">剩余可放置角色:<b class=${cx('num', remaining === 0 && 't-orange')}>${remaining}</b></span>
|
||
<button type="button" class=${cx('toolbtn', 'toolbtn--ice', frozen && 'is-on')} disabled=${!!frzReason} onClick=${onFreeze}
|
||
title=${frzReason || (frozen ? '解冻商店 · F' : '冻结商店(下回合保留) · F')}>
|
||
<${Img} src=${uiUrl(data.get('assets'), frozen ? 'shopPanel/frozen_icon2' : 'shopPanel/frozen_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="snow" />`} />
|
||
<span>${frozen ? '解冻' : '冻结'}</span><kbd>F</kbd>
|
||
</button>
|
||
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason} onClick=${onRefresh} title=${refReason || '刷新商店 · R'}>
|
||
<${Img} src=${uiUrl(data.get('assets'), 'shopPanel/refresh_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="refresh" />`} />
|
||
<span>刷新</span>
|
||
${free > 0 ? html`<span class="toolbtn__free">免费 ×${free}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason ? 'dark' : 'gold'} size="sm" />`}
|
||
<kbd>R</kbd>
|
||
</button>
|
||
</div>
|
||
<div class="shopbar__row">
|
||
<${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`<div class="shopbar__cards">
|
||
${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} />`;
|
||
})}
|
||
</div>`}
|
||
<div class="shopbar__item">
|
||
${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} />`}
|
||
</div>
|
||
<div class="funds">
|
||
<div class="funds__hex">
|
||
<${CoinGlyph} class="funds__coin" />
|
||
<b class="funds__num num">${funds}</b>
|
||
</div>
|
||
<span class="funds__label">目前资金</span>
|
||
<button type="button" class="funds__collapse" onClick=${() => onCollapse(true)}><${Icon} name="close" />收起</button>
|
||
</div>
|
||
</div>
|
||
</section>`;
|
||
}
|