Files
ark-stronghold-protocol/public/js/screens/bandDraft.js
T
sganggs d4f868cd19 Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash
- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays
  under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed
- #2 two-tap confirm on 机变 cards (like the shop)
- #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles)
- #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band
- #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from
- #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others)
- #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats)
- #8 official element gauges on units; burst cooldown (阻回) per PRTS
- #9 knocked-down operators stay on their tile with a redeploy countdown ring
- #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼)
- #11 赫默's drone only from her skill; placeable summons limited to talent tokens
- #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel
- #13 the occasional giant bond icon (loading texture scaled ×46) fixed
- docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
2026-09-30 05:35:45 +08:00

275 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Band draft — BAND_DRAFT "2/2 选择策略" (research 06 §4.2, D1): left = draft order (avatar, name, state:
// … waiting / ⌛ 决策中 / chosen band ✓), current picker highlighted; centre = grid of every band allowed
// for the mode type (icon, name, LP); a band a teammate already picked carries the picker's avatar and is marked
// 队友已选 — it cannot be chosen again (research 09 §5, guidebook 策略与轮选; the server refuses it too); right =
// detail pane (icon, 初始生命值, name, effect name + rich description) with 跳过 (co-op, once) and 确认选择.
// One countdown (user playtest #4 item 4): every turn has the same clock (Match BAND_TURN_SECONDS, m.public.draft
// turnSeconds) and the step header counts it down — m.public.deadline IS the turn's end, the same number as the
// current picker's row. The highlighted band (the detail pane's) is what a turn that runs out takes: every change of it
// is reported (g.bandFocus) and the server assigns it while it is free, else 「华法琳」, else the first free strategy
// (timeoutBand). It starts on that default, so the tip under the order list always names what a timeout gives.
// Solo, and a co-op match with a single human (the server's soloUntimed: draft.untimed): no clock at all.
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { html, Button, Icon, MicroLabel, useTicker, secondsLeft } from '../ui/components.js';
import { useGameData, BandIcon, RichText, PlayerAvatar, LpTower, Sprite } from '../ui/gameComponents.js';
import { StepHeader, ExitModal } from '../ui/matchChrome.js';
import { actions, act } from '../ui/gameActions.js';
import { normalizeDraft, sortedPlayers } from '../ui/gameLogic.js';
import { useStore } from '../store.js';
import { audio } from '../audio.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/**
* Bands selectable in a mode (modeTypeList contains the mode's type), sorted by sortId.
* @param {any[]} bands
* @param {string|null} modeType 'SINGLE'|'MULTI'
*/
export function allowedBands(bands, modeType) {
const sid = (b) => (Number.isFinite(b.sortId) ? b.sortId : 99);
return (Array.isArray(bands) ? bands : [])
.filter((b) => b && (!modeType || !Array.isArray(b.modeTypeList) || b.modeTypeList.includes(modeType)))
.sort((a, b) => sid(a) - sid(b) || (a.bandId < b.bandId ? -1 : a.bandId > b.bandId ? 1 : 0));
}
/** The official default strategy of an automatic assignment (data/config.json bandDraft.timeoutBandId). */
export const DEFAULT_TIMEOUT_BAND = 'band_bldsk';
/**
* The strategy the server assigns me when my turn times out (server/match/Match.js defaultBand): the official default
* 「华法琳」 while no teammate holds it, else the first free strategy in draft order (sortId) — never one a teammate
* already picked (队友已选).
* @param {any[]} bands allowedBands(...) (sortId order)
* @param {Map<string, any>} taken teammateBands(...)
* @param {string} [defaultId]
* @returns {string|null}
*/
export function timeoutBand(bands, taken, defaultId = DEFAULT_TIMEOUT_BAND) {
const list = Array.isArray(bands) ? bands : [];
const has = (id) => !!(taken && typeof taken.has === 'function' && taken.has(id));
if (defaultId && !has(defaultId) && (!list.length || list.some((b) => b.bandId === defaultId))) return defaultId;
return list.find((b) => !has(b.bandId))?.bandId || defaultId || null;
}
/**
* Bands taken by teammates (队友已选): bandId → the picking players (never the viewer).
* @param {Map<string, string>} picks normalizeDraft(...).picks (playerId → bandId)
* @param {string} myId
*/
export function teammateBands(picks, myId) {
const out = new Map();
for (const [pid, bid] of picks instanceof Map ? picks : []) {
if (pid === myId || typeof bid !== 'string') continue;
if (!out.has(bid)) out.set(bid, []);
out.get(bid).push(pid);
}
return out;
}
/**
* The band the detail pane shows (= the highlighted band a turn that runs out takes): the current one, else my pick,
* else the band a timeout would give me (timeoutBand: 「华法琳」 while free, else the first free one). When it is my
* turn and the shown band was taken meanwhile (队友已选), that default instead (confirm would be disabled).
* @param {string|null} sel
* @param {{ bands: any[], taken: Map<string, any>, myPick: string|null, myTurn: boolean, defaultId?: string }} o
*/
export function draftSelection(sel, { bands, taken, myPick, myTurn, defaultId = DEFAULT_TIMEOUT_BAND }) {
if (!Array.isArray(bands) || !bands.length) return sel;
const free = timeoutBand(bands, taken, defaultId) || bands[0].bandId;
if (!sel) return myPick || free;
if (!myPick && myTurn && taken.has(sel)) return free;
return sel;
}
/**
* The strategy a turn that runs out assigns me (server Match.timeoutBand): the highlighted band while it is one of
* the mode's and no teammate holds it, else timeoutBand. Null after my pick.
* @param {string|null} sel the highlighted band
* @param {{ bands: any[], taken: Map<string, any>, myPick?: string|null, defaultId?: string }} o
*/
export function autoPickBand(sel, { bands, taken, myPick = null, defaultId = DEFAULT_TIMEOUT_BAND }) {
if (myPick) return null;
const list = Array.isArray(bands) ? bands : [];
const has = (id) => !!(taken && typeof taken.has === 'function' && taken.has(id));
if (sel && !has(sel) && list.some((b) => b.bandId === sel)) return sel;
return timeoutBand(list, taken, defaultId);
}
/**
* The tip under the co-op draft order: the one skip, the turn clock and what a turn that runs out assigns me (the
* highlighted band while free — autoPickBand). Untimed drafts (a single human) name no clock.
* @param {{ timed: boolean, turnSeconds?: number|null, autoName?: string|null, selected?: boolean }} o
* selected: the auto pick is the highlighted band (not the default standing in for a band a teammate holds)
*/
export function draftTip({ timed, turnSeconds = null, autoName = null, selected = true }) {
const skip = '联合模拟在选择策略时可以进行一次跳过';
if (!timed) return `${skip};本局不限时`;
const clock = Number(turnSeconds) > 0 ? `每位博士有 ${Math.round(turnSeconds)} 秒` : '每位博士限时决策';
if (!autoName) return `${skip};${clock}`;
return `${skip};${clock},超时将自动选择${selected ? '当前选中的' : ''}「${autoName}」`;
}
/**
* The step header's countdown during the draft: the current turn's (m.public.deadline = draft.turnDeadline) with a
* turn's length as the gauge total — null when the draft is untimed.
* @param {any} pub m.public
* @returns {{ deadline: number, total: number|null } | null}
*/
export function draftClock(pub) {
const d = pub && typeof pub.draft === 'object' ? pub.draft : null;
if (!d || d.untimed) return null;
const deadline = Number(pub.deadline) > 0 ? Number(pub.deadline) : Number(d.turnDeadline) || 0;
if (!(deadline > 0)) return null;
return { deadline, total: Number(d.turnSeconds) > 0 ? Number(d.turnSeconds) : null };
}
/** BAND_DRAFT screen. */
export function BandDraftScreen() {
const pub = useStore((s) => s.match.public);
const priv = useStore((s) => s.match.private);
const myId = useStore((s) => s.me.playerId);
const roomSolo = useStore((s) => s.room?.mode === 'solo');
const gd = useGameData();
const [sel, setSel] = useState(null);
const [busy, setBusy] = useState(null);
const [exit, setExit] = useState(false);
const [skipped, setSkipped] = useState(false);
const mode = gd.config?.modes?.[pub?.modeId];
const solo = roomSolo || mode?.type === 'SINGLE' || String(pub?.modeId || '').includes('single');
const bands = useMemo(() => allowedBands(gd.list('bands'), mode?.type || (solo ? 'SINGLE' : 'MULTI')), [gd.ready, mode?.type, solo]);
const players = sortedPlayers(pub);
const draft = normalizeDraft(pub?.draft, players);
const myPick = draft.picks.get(myId) || priv?.bandId || null;
const myTurn = !myPick && (solo || draft.turnPid === myId);
const skipsLeft = draft.skipsLeft.has(myId) ? draft.skipsLeft.get(myId) : (skipped ? 0 : 1);
const canSkip = !solo && myTurn && skipsLeft > 0 && draft.order.length > 1;
const taken = solo ? new Map() : teammateBands(draft.picks, myId);
const pickers = new Map(); // bandId → players
for (const [pid, bid] of draft.picks) {
const p = players.find((x) => x.playerId === pid);
if (!pickers.has(bid)) pickers.set(bid, []);
pickers.get(bid).push(p || { playerId: pid, name: '?' });
}
// default selection: my pick, else what a timeout gives me (华法琳 while free); when my turn comes while the selected
// band has been taken by a teammate meanwhile (队友已选), move the selection back to that default
const defaultId = gd.config?.bandDraft?.timeoutBandId || DEFAULT_TIMEOUT_BAND;
const takenKey = [...taken.keys()].sort().join(',');
useEffect(() => {
const next = draftSelection(sel, { bands, taken, myPick, myTurn, defaultId });
if (next !== sel) setSel(next);
}, [bands.length, myPick, myTurn, takenKey]);
// "your turn" cue
useEffect(() => { if (myTurn && !solo) audio.sfx('yourTurn'); }, [myTurn]);
// one countdown (user playtest #4 item 4): the current turn's — m.public.deadline, the same clock as the picker's row
const clock = solo ? null : draftClock(pub);
// the highlighted band is what a turn that runs out takes (Match.timeoutBand): report every change before my pick
const timed = !solo && !!pub?.draft && !pub.draft.untimed;
const focusSent = useRef(null);
useEffect(() => {
if (!timed || myPick || !sel || focusSent.current === sel) return;
focusSent.current = sel;
act('g.bandFocus', { bandId: sel }, { sfx: false, quiet: true });
}, [sel, timed, myPick]);
// what a timeout gives me: the highlighted band while free, else the default (never 队友已选 — Match.js timeoutBand)
const autoId = autoPickBand(sel, { bands, taken, myPick, defaultId });
const autoName = (autoId && gd.band(autoId)?.name) || gd.band(defaultId)?.name || '华法琳';
const band = sel ? gd.band(sel) : null;
const selTaken = !!band && taken.has(band.bandId);
const confirm = async () => {
if (!band || busy || !myTurn || selTaken) return;
setBusy('pick');
await actions.band(band.bandId);
setBusy(null);
};
const skip = async () => {
if (busy || !canSkip) return;
setBusy('skip');
if (await actions.bandSkip()) setSkipped(true);
setBusy(null);
};
const turnName = players.find((p) => p.playerId === draft.turnPid)?.name;
useTicker(clock ? 250 : 0);
// the picker's row shows the step header's number (both read the one turn deadline)
const turnSecs = clock ? secondsLeft(clock.deadline) : null;
const turnLen = Number(pub?.draft?.turnSeconds) > 0 ? Math.round(pub.draft.turnSeconds) : null;
return html`<div class="screen draft">
<div class="brief__bg" aria-hidden="true"></div>
<${StepHeader} step=${2} of=${2} title="选择策略" micro="STRATEGY // BAND CHECK" pub=${clock ? { ...pub, deadline: clock.deadline } : { ...pub, deadline: 0 }}
total=${clock ? clock.total : null} onExit=${() => setExit(true)} />
<main class="draft__main">
<aside class="draft-order">
<h3 class="brief-h"><span>${solo ? '独立模拟' : '决策顺序'}</span><${MicroLabel}>${solo ? 'FREE PICK' : 'RANDOM ORDER'}</${MicroLabel}></h3>
${(solo ? players.filter((p) => p.playerId === myId) : draft.order.map((pid) => players.find((p) => p.playerId === pid)).filter(Boolean)).map((p, i) => {
const picked = draft.picks.get(p.playerId) || (p.playerId === myId ? myPick : p.bandId) || null;
const cur = !picked && (solo || draft.turnPid === p.playerId);
const pband = picked ? gd.band(picked) : null;
return html`<div key=${p.playerId} class=${cx('dorder', cur && 'is-cur', picked && 'is-done', p.playerId === myId && 'is-self')}>
${!solo ? html`<span class="dorder__idx num">${i + 1}</span>` : null}
<${PlayerAvatar} player=${p} self=${p.playerId === myId} />
<div class="dorder__text">
<b class="dorder__name">${p.name || '博士'}${p.isBot ? html`<span class="dorder__ai">AI</span>` : null}</b>
<span class="dorder__state">${picked ? html`<span class="t-mint">${pband?.name || '已选择'}</span>`
: cur ? html`<span class="t-gold"><${Icon} name="hourglass" />决策中${turnSecs != null ? html`<b class="num dorder__secs">${turnSecs}s</b>` : null}</span>`
: html`<span class="t-dim"><${Icon} name="dots" />等待中</span>`}</span>
</div>
<span class="dorder__box">
${picked ? html`<${BandIcon} bandId=${picked} size="sm" /><span class="dorder__check"><${Icon} name="check" /></span>`
: cur && p.playerId === myId ? html`<${Sprite} k="bandChoose/youturn_finger" class="dorder__finger" fallback=${html`<${Icon} name="chevronLeft" />`} />`
: null}
</span>
</div>`;
})}
${!solo ? html`<p class="draft-order__tip" data-testid="draft-tip">${draftTip({ timed, turnSeconds: turnLen, autoName: myPick ? null : autoName, selected: autoId === sel })}</p>` : null}
</aside>
<section class="draft-grid" role="listbox" aria-label="策略">
${bands.map((b) => {
const who = pickers.get(b.bandId) || [];
const isTaken = taken.has(b.bandId);
return html`<button key=${b.bandId} type="button" role="option" aria-selected=${sel === b.bandId ? 'true' : 'false'}
aria-disabled=${isTaken ? 'true' : 'false'} title=${isTaken ? '队友已选' : undefined}
class=${cx('dband', sel === b.bandId && 'is-sel', myPick === b.bandId && 'is-mine', isTaken && 'is-taken')} onClick=${() => { setSel(b.bandId); audio.sfx('tab', { volume: 0.5 }); }}>
<${BandIcon} bandId=${b.bandId} size="lg" />
<span class="dband__name">${b.name}</span>
<span class="dband__lp num"><i></i>${b.totalHp}</span>
${who.length ? html`<span class="dband__who">${who.slice(0, 4).map((p) => html`<${PlayerAvatar} key=${p.playerId} player=${p} size="sm" />`)}</span>` : null}
${isTaken ? html`<span class="dband__taken">队友已选</span>` : null}
</button>`;
})}
</section>
<aside class="draft-detail brackets">
${band ? html`
<div class="draft-detail__art">
<${BandIcon} bandId=${band.bandId} size="xl" />
</div>
<div class="draft-detail__hp"><span>初始生命值</span><${LpTower} value=${band.totalHp} size="lg" /></div>
<h2 class="draft-detail__name">${band.name}</h2>
<div class="draft-detail__eff">
<${MicroLabel} tone="mint">EFFECT</${MicroLabel}>
<b>${band.effectName || ''}</b>
<${RichText} as="p" text=${band.descRaw || band.desc} class="draft-detail__desc" />
</div>` : html`<p class="t-dim">选择一个策略查看详情</p>`}
<div class="draft-detail__actions">
${myPick ? html`<p class="draft-detail__status t-mint"><${Icon} name="check" />已选择「${gd.band(myPick)?.name || ''}」${!solo && !draft.done ? ',等待其他博士' : ''}</p>`
: selTaken ? html`<p class="draft-detail__status draft-detail__status--taken"><${Icon} name="close" />队友已选,请选择其他策略</p>`
: !myTurn ? html`<p class="draft-detail__status"><${Icon} name="hourglass" />${turnName ? `${turnName} 正在决策…` : '等待轮到你'}</p>` : null}
<div class="draft-detail__btns">
${!solo ? html`<${Button} variant="secondary" size="lg" icon="chevrons" disabled=${!canSkip} loading=${busy === 'skip'} onClick=${skip}
title=${skipsLeft > 0 ? '跳过本轮,稍后再选' : '跳过次数已用完'}>跳过${skipsLeft > 0 ? '' : '(已用)'}<//>` : null}
<${Button} variant="primary" size="lg" icon="check" disabled=${!myTurn || !band || selTaken} loading=${busy === 'pick'} onClick=${confirm}>${selTaken ? '队友已选' : '确认选择'}<//>
</div>
</div>
</aside>
</main>
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} />
</div>`;
}