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)
This commit is contained in:
sganggs committed 2026-09-30 05:35:45 +08:00
1 parent 0aba602ecf
commit d4f868cd19
107 files changed
+4510 -1653

No files matched your search

+82 -24
View File
@@ -3,15 +3,19 @@
// 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 确认选择.
// A timeout assigns 「华法琳」, or — when a teammate already holds it — the first free strategy (timeoutBand; the tip
// under the order list names the one I would get). Solo: free pick, no timer.
// 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, useState } from '../../vendor/hooks.module.js';
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 } from '../ui/gameActions.js';
import { normalizeDraft, sortedPlayers, phaseTotalSeconds } from '../ui/gameLogic.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';
@@ -64,19 +68,62 @@ export function teammateBands(picks, myId) {
}
/**
* The band the detail pane shows: the current one, else my pick, else the first band no teammate took. When it is my
* turn and the shown band was taken meanwhile (队友已选), the first free one instead (confirm would be disabled).
* 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 }} o
* @param {{ bands: any[], taken: Map<string, any>, myPick: string|null, myTurn: boolean, defaultId?: string }} o
*/
export function draftSelection(sel, { bands, taken, myPick, myTurn }) {
export function draftSelection(sel, { bands, taken, myPick, myTurn, defaultId = DEFAULT_TIMEOUT_BAND }) {
if (!Array.isArray(bands) || !bands.length) return sel;
const free = (bands.find((b) => !taken.has(b.bandId)) || bands[0]).bandId;
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);
@@ -106,21 +153,31 @@ export function BandDraftScreen() {
pickers.get(bid).push(p || { playerId: pid, name: '?' });
}
// default selection: my pick, else the first band nobody else took; when my turn comes while the selected band has
// been taken by a teammate meanwhile (队友已选), move the selection to the first free one
// 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 });
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]);
// what a timeout gives me (the server never assigns a strategy a teammate holds — Match.js defaultBand)
const defaultId = gd.config?.bandDraft?.timeoutBandId || DEFAULT_TIMEOUT_BAND;
const autoId = myPick ? null : timeoutBand(bands, taken, defaultId);
const defaultName = gd.band(defaultId)?.name || '华法琳';
const autoName = (autoId && gd.band(autoId)?.name) || defaultName;
// 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);
@@ -137,14 +194,15 @@ export function BandDraftScreen() {
setBusy(null);
};
const turnName = players.find((p) => p.playerId === draft.turnPid)?.name;
const turnDeadline = Number(pub?.draft?.turnDeadline) || 0;
useTicker(!solo && turnDeadline > 0 ? 250 : 0);
const turnSecs = !solo && turnDeadline > 0 ? secondsLeft(turnDeadline) : null;
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=${pub}
total=${phaseTotalSeconds(pub, gd.config)} onExit=${() => setExit(true)} />
<${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>
@@ -168,7 +226,7 @@ export function BandDraftScreen() {
</span>
</div>`;
})}
${!solo ? html`<p class="draft-order__tip">联合模拟在选择策略时可以进行一次跳过;超时将自动选择「${autoName}」${autoId && autoId !== defaultId ? `(「${defaultName}」已被队友选择)` : ''}</p>` : null}
${!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="策略">