卫戍协议:盟约 web remake — initial import

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).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+304
View File
@@ -0,0 +1,304 @@
// 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>`;
}