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:
1 parent
0aba602ecf
commit
d4f868cd19
107 files changed
+4510
-1653
No files matched your search
@@ -1,11 +1,17 @@
|
||||
// 机变 draft overlay (research 06 §4.4 / §11.5): family title | description, "倒计时结束后仍未选定将自动分配",
|
||||
// whose turn ("当前轮到你决策" / "{name} 正在决策…") with countdown, pick order with ✓ / ⌛ / … / door,
|
||||
// and a 3×2 grid (solo: 3 cards) of cards with icon, title, rich description, tier chip and the taker's
|
||||
// avatar badge. Clicking an available card on your turn sends g.choice; while it is in flight the card shows a
|
||||
// "选择中" strip with a sweeping bar (never a spinner over its text — user playtest #3 item 9), dropped as soon as the
|
||||
// pick shows in m.public (spBusy itself resets when the request settles, ≤ 8 s, or the phase moves on).
|
||||
// avatar badge. Picking takes two taps, like buying in the shop (user playtest #4 item 2 — extra enemies, items and
|
||||
// tactics were picked by a slip of the finger; research 09 §5 EventOnFirstClick → EventOnConfirm): the first tap on an
|
||||
// available card selects it (it lifts with a gold frame and a 确认选择 · 再次点击 strip; the header shows 确认选择), a
|
||||
// second tap on the same card — or 确认选择 — sends g.choice; a tap on another card moves the selection, a tap
|
||||
// elsewhere or Esc drops it. Cards are buttons (Tab / Enter work the same way). While the pick is in flight the card
|
||||
// shows a "选择中" strip with a sweeping bar (never a spinner over its text — user playtest #3 item 9), dropped as soon
|
||||
// as the pick shows in m.public (spBusy itself resets when the request settles, ≤ 8 s, or the phase moves on).
|
||||
// Untimed drafts (solo, a single-human match: sp.untimed) show no countdown and say so.
|
||||
|
||||
import { html, Icon, TierChip, Countdown, MicroLabel } from './components.js';
|
||||
import { useEffect, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Icon, TierChip, Countdown, MicroLabel, Button } from './components.js';
|
||||
import { Img, RichText, PlayerAvatar, GIcon } from './gameComponents.js';
|
||||
import { itemIconUrl, enemyIconUrl, uiUrl } from './assetUrls.js';
|
||||
import { richTextPlain } from './richText.js';
|
||||
@@ -59,9 +65,74 @@ export function pickBusy(busyIdx, card, mine) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether I may pick a card right now: my turn (solo: always), no pick of mine yet, the card free and no pick in flight.
|
||||
* @param {any} sp normalizeSp(...) @param {{ idx: number, takenBy?: string|null } | null | undefined} card
|
||||
* @param {{ myId: string, solo: boolean, busyIdx?: number|null }} o
|
||||
*/
|
||||
export function cardPickable(sp, card, { myId, solo, busyIdx = null }) {
|
||||
if (!sp || !card) return false;
|
||||
const myTurn = solo || sp.turnPid === myId;
|
||||
return myTurn && sp.pickOf.get(myId) == null && !card.takenBy && busyIdx == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-tap step of a card tap (like the shop's first tap → confirm): tapping an available card selects it, tapping the
|
||||
* selected card again confirms it. A card that cannot be picked changes nothing.
|
||||
* @param {number|null} armed the selected card's idx
|
||||
* @param {number} idx the tapped card
|
||||
* @param {boolean} pickable cardPickable(...) of the tapped card
|
||||
* @returns {{ armed: number|null, pick: number|null }} the new selection and the card to send (g.choice) or null
|
||||
*/
|
||||
export function spTap(armed, idx, pickable) {
|
||||
if (!pickable || !Number.isInteger(idx)) return { armed, pick: null };
|
||||
if (armed === idx) return { armed: null, pick: idx };
|
||||
return { armed: idx, pick: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected card while it can still be picked (my turn, not taken, nothing in flight), else null — a selection
|
||||
* never outlives the state that allowed it.
|
||||
* @param {number|null} armed @param {any} sp @param {{ myId: string, solo: boolean, busyIdx?: number|null }} o
|
||||
*/
|
||||
export function armedCard(armed, sp, o) {
|
||||
if (armed == null || !sp) return null;
|
||||
const card = sp.cards.find((c) => c && c.idx === armed);
|
||||
return cardPickable(sp, card, o) ? armed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The overlay: keeps the two-tap selection and renders ChoiceView. `onPick(idx)` sends the confirmed card (g.choice).
|
||||
* @param {{ pub:any, sp:any, myId:string, solo:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props
|
||||
*/
|
||||
export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, total = null }) {
|
||||
export function ChoiceOverlay(props) {
|
||||
const { sp, myId, solo, busyIdx = null, onPick } = props;
|
||||
const [sel, setSel] = useState(null);
|
||||
const armed = armedCard(sel, sp, { myId, solo, busyIdx });
|
||||
// a selection whose card cannot be picked any more (taken, the turn moved on, a pick in flight) is dropped
|
||||
useEffect(() => { if (sel != null && armed == null) setSel(null); }, [sel, armed]);
|
||||
useEffect(() => {
|
||||
if (armed == null) return undefined;
|
||||
const onKey = (e) => { if (e.key === 'Escape') setSel(null); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [armed]);
|
||||
if (!sp) return null;
|
||||
const tap = (idx) => {
|
||||
const card = sp.cards.find((c) => c && c.idx === idx);
|
||||
const r = spTap(armed, idx, cardPickable(sp, card, { myId, solo, busyIdx }));
|
||||
setSel(r.armed);
|
||||
if (r.pick != null) onPick(r.pick);
|
||||
};
|
||||
return html`<${ChoiceView} ...${props} armed=${armed} onTap=${tap}
|
||||
onConfirm=${() => { if (armed != null) tap(armed); }} onDisarm=${() => setSel(null)} />`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The overlay's view (pure: no hooks — test/ui renders it as a function).
|
||||
* @param {{ pub:any, sp:any, myId:string, solo:boolean, busyIdx?:number|null, total?:number|null, armed?:number|null,
|
||||
* onTap?:(idx:number)=>void, onConfirm?:()=>void, onDisarm?:()=>void }} props
|
||||
*/
|
||||
export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null, armed = null, onTap = () => {}, onConfirm = () => {}, onDisarm = () => {} }) {
|
||||
if (!sp) return null;
|
||||
const fam = data.get('choices')?.families?.[sp.family] || null;
|
||||
const players = new Map(sortedPlayers(pub).map((p) => [p.playerId, p]));
|
||||
@@ -70,20 +141,32 @@ export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, tot
|
||||
const turnName = players.get(sp.turnPid)?.name || '队友';
|
||||
const special = /_s$/.test(String(sp.family || ''));
|
||||
const order = solo ? [] : sp.order;
|
||||
return html`<div class="spov" role="dialog" aria-label="机变阶段">
|
||||
const timed = !solo && !sp.untimed;
|
||||
const armedCardRec = armed != null ? sp.cards.find((c) => c && c.idx === armed) : null;
|
||||
const armedName = armedCardRec ? resolveSpCard(armedCardRec, sp.family).name : null;
|
||||
// a press anywhere but a card or the confirm button drops the selection
|
||||
const onDown = (e) => {
|
||||
if (armed == null) return;
|
||||
const t = e.target;
|
||||
if (t && typeof t.closest === 'function' && t.closest('.spcard, .spov__confirm')) return;
|
||||
onDisarm();
|
||||
};
|
||||
return html`<div class=${cx('spov', armed != null && 'has-armed')} role="dialog" aria-label="机变阶段" onPointerDown=${onDown}>
|
||||
<div class="spov__veil" aria-hidden="true"></div>
|
||||
<div class="spov__inner">
|
||||
<header class="spov__head">
|
||||
<div class="spov__titles">
|
||||
<${MicroLabel} tone="mint">CONTINGENCY // 机变阶段</${MicroLabel}>
|
||||
<h2 class=${cx('spov__title', special && 'is-special')}>${sp.name || fam?.name || '机变'}<span class="spov__bar">|</span><span class="spov__desc"><${RichText} text=${sp.desc || fam?.desc || '选择一项'} /></span></h2>
|
||||
${!solo ? html`<p class="spov__sub">倒计时结束后仍未选定将自动分配</p>` : html`<p class="spov__sub">选择一项(无时间限制)</p>`}
|
||||
<p class="spov__sub">${timed ? '倒计时结束后仍未选定将自动分配' : '选择一项(无时间限制)'}${mine == null && myTurn ? ' · 点击卡牌选中,再次点击确认' : ''}</p>
|
||||
</div>
|
||||
<div class="spov__turn">
|
||||
${mine != null ? html`<span class="spov__turntxt is-done"><${Icon} name="check" />已完成选择</span>`
|
||||
: myTurn ? html`<span class="spov__turntxt is-mine">当前轮到你决策</span>`
|
||||
: html`<span class="spov__turntxt">${turnName} 正在决策…<${Icon} name="hourglass" /></span>`}
|
||||
${!solo && !sp.untimed ? html`<${Countdown} deadline=${pub?.deadline} total=${total ?? undefined} size="sm" />` : null}
|
||||
${armed != null ? html`<${Button} variant="primary" size="lg" icon="check" class="spov__confirm" data-testid="sp-confirm"
|
||||
title=${armedName ? `确认选择「${armedName}」(再次点击卡牌亦可)` : '确认选择'} onClick=${onConfirm}>确认选择<//>` : null}
|
||||
${timed ? html`<${Countdown} deadline=${pub?.deadline} total=${total ?? undefined} size="sm" />` : null}
|
||||
</div>
|
||||
</header>
|
||||
${order.length ? html`<div class="spov__order" aria-label="决策顺序">
|
||||
@@ -104,11 +187,12 @@ export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, tot
|
||||
${sp.cards.map((card) => {
|
||||
const r = resolveSpCard(card, sp.family);
|
||||
const taker = card.takenBy ? players.get(card.takenBy) : null;
|
||||
const can = myTurn && mine == null && !card.takenBy && busyIdx == null;
|
||||
const can = cardPickable(sp, card, { myId, solo, busyIdx });
|
||||
const busy = pickBusy(busyIdx, card, mine);
|
||||
return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, card.takenBy && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', busy && 'is-busy')}
|
||||
aria-busy=${busy ? 'true' : undefined}
|
||||
disabled=${!can} onClick=${() => can && onPick(card.idx)} aria-label=${r.name} title=${`${r.name}\n${richTextPlain(r.desc)}`}>
|
||||
const isArmed = can && armed === card.idx;
|
||||
return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, card.takenBy && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', isArmed && 'is-armed', busy && 'is-busy')}
|
||||
aria-busy=${busy ? 'true' : undefined} aria-pressed=${can ? String(isArmed) : undefined}
|
||||
disabled=${!can} onClick=${() => can && onTap(card.idx)} aria-label=${isArmed ? `${r.name},已选中,再次点击确认` : r.name} title=${`${r.name}\n${richTextPlain(r.desc)}`}>
|
||||
<span class="spcard__glow" aria-hidden="true"></span>
|
||||
${r.tier ? html`<${TierChip} tier=${r.tier} size="md" class="spcard__tier" />` : null}
|
||||
<span class="spcard__icon"><${Img} src=${r.icon} fallback=${html`<${GIcon} name=${r.kind === 'bounty' ? 'target' : 'bolt'} />`} /></span>
|
||||
@@ -121,6 +205,7 @@ export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, tot
|
||||
</span>
|
||||
</span>
|
||||
${taker ? html`<span class="spcard__taker" title=${`${taker.name} 已选择`}><${PlayerAvatar} player=${taker} size="sm" /><span>${card.takenBy === myId ? '你' : taker.name}</span></span>` : null}
|
||||
${isArmed ? html`<span class="spcard__confirm" role="status"><b>确认选择</b><small>再次点击</small></span>` : null}
|
||||
${busy ? html`<span class="spcard__busy" role="status">选择中</span>` : null}
|
||||
</button>`;
|
||||
})}
|
||||
|
||||
Reference in new issue
Block a user