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>`;
|
||||
})}
|
||||
|
||||
@@ -342,6 +342,9 @@ export function secondsLeft(deadline) {
|
||||
|
||||
/**
|
||||
* 7-segment countdown with "COUNTDOWN" micro label and a 5-bar gauge; turns orange ≤ warnAt s.
|
||||
* Untimed (no `seconds` and no deadline — the server sends deadline 0 for every phase outside the battles of a solo
|
||||
* match, Match.soloUntimed): renders nothing at all. It used to draw a "--" placeholder with the COUNTDOWN label,
|
||||
* which reads as a timer (user playtest #4 item 3; css/screens/loadout.css keeps hiding the old placeholder).
|
||||
* @param {{ deadline?: number, seconds?: number, total?: number, warnAt?: number, label?: string,
|
||||
* size?: 'sm'|'md'|'lg', gauge?: boolean, onExpire?: Function, class?: string }} props
|
||||
* deadline: server epoch ms (live); seconds: static value (overrides deadline); total: seconds for the gauge.
|
||||
@@ -364,6 +367,8 @@ export function Countdown({ deadline, seconds, total, warnAt = 10, label = 'COUN
|
||||
}
|
||||
});
|
||||
|
||||
// untimed phase: no timer on screen (hooks above run unconditionally, so the component may toggle freely)
|
||||
if (remain == null) return null;
|
||||
return html`<div class=${cx('countdown', `countdown--${size}`, warn && 'is-warn', cls)} role="timer" aria-label=${remain == null ? '无倒计时' : `剩余${remain}秒`}>
|
||||
<div class="countdown__main">
|
||||
<${SevenSeg} text=${text} tone=${warn ? 'orange' : 'mint'} flicker=${warn && remain > 0} />
|
||||
|
||||
+126
-30
@@ -10,8 +10,14 @@
|
||||
// match (research 09 §5, ui/underframe.js): the panel's own 出售 / 销毁 buttons only render for callers that pass
|
||||
// `editable` + handlers. `side` 'right' docks the panel at the right edge (the game screen picks the side away from a
|
||||
// selected unit's underframe, gameLogic panelSide).
|
||||
// Live stats (user playtest #4 item 7 — the card used to show the fixed record numbers): `live` = the unit's current
|
||||
// stats (shared/protocol.js unitStatsEntry + `src`) — in battle the browser's own sim (battle/runner.js unitStats; a
|
||||
// getter re-read 4× a second: current HP, max HP, ATK, DEF, RES, attack interval, block), in prep the stats the own
|
||||
// board's units start their next battle with (m.unitStats: equipment, bonds / layers, 特质, band and 机变 effects). Each
|
||||
// value is coloured against the unit's base like the official card — green when it helps (higher, or a shorter attack
|
||||
// interval) with the difference beside it, red when it hurts — and a 实时 / 开战时 tag says which it is.
|
||||
|
||||
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog } from './components.js';
|
||||
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
|
||||
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
|
||||
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier } from './gameLogic.js';
|
||||
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
|
||||
@@ -43,8 +49,64 @@ export function RangeGrid({ grid, class: cls }) {
|
||||
return html`<div class=${cx('rgrid', cls)} style=${`grid-template-columns:repeat(${box.cols}, var(--rg))`} aria-label="攻击范围">${cells}</div>`;
|
||||
}
|
||||
|
||||
function Stat({ k, v, sub }) {
|
||||
return html`<div class="dstat"><span class="dstat__k">${k}</span><span class="dstat__row"><b class="dstat__v num">${v}</b>${sub ? html`<small>${sub}</small>` : null}</span></div>`;
|
||||
function Stat({ k, v, sub, tone = null, title }) {
|
||||
return html`<div class=${cx('dstat', tone && `is-${tone}`)} title=${title}><span class="dstat__k">${k}</span><span class="dstat__row"><b class="dstat__v num">${v}</b>${sub ? html`<small>${sub}</small>` : null}</span></div>`;
|
||||
}
|
||||
|
||||
/** Tolerance below which a live stat counts as its base (display rounding). */
|
||||
const STAT_EPS = { interval: 0.005, res: 0.05, moveSpeed: 0.005 };
|
||||
|
||||
/**
|
||||
* How a live stat compares with the unit's base (the official card's colours): 'up' (green) when it helps — higher,
|
||||
* or a shorter attack interval —, 'down' (red) when it hurts, null when equal or unknown.
|
||||
* @param {string} key maxHp | atk | def | res | interval | blockCnt | moveSpeed
|
||||
* @param {any} cur @param {any} base
|
||||
* @returns {'up'|'down'|null}
|
||||
*/
|
||||
export function statTone(key, cur, base) {
|
||||
if (!Number.isFinite(cur) || !Number.isFinite(base)) return null;
|
||||
const d = cur - base;
|
||||
if (Math.abs(d) < (STAT_EPS[key] ?? 0.5)) return null;
|
||||
return (key === 'interval' ? d < 0 : d > 0) ? 'up' : 'down';
|
||||
}
|
||||
|
||||
/**
|
||||
* One stat of the card: the live value (when `live` has it) coloured against `live.base`, with the difference as the
|
||||
* small text; else the record value.
|
||||
* @param {any} live unitStatsEntry (+ src) or null
|
||||
* @param {string} key unitStatsEntry key
|
||||
* @param {any} fallback record value
|
||||
* @param {(v: any) => any} [fmt]
|
||||
* @returns {{ v: any, tone: 'up'|'down'|null, sub: string|null, title: string|undefined }}
|
||||
*/
|
||||
export function liveStat(live, key, fallback, fmt = fmtNum) {
|
||||
const cur = live && Number.isFinite(live[key]) ? live[key] : null;
|
||||
if (cur == null) return { v: fallback == null ? '—' : fmt(fallback), tone: null, sub: null, title: undefined };
|
||||
const base = live.base && Number.isFinite(live.base[key]) ? live.base[key] : null;
|
||||
const tone = statTone(key, cur, base);
|
||||
let sub = null;
|
||||
if (tone) {
|
||||
const d = cur - base;
|
||||
sub = key === 'interval' ? `${d > 0 ? '+' : '−'}${Math.abs(d).toFixed(2)}` : `${d > 0 ? '+' : '−'}${key === 'res' || key === 'moveSpeed' ? Math.round(Math.abs(d) * 10) / 10 : fmtNum(Math.abs(d))}`;
|
||||
}
|
||||
return { v: fmt(cur), tone, sub, title: base != null ? `基础 ${fmt(base)}` : undefined };
|
||||
}
|
||||
|
||||
/** The tag of a live stats block: 实时 (battle) / 开战时 (the prep preview). */
|
||||
function LiveTag({ live }) {
|
||||
if (!live) return null;
|
||||
const battle = live.src === 'battle';
|
||||
return html`<span class=${cx('dstats__tag', battle && 'is-battle')} title=${battle ? '当前作战中的实时数值(绿色为增益,红色为减益)'
|
||||
: '下一场作战开始时的数值:已计入装备、盟约层数、特质、策略与机变效果(不含技能与作战中的临时效果)'}>${battle ? '实时' : '开战时'}</span>`;
|
||||
}
|
||||
|
||||
const fmtInterval = (v) => (Number.isFinite(v) && v > 0 ? `${v.toFixed(2)}s` : '—');
|
||||
const fmtRes = (v) => (Number.isFinite(v) ? String(Math.round(v * 10) / 10) : '0');
|
||||
|
||||
/** HP bar of the card header: the live HP in battle, else the snapshot's. */
|
||||
function hpOf(live, snapHp) {
|
||||
if (live && live.src === 'battle' && Number.isFinite(live.hp) && Number.isFinite(live.maxHp)) return { hp: live.hp, max: live.maxHp };
|
||||
return snapHp || null;
|
||||
}
|
||||
|
||||
function Section({ title, micro, children, class: cls }) {
|
||||
@@ -139,8 +201,9 @@ function GarrisonBlock({ garrison, m }) {
|
||||
</section>`;
|
||||
}
|
||||
|
||||
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond }) {
|
||||
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond, live = null }) {
|
||||
const m = data.get('assets');
|
||||
const hp = hpOf(live, snapHp);
|
||||
const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id));
|
||||
const c = chess;
|
||||
// stats / talents the unit fights with: the chosen module's (or none — statsBase) for an elite (DESIGN §16)
|
||||
@@ -177,21 +240,28 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
|
||||
<span>${c.subProfessionName || ''}</span>
|
||||
<span class="dhead__pos">${c.position === 'MELEE' ? '近战位' : '远程位'}</span>
|
||||
</div>
|
||||
${snapHp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (snapHp.hp / Math.max(1, snapHp.max)) * 100))}%`}></i><span class="num">${fmtNum(snapHp.hp)} / ${fmtNum(snapHp.max)}</span></div>` : null}
|
||||
${hp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
|
||||
<${BondChips} bondIds=${c.bonds} bonds=${bonds} onBond=${onBond} />
|
||||
</div>
|
||||
</div>`;
|
||||
blocks.garrison = garrison ? html`<${GarrisonBlock} key="garrison" garrison=${garrison} m=${m} />` : null;
|
||||
blocks.trait = c.trait?.desc ? html`<p key="trait" class="dtrait"><${Icon} name="info" /><${RichText} text=${traitText(c, golden, lo)} /></p>` : null;
|
||||
// live (battle) / start-of-battle (prep) values against the base, else the record's (liveStat)
|
||||
const st = {
|
||||
maxHp: liveStat(live, 'maxHp', s.maxHp), atk: liveStat(live, 'atk', s.atk), def: liveStat(live, 'def', s.def),
|
||||
res: liveStat(live, 'res', s.res ?? 0, fmtRes), interval: liveStat(live, 'interval', interval, fmtInterval),
|
||||
blockCnt: liveStat(live, 'blockCnt', s.blockCnt, (v) => String(v)),
|
||||
};
|
||||
blocks.stats = html`
|
||||
<div key="stats" class="dstats-wrap">
|
||||
<div key="stats" class=${cx('dstats-wrap', live && 'is-live')} data-live=${live ? live.src || 'prep' : undefined}>
|
||||
<div class="dstats">
|
||||
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} />
|
||||
<${Stat} k="攻击" v=${fmtNum(s.atk)} />
|
||||
<${Stat} k="防御" v=${fmtNum(s.def)} />
|
||||
<${Stat} k="法术抗性" v=${s.res ?? 0} />
|
||||
<${Stat} k="攻击间隔" v=${interval ? `${interval.toFixed(2)}s` : '—'} />
|
||||
<${Stat} k="阻挡数" v=${s.blockCnt ?? '—'} />
|
||||
<${LiveTag} live=${live} />
|
||||
<${Stat} k="生命上限" ...${st.maxHp} />
|
||||
<${Stat} k="攻击" ...${st.atk} />
|
||||
<${Stat} k="防御" ...${st.def} />
|
||||
<${Stat} k="法术抗性" ...${st.res} />
|
||||
<${Stat} k="攻击间隔" ...${st.interval} />
|
||||
<${Stat} k="阻挡数" ...${st.blockCnt} />
|
||||
<${Stat} k="部署费用" v=${s.cost ?? '—'} />
|
||||
<${Stat} k="再部署" v=${s.respawnTime != null ? `${s.respawnTime}s` : '—'} />
|
||||
</div>
|
||||
@@ -234,7 +304,11 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
|
||||
return CHESS_SECTIONS.map((k) => blocks[k]).filter(Boolean);
|
||||
}
|
||||
|
||||
function ItemDetail({ item, piece, editable, onDestroy }) {
|
||||
/**
|
||||
* An item's card (hand / temp / shop / equipped). An effect-only item (items.json `shopExcluded`: the special 维式重锤,
|
||||
* 突变细胞 — user playtest #4 item 5) says it is never sold and where it comes from (`shopExcludedBy`).
|
||||
*/
|
||||
export function ItemDetail({ item, piece, editable, onDestroy }) {
|
||||
const m = data.get('assets');
|
||||
return html`
|
||||
<div class="dhead dhead--item">
|
||||
@@ -250,16 +324,24 @@ function ItemDetail({ item, piece, editable, onDestroy }) {
|
||||
${item.itemType === 'MAGIC'
|
||||
? html`<p class="dhint"><${Icon} name="info" />将其拖拽至战场上的格子使用</p>`
|
||||
: html`<p class="dhint"><${Icon} name="info" />拖拽至干员身上进行配发(每名干员最多 2 件,配发后无法取下)${item.mergeable ? ';2 件相同装备自动合成进阶装备' : ''}</p>`}
|
||||
${item.shopExcluded ? html`<p class="dhint dhint--source"><${Icon} name="info" />调度中心不出售 · 获取途径:${item.shopExcludedBy || '效果获得'}</p>` : null}
|
||||
${piece && editable ? html`<div class="dactions"><${Button} variant="danger" onClick=${() => onDestroy(piece, item)}>销毁道具<//></div>` : null}`;
|
||||
}
|
||||
|
||||
function EnemyDetail({ enemy, snapHp, count }) {
|
||||
function EnemyDetail({ enemy, snapHp, count, live = null }) {
|
||||
const m = data.get('assets');
|
||||
const s = enemy.stats || {};
|
||||
const types = Array.isArray(enemy.acTypes) ? enemy.acTypes : enemy.acType ? [enemy.acType] : [];
|
||||
const factions = data.get('factions')?.types || {};
|
||||
const imm = Object.entries(s.immunities || {}).filter(([, v]) => v).map(([k]) => ({ stun: '晕眩', silence: '沉默', sleep: '沉睡', frozen: '冻结', levitate: '浮空' }[k] || k));
|
||||
const interval = attackInterval(s.bat, s.aspd);
|
||||
const hp = hpOf(live, snapHp);
|
||||
// a battle enemy: its live stats against its spawned ones (the round's multipliers included — unitStatsEntry base)
|
||||
const st = {
|
||||
maxHp: liveStat(live, 'maxHp', s.maxHp), atk: liveStat(live, 'atk', s.atk), def: liveStat(live, 'def', s.def),
|
||||
res: liveStat(live, 'res', s.res ?? 0, fmtRes), moveSpeed: liveStat(live, 'moveSpeed', s.moveSpeed, (v) => String(Math.round(v * 100) / 100)),
|
||||
interval: liveStat(live, 'interval', interval, (v) => (Number.isFinite(v) && v > 0 ? `${v.toFixed(1)}s` : '—')),
|
||||
};
|
||||
return html`
|
||||
<div class="dhead dhead--enemy">
|
||||
<div class=${cx('dhead__icon', 'dhead__icon--enemy', enemy.rank === 'BOSS' && 'is-boss', enemy.rank === 'ELITE' && 'is-elite')}>
|
||||
@@ -273,16 +355,17 @@ function EnemyDetail({ enemy, snapHp, count }) {
|
||||
</div>
|
||||
<h3 class="dhead__name">${enemy.name}</h3>
|
||||
<div class="dfactions">${types.map((t) => html`<span key=${t} class="dfaction"><${Img} src=${factionIconUrl(m, factions[t]?.icon)} />${factions[t]?.name || t}</span>`)}</div>
|
||||
${snapHp ? html`<div class="dhp dhp--enemy"><i style=${`width:${Math.max(0, Math.min(100, (snapHp.hp / Math.max(1, snapHp.max)) * 100))}%`}></i><span class="num">${fmtNum(snapHp.hp)} / ${fmtNum(snapHp.max)}</span></div>` : null}
|
||||
${hp ? html`<div class="dhp dhp--enemy"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dstats">
|
||||
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} />
|
||||
<${Stat} k="攻击" v=${fmtNum(s.atk)} sub=${DMG[s.dmgType] || ''} />
|
||||
<${Stat} k="防御" v=${fmtNum(s.def)} />
|
||||
<${Stat} k="法术抗性" v=${s.res ?? 0} />
|
||||
<${Stat} k="移动速度" v=${s.moveSpeed ?? '—'} />
|
||||
<${Stat} k="攻击间隔" v=${interval ? `${interval.toFixed(1)}s` : '—'} />
|
||||
<div class=${cx('dstats', live && 'is-live')} data-live=${live ? live.src || 'battle' : undefined}>
|
||||
<${LiveTag} live=${live} />
|
||||
<${Stat} k="生命上限" ...${st.maxHp} />
|
||||
<${Stat} k="攻击" ...${st.atk} sub=${st.atk.sub || DMG[s.dmgType] || ''} />
|
||||
<${Stat} k="防御" ...${st.def} />
|
||||
<${Stat} k="法术抗性" ...${st.res} />
|
||||
<${Stat} k="移动速度" ...${st.moveSpeed} />
|
||||
<${Stat} k="攻击间隔" ...${st.interval} />
|
||||
<${Stat} k="攻击范围" v=${s.rangeRadius > 0 ? s.rangeRadius : '近战'} />
|
||||
<${Stat} k="目标价值" v=${s.lpr ?? 1} />
|
||||
</div>
|
||||
@@ -292,20 +375,27 @@ function EnemyDetail({ enemy, snapHp, count }) {
|
||||
<//>` : enemy.descRaw || enemy.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${enemy.descRaw || enemy.desc} class="dtext" /><//>` : null}`;
|
||||
}
|
||||
|
||||
function TokenDetail({ token, piece }) {
|
||||
function TokenDetail({ token, piece, snapHp = null, live = null }) {
|
||||
const m = data.get('assets');
|
||||
const s = token.stats || {};
|
||||
const hp = hpOf(live, snapHp);
|
||||
const st = {
|
||||
maxHp: liveStat(live, 'maxHp', s.maxHp), atk: liveStat(live, 'atk', s.atk), def: liveStat(live, 'def', s.def),
|
||||
blockCnt: liveStat(live, 'blockCnt', s.blockCnt, (v) => String(v)),
|
||||
};
|
||||
return html`
|
||||
<div class="dhead dhead--item">
|
||||
<div class="dhead__icon"><${Img} src=${tokenAvatarUrl(m, token.tokenId)} fallback=${html`<${GIcon} name="target" />`} /></div>
|
||||
<div class="dhead__info">
|
||||
<div class="dhead__chips"><span class="dtag-token">召唤物</span>${piece?.count > 1 ? html`<span class="dtag-kind num">×${piece.count}</span>` : null}</div>
|
||||
<h3 class="dhead__name">${token.name}</h3>
|
||||
${hp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dstats">
|
||||
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} /><${Stat} k="攻击" v=${fmtNum(s.atk)} />
|
||||
<${Stat} k="防御" v=${fmtNum(s.def)} /><${Stat} k="阻挡数" v=${s.blockCnt ?? '—'} />
|
||||
<div class=${cx('dstats', live && 'is-live')} data-live=${live ? live.src || 'prep' : undefined}>
|
||||
<${LiveTag} live=${live} />
|
||||
<${Stat} k="生命上限" ...${st.maxHp} /><${Stat} k="攻击" ...${st.atk} />
|
||||
<${Stat} k="防御" ...${st.def} /><${Stat} k="阻挡数" ...${st.blockCnt} />
|
||||
</div>
|
||||
${token.descRaw || token.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${token.descRaw || token.desc} class="dtext" /><//>` : null}`;
|
||||
}
|
||||
@@ -351,9 +441,15 @@ export function resolveDetail(target, pieces) {
|
||||
* bonds: the owner's m.private.bonds (counts / tiers of the bond chips); loadout: m.private.loadout (DESIGN §16) for
|
||||
* the player's own operators and shop cards; a teammate's unit gets its owner's choice (gameLogic unitLoadout); null
|
||||
* = the defaults
|
||||
* live: the unit's live stats (unitStatsEntry + src 'battle' | 'prep') — an object, or a getter the panel re-reads 4×
|
||||
* a second (the battle's own sim, battle/runner.js unitStats); null ⇒ the record's numbers
|
||||
*/
|
||||
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], loadout = null, onBond = null, side = 'left', shopOpen = false }) {
|
||||
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], loadout = null, onBond = null, side = 'left', shopOpen = false, live = null }) {
|
||||
const getter = typeof live === 'function' ? live : null;
|
||||
useTicker(detail && getter ? 250 : 0);
|
||||
if (!detail) return null;
|
||||
let liveNow = null;
|
||||
try { liveNow = getter ? getter() : live && typeof live === 'object' ? live : null; } catch { liveNow = null; }
|
||||
const sellIt = async (piece, chess) => {
|
||||
const golden = piece.golden || chess?.isGolden;
|
||||
if (golden) {
|
||||
@@ -371,10 +467,10 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
|
||||
<button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
|
||||
<div class="dpanel__scroll">
|
||||
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
|
||||
bonds=${bonds} loadout=${loadout} onBond=${onBond} />` : null}
|
||||
bonds=${bonds} loadout=${loadout} onBond=${onBond} live=${liveNow} />` : null}
|
||||
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} />` : null}
|
||||
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} />` : null}
|
||||
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} />` : null}
|
||||
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
|
||||
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} snapHp=${snapHp} live=${liveNow} />` : null}
|
||||
</div>
|
||||
</aside>`;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { GEO, PHASE, UF } from '../../../shared/constants.js';
|
||||
import { resolveLoadout, loadoutOptions, MODULE_NONE } from '../../../shared/protocol.js';
|
||||
import { resolveRecordLoadout, loadoutRecord } from '../../../shared/loadoutRecord.js';
|
||||
import { layoutPen } from '../render/pen.js';
|
||||
import { pickBody } from '../render/pick.js';
|
||||
import { bossLevelSeconds } from './matchStatus.js';
|
||||
|
||||
// ---- small helpers -------------------------------------------------------------------------------
|
||||
@@ -148,7 +147,8 @@ export function phaseTotalSeconds(pub, config, myId = null) {
|
||||
const num = (v) => (Number.isFinite(v) && v > 0 ? v : null);
|
||||
switch (pub.phase) {
|
||||
case PHASE.INFO_CHECK: return num(timers.infoCheck) ?? 25;
|
||||
case PHASE.BAND_DRAFT: return num(timers.bandTurn) ?? 12;
|
||||
// one countdown: the current turn's (m.public.draft.turnSeconds = Match.BAND_TURN_SECONDS; user playtest #4 item 4)
|
||||
case PHASE.BAND_DRAFT: return num(pub.draft?.turnSeconds) ?? num(timers.bandTurn) ?? 30;
|
||||
case PHASE.BATTLE_CHECK: return num(timers.battleCheck) ?? 3;
|
||||
case PHASE.SP_DRAFT: {
|
||||
const sp = normalizeSp(pub.sp, pub.players);
|
||||
@@ -871,56 +871,6 @@ export function dropIntent(ctx, uid, target) {
|
||||
return { t: 'g.move', fields: { uid, to } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The piece under a screen point (user playtest #3 item 7). Rects of the render engine carry the drawn body
|
||||
* (`shape`, view.pieceScreenRect) and are picked by the shared rule of render/pick.js — the face / torso of a unit is
|
||||
* its own; elsewhere the piece standing on the tile under the pointer (`tile`, same space as the rects' `tile`) wins.
|
||||
* Plain rects (the DOM fallback) go to the one whose centre is nearest relative to its size (a unit standing in front
|
||||
* no longer takes the whole overlap), ties → the front-most (largest bottom).
|
||||
* @param {Array<{uid:number, left:number, top:number, right:number, bottom:number, shape?:object, tile?:{row:number,col:number}}|null>} rects
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {{row:number, col:number}|null} [tile] the tile under the point
|
||||
* @returns {number|null} uid
|
||||
*/
|
||||
export function pickPieceAt(rects, x, y, tile = null) {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||
const list = (Array.isArray(rects) ? rects : []).filter((r) => isObj(r) && Number.isInteger(r.uid));
|
||||
const shaped = list.filter((r) => isObj(r.shape) && r.shape.s > 0);
|
||||
if (shaped.length && shaped.length === list.length) {
|
||||
const hit = pickBody(shaped.map((r) => ({ ...r.shape, tile: r.tile || null, uid: r.uid })), x, y, tile);
|
||||
return hit ? hit.uid : null;
|
||||
}
|
||||
let best = null, bestD = Infinity;
|
||||
for (const r of list) {
|
||||
if (x < r.left || x > r.right || y < r.top || y > r.bottom) continue;
|
||||
const hw = Math.max(1, (r.right - r.left) / 2), hh = Math.max(1, (r.bottom - r.top) / 2);
|
||||
const d = Math.hypot((x - (r.left + r.right) / 2) / hw, (y - (r.top + r.bottom) / 2) / hh);
|
||||
if (d < bestD - 1e-9 || (Math.abs(d - bestD) <= 1e-9 && r.bottom > best.bottom)) { best = r; bestD = d; }
|
||||
}
|
||||
return best ? best.uid : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* An equipment item released over an operator's sprite goes to that operator. The sprite stands about a tile taller
|
||||
* than its own tile, so the tile under the pointer (the drop target of render/drag.js) is often the one behind it.
|
||||
* @param {ReturnType<typeof placementContext>} ctx
|
||||
* @param {number} uid the dragged piece
|
||||
* @param {any} target the tile target (board / hand / outside / null)
|
||||
* @param {number|null} hitUid the own unit piece whose sprite is under the pointer (pickPieceAt)
|
||||
* @returns {any} the target to use: the hit unit's tile / slot, or `target` unchanged
|
||||
*/
|
||||
export function equipRetarget(ctx, uid, target, hitUid) {
|
||||
const src = ctx?.pieces?.get(uid);
|
||||
if (!src || src.piece.kind !== 'item' || !Number.isInteger(hitUid) || hitUid === uid) return target;
|
||||
if (ctx.getItem(src.piece.id)?.itemType === 'MAGIC') return target; // Arts are used on the tile itself
|
||||
const hit = ctx.pieces.get(hitUid);
|
||||
if (!hit || (hit.piece.kind !== 'chess' && hit.piece.kind !== 'token')) return target;
|
||||
if (hit.area === 'board') return { area: 'board', row: hit.row, col: hit.col };
|
||||
if (hit.area === 'hand') return { area: 'hand', idx: hit.idx };
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a drag released over the field was refused (null when there is nothing to say): the canPlace reason for a
|
||||
* board tile / hand slot, a generic line for other tiles of the own board rows (lanes, blocked tiles) and the
|
||||
|
||||
Reference in new issue
Block a user