// 机变 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 in the official layout (the official 悬赏 / 机密商店 / 战术 screenshots): a // framed icon at the top-left with the bold name beside it (tags under the name), the effect text across the card under // both, left-aligned; the tier chip on the icon's corner and the taker's round avatar alone at the top-right (their // name is its tooltip and part of the card's accessible name; a taken card's name wraps before it). The effect text // is the official rich text of the item / effect (items.json / effects.json `descRaw` — the detail card's text — while it // says what the server card's plain `desc` says: cardText), clamped by lines, and it fits the card on phones too (user // playtest #6 item 6: at 756×366 it used to sit below a large centred icon, clipped away). 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 { 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'; import { sortedPlayers } from './gameLogic.js'; import { data } from '../data.js'; import { t, tName } from '../../../shared/i18n.js'; import { sentText } from './lang.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); const bare = (t) => String(t || '').replace(/\s+/g, ''); /** * A card's effect text: the card's own rich text, else the data's rich text (highlighted numbers, 下场作战, set bonuses, * line breaks — the detail card's text) while it says what the server's plain `desc` says, else that plain text (a * server-side wording the data does not have wins). The server's texts are Chinese: `rawRich` is the record's Chinese * rich text they are compared with, `rich` the same record in the current language (the two are one text in Chinese). * @param {{ descRaw?: string, desc?: string }} card @param {string} rich items.json / effects.json descRaw * @param {string} [rawRich] its Chinese text (data.lookupRaw) */ export function cardText(card, rich, rawRich = rich) { if (card.descRaw) return rich && card.descRaw === rawRich ? rich : card.descRaw; if (rich && (!card.desc || bare(richTextPlain(rawRich || rich)) === bare(card.desc))) return rich; return card.desc || rich || ''; } /** * Resolve an sp card to display fields. * @param {any} card normalised card ({ idx, takenBy, effectId?, itemId?, id?, name?, desc?, descRaw?, tier?, team?, enemyKey? }) * @param {string|null} family 'bounty'|'supply'|'shop'|'tactic' */ export function resolveSpCard(card, family) { const choices = data.get('choices'); const byEffect = (list, id) => (Array.isArray(list) && id ? list.find((x) => x && x.effectId === id) : null); const effectId = card.effectId || (!card.itemId && typeof card.id === 'string' && data.lookup('effects', card.id) ? card.id : null); const eff = effectId ? data.lookup('effects', effectId) : null; const itemId = card.itemId || (!effectId && typeof card.id === 'string' && data.lookup('items', card.id) ? card.id : null); const item = itemId ? data.lookup('items', itemId) : null; const rawRich = (itemId && data.lookupRaw('items', itemId)?.descRaw) || (effectId && data.lookupRaw('effects', effectId)?.descRaw) || ''; const bounty = byEffect(choices?.cards?.bounty, effectId); const tactic = byEffect(choices?.cards?.tactic, effectId); const m = data.get('assets'); const kind = card.kind === 'item' || item ? 'item' : card.kind === 'bounty' || bounty || family === 'bounty' ? 'bounty' : 'tactic'; const enemyKey = card.enemyKey || bounty?.enemyKey || eff?.params?.enemy_id || null; const team = card.team ?? tactic?.team ?? (eff?.decoIconId === 'icon_team_buff'); let icon = null; if (item) icon = itemIconUrl(m, item); else if (kind === 'bounty' && enemyKey) icon = enemyIconUrl(m, enemyKey); else if ((card.tacticKind || tactic?.kind) === 'terrain') icon = uiUrl(m, 'buffIcon/icon_stage_buff'); else if ((card.tacticKind || tactic?.kind) === 'enemyDebuff') icon = uiUrl(m, 'buffIcon/icon_enemy_debuff'); else icon = uiUrl(m, `buffIcon/${team ? 'icon_team_buff' : 'icon_player_buff'}`); return { kind, name: tName(card.name) || item?.name || eff?.name || bounty?.name || tactic?.name || t('机变'), desc: cardText(card, item?.descRaw || eff?.descRaw || '', rawRich) || item?.desc || eff?.desc || bounty?.desc || tactic?.desc || '', tier: Number.isFinite(card.tier) ? card.tier : item?.tier ?? bounty?.tier ?? null, icon, team: !!team, coin: card.coin ?? bounty?.coin ?? eff?.enemyPrice ?? null, }; } /** * Whether a card shows the in-flight pick (g.choice sent, no answer yet): only until the pick lands in m.public — the * player's pick is known or the card is taken — so it can never outlive the request's effect. * @param {number|null} busyIdx @param {{ idx: number, takenBy?: string|null }} card @param {number|null|undefined} mine */ export function pickBusy(busyIdx, card, mine) { return busyIdx != null && !!card && busyIdx === card.idx && mine == null && !card.takenBy; } /** * 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(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 rawFam = data.getRaw('choices')?.families?.[sp.family] || null; const players = new Map(sortedPlayers(pub).map((p) => [p.playerId, p])); const myTurn = solo || sp.turnPid === myId; const mine = sp.pickOf.get(myId); const turnName = players.get(sp.turnPid)?.name || t('队友'); const special = /_s$/.test(String(sp.family || '')); const order = solo ? [] : sp.order; 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``; }