Playtest #3 fixes: lost models, picking, live LP, temp overflow, 蕾缪安 S3, 回环射手, FX, detail card, spinners
- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s - #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks, hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped at one per frame - #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates' pending LP in the team panel - #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue); temp-row warning and ready-button reason - #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx - #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught) - #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost - #8 特质 right under the detail card header, with its official type icon - #9 no spinning busy cursors / spinners next to texts; game data warmed before the match - docs, recordings and tests updated (2414 unit tests, all browser suites green)
This commit is contained in:
1 parent
01a44cd288
commit
0aba602ecf
72 files changed
+5132
-571
No files matched your search
@@ -1,9 +1,11 @@
|
||||
// 机变 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.
|
||||
// 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).
|
||||
|
||||
import { html, Icon, TierChip, Countdown, MicroLabel, Spinner } from './components.js';
|
||||
import { html, Icon, TierChip, Countdown, MicroLabel } from './components.js';
|
||||
import { Img, RichText, PlayerAvatar, GIcon } from './gameComponents.js';
|
||||
import { itemIconUrl, enemyIconUrl, uiUrl } from './assetUrls.js';
|
||||
import { richTextPlain } from './richText.js';
|
||||
@@ -47,6 +49,15 @@ export function resolveSpCard(card, family) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ pub:any, sp:any, myId:string, solo:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props
|
||||
*/
|
||||
@@ -94,7 +105,9 @@ export function ChoiceOverlay({ pub, sp, myId, solo, onPick, busyIdx = null, tot
|
||||
const r = resolveSpCard(card, sp.family);
|
||||
const taker = card.takenBy ? players.get(card.takenBy) : null;
|
||||
const can = myTurn && mine == null && !card.takenBy && busyIdx == null;
|
||||
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', busyIdx === card.idx && 'is-busy')}
|
||||
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)}`}>
|
||||
<span class="spcard__glow" aria-hidden="true"></span>
|
||||
${r.tier ? html`<${TierChip} tier=${r.tier} size="md" class="spcard__tier" />` : null}
|
||||
@@ -108,7 +121,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}
|
||||
${busyIdx === card.idx ? html`<span class="spcard__spin"><${Spinner} size="sm" /></span>` : null}
|
||||
${busy ? html`<span class="spcard__busy" role="status">选择中</span>` : null}
|
||||
</button>`;
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -111,7 +111,9 @@ export function Chevrons({ count = 3, dir = 'right', tone = 'mint', animated = t
|
||||
// ---- Button ------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Button.
|
||||
* Button. `loading` (a request in flight): disabled and dimmed, and after a short delay a thin bar sweeps along its
|
||||
* bottom edge (css .btn__busy) — never a spinning ring or a wait cursor, which read as "this text is still loading from
|
||||
* the server" (user playtest #3 item 9); the icon stays.
|
||||
* @param {{ variant?: 'primary'|'secondary'|'danger'|'amber'|'ice'|'ghost', size?: 'sm'|'md'|'lg'|'xl',
|
||||
* icon?: string, iconRight?: string, loading?: boolean, disabled?: boolean, block?: boolean, square?: boolean,
|
||||
* active?: boolean, onClick?: Function, type?: string, title?: string, class?: string, children?: any }} props
|
||||
@@ -126,9 +128,10 @@ export function Button({
|
||||
active && 'is-active', loading && 'is-loading', cls)}
|
||||
disabled=${isDisabled} aria-busy=${loading ? 'true' : undefined} aria-pressed=${active ? 'true' : undefined}
|
||||
onClick=${(e) => { if (!isDisabled && onClick) onClick(e); }} ...${rest}>
|
||||
${loading ? html`<span class="btn__spin" aria-hidden="true"></span>` : icon ? html`<${Icon} name=${icon} class="btn__icon" />` : null}
|
||||
${icon ? html`<${Icon} name=${icon} class="btn__icon" />` : null}
|
||||
${children != null && children !== false ? html`<span class="btn__label">${children}</span>` : null}
|
||||
${iconRight ? html`<${Icon} name=${iconRight} class="btn__icon btn__icon--right" />` : null}
|
||||
${loading ? html`<span class="btn__busy" aria-hidden="true"></span>` : null}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
|
||||
+64
-31
@@ -1,8 +1,11 @@
|
||||
// Detail panel (click / right-click a piece, shop card, bond member, battle unit or previewed enemy):
|
||||
// operators — portrait, name, tier, elite, class/subclass and, right under them in the header's right column (no
|
||||
// scrolling, user playtest #2 item 9), the unit's bonds (阵营 / 盟约: icon, name, member count / next threshold,
|
||||
// reached tier, active state — tap one for its popup); then stats, range mini-map, skill (the one chosen in the loadout, DESIGN §16: icon, SP info, rich description,
|
||||
// 已调配 when not the default), elite module (模组: official type icon from the local-client art, else its letter), talents, 特质 (garrison), equipped items; items — icon, tier,
|
||||
// reached tier, active state — tap one for its popup); right under the header the operator's own effect (特质 —
|
||||
// garrison: type chip (its official type icon + eventTypeDesc such as 整备能力, garrisonTypeIconKey) + description,
|
||||
// compact, visible without scrolling: user playtest #3 items 8 / 9), the class trait (特性), stats + range mini-map, skill (the one chosen in the loadout, DESIGN §16: icon, SP
|
||||
// info, rich description, 已调配 when not the default), elite module (模组: official type icon from the local-client
|
||||
// art, else its letter), equipped items, talents (CHESS_SECTIONS); items — icon, tier,
|
||||
// effect; tokens; enemies — stats, rank, faction tags, abilities. Selling / destroying is the underframe's job in the
|
||||
// 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
|
||||
@@ -21,7 +24,8 @@ const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
const PROF_NAME = { PIONEER: '先锋', WARRIOR: '近卫', TANK: '重装', SNIPER: '狙击', CASTER: '术师', MEDIC: '医疗', SUPPORT: '辅助', SPECIAL: '特种', TOKEN: '召唤物' };
|
||||
const SP_TYPE = { INCREASE_WITH_TIME: '自动回复', INCREASE_WHEN_ATTACK: '攻击回复', INCREASE_WHEN_TAKEN_DAMAGE: '受击回复', ON_DEPLOY: '被动' };
|
||||
const SKILL_TYPE = { MANUAL: '自动触发', AUTO: '自动触发', PASSIVE: '被动' };
|
||||
const EVENT_ICON = { IN_BATTLE: 's_icon_battle', SERVER_GAIN: 's_icon_bond', SERVER_PREP_START: 's_icon_support', SERVER_PREP_FIN: 's_icon_support', SERVER_CHESS_SOLD: 's_icon_gold', SERVER_PRICE: 's_icon_gold', SERVER_REFRESH_SHOP: 's_icon_gold' };
|
||||
/** Fallback type icon by trigger, for a garrison record without its official `eventTypeIcon` (garrisonTypeIconKey). */
|
||||
const EVENT_ICON = { IN_BATTLE: 's_icon_battle', SERVER_GAIN: 's_icon_bond', SERVER_PREP_START: 's_icon_bond', SERVER_PREP_FIN: 's_icon_bond', SERVER_CHESS_SOLD: 's_icon_gold', SERVER_PRICE: 's_icon_gold', SERVER_REFRESH_SHOP: 's_icon_gold' };
|
||||
const RANK = { NORMAL: '普通', ELITE: '精英', BOSS: '领袖' };
|
||||
const DMG = { phys: '物理', arts: '法术', heal: '治疗', true: '真实', none: '无' };
|
||||
|
||||
@@ -40,7 +44,7 @@ export function RangeGrid({ grid, class: cls }) {
|
||||
}
|
||||
|
||||
function Stat({ k, v, sub }) {
|
||||
return html`<div class="dstat"><span class="dstat__k">${k}</span><b class="dstat__v num">${v}</b>${sub ? html`<small>${sub}</small>` : null}</div>`;
|
||||
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 Section({ title, micro, children, class: cls }) {
|
||||
@@ -101,7 +105,41 @@ function traitText(c, golden, lo) {
|
||||
return t.moduleDescRaw || base;
|
||||
}
|
||||
|
||||
function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond }) {
|
||||
/**
|
||||
* Order of an operator card's blocks (user playtest #3 item 8): the operator's own effect (特质 — garrison: its trigger
|
||||
* such as 休整期结束时 and what it does) right under the header, visible without scrolling; the class trait (特性) and
|
||||
* the stats next; then the skill, the elite's module, the equipped items (the player's own build) and the talents.
|
||||
*/
|
||||
export const CHESS_SECTIONS = Object.freeze(['head', 'garrison', 'trait', 'stats', 'skill', 'module', 'equip', 'talents', 'actions']);
|
||||
|
||||
/**
|
||||
* Sprite key (ui `garrisonTypeIcon/…`, small variant) of a 特质's type chip: the garrison's own official
|
||||
* `eventTypeIcon` — icon_battle 作战能力 / icon_gold 整备能力 / icon_bond 持续叠加·单次叠加 / icon_support 特异化, the
|
||||
* icon that goes with its `eventTypeDesc` — never a guess from the trigger (that put the spoked 特异化 glyph, which reads
|
||||
* like a loading spinner, before the text of every <休整期开始时 / 结束时> 特质: user playtest #3 item 9).
|
||||
* @param {{ eventTypeIcon?: string|null, eventType?: string }|null} garrison
|
||||
*/
|
||||
export function garrisonTypeIconKey(garrison) {
|
||||
const k = garrison && typeof garrison.eventTypeIcon === 'string' ? garrison.eventTypeIcon : '';
|
||||
if (/^icon_[a-z]+$/.test(k)) return `s_${k}`;
|
||||
return EVENT_ICON[garrison?.eventType] || 's_icon_bond';
|
||||
}
|
||||
|
||||
/** The operator's own effect (特质, garrisons.json): trigger chip + description, compact. */
|
||||
function GarrisonBlock({ garrison, m }) {
|
||||
return html`<section class="dgarrison" aria-label="特质" data-garrison=${garrison.garrisonId || ''}>
|
||||
<div class="dgarrison__head">
|
||||
<span class="dgarrison__k">特质</span>
|
||||
<span class="dgarrison__type">
|
||||
<${Img} src=${uiUrl(m, `garrisonTypeIcon/${garrisonTypeIconKey(garrison)}`)} class="dgarrison__icon" />
|
||||
${garrison.eventTypeDesc || ''}
|
||||
</span>
|
||||
</div>
|
||||
<${RichText} as="p" text=${garrison.descRaw || garrison.desc} class="dgarrison__text" />
|
||||
</section>`;
|
||||
}
|
||||
|
||||
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond }) {
|
||||
const m = data.get('assets');
|
||||
const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id));
|
||||
const c = chess;
|
||||
@@ -117,8 +155,9 @@ function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, load
|
||||
const garrison = Array.isArray(c.garrisonIds) && c.garrisonIds[0] ? data.lookup('garrisons', c.garrisonIds[0]) : null;
|
||||
const items = Array.isArray(piece?.items) ? piece.items : [];
|
||||
const sell = c.sellPrice ?? 1;
|
||||
return html`
|
||||
<div class="dhead">
|
||||
const blocks = {};
|
||||
blocks.head = html`
|
||||
<div key="head" class="dhead">
|
||||
<div class=${cx('dhead__art', golden && 'is-golden', `dhead__art--t${c.tier}`)}>
|
||||
<${Img} src=${chessPortraitUrl(m, c)} fallback=${html`<${UnitThumb} kind="chess" id=${c.chessId} size="lg" />`} />
|
||||
</div>
|
||||
@@ -141,9 +180,11 @@ function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, load
|
||||
${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}
|
||||
<${BondChips} bondIds=${c.bonds} bonds=${bonds} onBond=${onBond} />
|
||||
</div>
|
||||
</div>
|
||||
${c.trait?.desc ? html`<p class="dtrait"><${Icon} name="info" /><${RichText} text=${traitText(c, golden, lo)} /></p>` : null}
|
||||
<div class="dstats-wrap">
|
||||
</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;
|
||||
blocks.stats = html`
|
||||
<div key="stats" class="dstats-wrap">
|
||||
<div class="dstats">
|
||||
<${Stat} k="生命上限" v=${fmtNum(s.maxHp)} />
|
||||
<${Stat} k="攻击" v=${fmtNum(s.atk)} />
|
||||
@@ -155,8 +196,8 @@ function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, load
|
||||
<${Stat} k="再部署" v=${s.respawnTime != null ? `${s.respawnTime}s` : '—'} />
|
||||
</div>
|
||||
<div class="drange"><span class="dstat__k">攻击范围</span><${RangeGrid} grid=${attackRangeGrid(fr) || c.rangeGrid} /></div>
|
||||
</div>
|
||||
${sk ? html`<${Section} title="技能" micro="SKILL" class="dsec--skill">
|
||||
</div>`;
|
||||
blocks.skill = sk ? html`<${Section} key="skill" title="技能" micro="SKILL" class="dsec--skill">
|
||||
<div class="dskill" data-skill=${sk.skillId || ''}>
|
||||
<${Img} src=${skIcon} class="dskill__icon" fallback=${html`<span class="dskill__icon dskill__icon--empty">${skSlot ? html`<b class="num">${skSlot}</b>` : null}</span>`} />
|
||||
<div class="dskill__meta">
|
||||
@@ -171,8 +212,8 @@ function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, load
|
||||
</div>
|
||||
</div>
|
||||
<${RichText} as="p" text=${sk.descRaw || sk.desc} class="dtext" />
|
||||
<//>` : null}
|
||||
${golden && lo?.module ? html`<${Section} title="模组" micro="MODULE" class="dsec--module">
|
||||
<//>` : null;
|
||||
blocks.module = golden && lo?.module ? html`<${Section} key="module" title="模组" micro="MODULE" class="dsec--module">
|
||||
<div class=${cx('dmodule', lo.module.none && 'is-none')} data-module=${lo.module.id}>
|
||||
${!lo.module.none && lo.module.typeName ? html`<span class="dmodule__icon" data-type=${lo.module.typeName}>
|
||||
<${Img} src=${moduleTypeIconUrl(data.get('local'), lo.module.typeName)} fallback=${html`<b class="num">${moduleBadge(lo.module)}</b>`} /></span>` : null}
|
||||
@@ -180,25 +221,17 @@ function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, load
|
||||
${lo.module.typeName ? html`<span class="dmodule__type">${lo.module.typeName}</span>` : null}
|
||||
${!lo.defaultModule ? html`<span class="dtag-loadout" title="干员调配中选择的模组">已调配</span>` : null}
|
||||
</div>
|
||||
<//>` : null}
|
||||
${Array.isArray(fr.talents) && fr.talents.some((t) => t && t.name && !t.hidden) ? html`<${Section} title="天赋" micro="TALENT">
|
||||
${fr.talents.filter((t) => t && t.name && !t.hidden).map((t, i) => html`<div key=${i} class="dtalent"><b>${t.name}</b><${RichText} text=${t.descRaw || t.desc} class="dtext" /></div>`)}
|
||||
<//>` : null}
|
||||
${garrison ? html`<${Section} title="特质" micro="GARRISON">
|
||||
<div class="dgarrison">
|
||||
<span class="dgarrison__type">
|
||||
<${Img} src=${uiUrl(m, `garrisonTypeIcon/${EVENT_ICON[garrison.eventType] || 's_icon_bond'}`)} class="dgarrison__icon" />
|
||||
${garrison.eventTypeDesc || ''}
|
||||
</span>
|
||||
<${RichText} as="p" text=${garrison.descRaw || garrison.desc} class="dtext" />
|
||||
</div>
|
||||
<//>` : null}
|
||||
${piece?.kind === 'chess' ? html`<${Section} title="装备" micro=${`EQUIP ${items.length}/2`}>
|
||||
<//>` : null;
|
||||
blocks.equip = piece?.kind === 'chess' ? html`<${Section} key="equip" title="装备" micro=${`EQUIP ${items.length}/2`} class="dsec--equip">
|
||||
${items.length ? items.map((it) => html`<${ItemRow} key=${it.uid} itemId=${it.id} />`) : html`<p class="t-dim dempty">拖拽装备至该干员以配发(最多 2 件)</p>`}
|
||||
<//>` : null}
|
||||
${piece && editable && piece.kind !== 'item' ? html`<div class="dactions">
|
||||
<//>` : null;
|
||||
blocks.talents = Array.isArray(fr.talents) && fr.talents.some((t) => t && t.name && !t.hidden) ? html`<${Section} key="talents" title="天赋" micro="TALENT" class="dsec--talent">
|
||||
${fr.talents.filter((t) => t && t.name && !t.hidden).map((t, i) => html`<div key=${i} class="dtalent"><b>${t.name}</b><${RichText} text=${t.descRaw || t.desc} class="dtext" /></div>`)}
|
||||
<//>` : null;
|
||||
blocks.actions = piece && editable && piece.kind !== 'item' ? html`<div key="actions" class="dactions">
|
||||
<${Button} variant="amber" icon="close" class="dpanel__sell" onClick=${() => onSell(piece, c)}>出售<span class="dsell num">+${sell}</span><//>
|
||||
</div>` : null}`;
|
||||
</div>` : null;
|
||||
return CHESS_SECTIONS.map((k) => blocks[k]).filter(Boolean);
|
||||
}
|
||||
|
||||
function ItemDetail({ item, piece, editable, onDestroy }) {
|
||||
|
||||
@@ -162,11 +162,21 @@ export function GIcon({ name, class: cls, title }) {
|
||||
${title ? html`<title>${title}</title>` : null}<path d=${d} fill-rule="evenodd" /></svg>`;
|
||||
}
|
||||
|
||||
/** Green LP tower + number. */
|
||||
export function LpTower({ value, size = 'md', class: cls, tone }) {
|
||||
return html`<span class=${cx('lp', `lp--${size}`, tone && `lp--${tone}`, cls)} title="目标生命值">
|
||||
/**
|
||||
* Green LP tower + number. `pending` (> 0): the loss the running battle's leaks will cost at settlement (user
|
||||
* playtest #3 item 2) — the number shows value − pending in red followed by a −N tick that pops again on every change
|
||||
* (keyed); `note` a small tag after it (联防中); `tip` the title text.
|
||||
* @param {{ value: any, size?: 'sm'|'md'|'lg', class?: string, tone?: string|null, pending?: number, note?: string|null, tip?: string|null }} props
|
||||
*/
|
||||
export function LpTower({ value, size = 'md', class: cls, tone, pending = 0, note = null, tip = null }) {
|
||||
const ok = Number.isFinite(value);
|
||||
const p = ok && Number(pending) > 0 ? Math.min(value, Math.trunc(Number(pending))) : 0;
|
||||
return html`<span class=${cx('lp', `lp--${size}`, tone && `lp--${tone}`, p > 0 && 'is-pending', cls)} title=${tip || '目标生命值'}
|
||||
data-pending=${p > 0 ? p : null}>
|
||||
<${Sprite} k="hudPanel/icon_hp" class="lp__icon" fallback=${html`<${Icon} name="rook" class="lp__icon" />`} />
|
||||
<b class="num">${Number.isFinite(value) ? value : '--'}</b>
|
||||
<b class="num lp__val">${ok ? Math.max(0, value - p) : '--'}</b>
|
||||
${p > 0 ? html`<span key=${p} class="lp__pend num" aria-label=${`结算时扣除 ${p}`}>−${p}</span>` : null}
|
||||
${note ? html`<span class="lp__note">${note}</span>` : null}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ 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 -------------------------------------------------------------------------------
|
||||
@@ -871,17 +872,31 @@ export function dropIntent(ctx, uid, target) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The front-most piece whose screen rect contains (x, y): the one standing nearest the camera (largest bottom).
|
||||
* @param {Array<{uid:number, left:number, top:number, right:number, bottom:number}|null>} rects
|
||||
* 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) {
|
||||
export function pickPieceAt(rects, x, y, tile = null) {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||
let best = null;
|
||||
for (const r of Array.isArray(rects) ? rects : []) {
|
||||
if (!isObj(r) || !Number.isInteger(r.uid)) continue;
|
||||
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;
|
||||
if (!best || r.bottom > best.bottom) best = r;
|
||||
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;
|
||||
}
|
||||
|
||||
+107
-7
@@ -12,6 +12,11 @@
|
||||
// overtimeState): "NN 秒后全队生命值开始流失" once the level time ran out, then a live "生命值 −1/秒" indicator while the
|
||||
// merged team LP drains (the LP tower turns red). Solo battles: a pause / resume button beside the countdown (g.pause);
|
||||
// while m.public.paused every clock here is frozen at the pause moment (`frozenAt`).
|
||||
// Normal rounds (user playtest #3 item 2): the LP tower drops live as the own battle's enemies enter the blue gate —
|
||||
// lp − min(lpCapPerRound, counted leaks) in red with a −N tick, 联防中 while a 联防 may still save part of it (liveLp;
|
||||
// the leaks come from the local battle runner, else m.public players[].pendingLp).
|
||||
// 准备就绪 is refused while the temp overflow row (临时整备区) holds pieces: the reason shows under the button
|
||||
// (user playtest #3 item 3; the row's own label is ui/underframe.js TempRowNotice).
|
||||
|
||||
import { useRef } from '../../vendor/hooks.module.js';
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
@@ -56,21 +61,110 @@ export function PhaseCapsule({ pub, hud }) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---- live LP of the own battle (user playtest #3 item 2) --------------------------------------------------------
|
||||
|
||||
/** Phases whose leaks are still to be charged at settlement (normal rounds: the own battle, then 联防). */
|
||||
const LEAK_PHASES = new Set([PHASE.COMBAT, PHASE.UNITE]);
|
||||
|
||||
/**
|
||||
* Ready toggle (PREP only). Disabled with a reason while the temp hand is non-empty.
|
||||
* LP a round's leaks cost at settlement — the server rule (server/match/Match.js settle): min(lpCapPerRound, counted
|
||||
* leaks), never negative.
|
||||
* @param {number} leaks counted leaks so far
|
||||
* @param {number} [cap] data/config.json lpCapPerRound
|
||||
*/
|
||||
export function pendingLoss(leaks, cap = 10) {
|
||||
const n = Math.max(0, Math.trunc(Number(leaks) || 0));
|
||||
const c = Number(cap) > 0 ? Math.trunc(Number(cap)) : 10;
|
||||
return Math.min(c, n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counted leaks of the own battle so far from its two sources: the local runner's count (state().leaks of the own
|
||||
* field — authoritative or a display replica) and the server's m.public players[].pendingLp (the authority's b.progress,
|
||||
* ~1 Hz; the recorded result in 联防). Both only grow during a round, so the further one wins: a display replica stands
|
||||
* still while the player watches a teammate's field (the server's count moves on), the server's lags the local one by
|
||||
* up to a second. Missing / invalid values count as 0.
|
||||
* @param {any} local @param {any} server
|
||||
*/
|
||||
export function ownLeaks(local, server) {
|
||||
const n = (v) => (Number.isFinite(v) && v > 0 ? Math.trunc(v) : 0);
|
||||
return Math.max(n(local), n(server));
|
||||
}
|
||||
|
||||
/**
|
||||
* The own LP while a normal round's battle runs: the loss its counted leaks will cost is shown at once (red, −N)
|
||||
* instead of only at settlement. `base` — kept by the caller between renders — is the settled m.private state the
|
||||
* pending loss applies to: { round, lp, statsLeaks } as first seen in the round's COMBAT / 联防. The pending part is
|
||||
* dropped as soon as the settlement lands — m.private lp or stats.leaks changed (Match.flush sends m.private before the
|
||||
* SETTLE m.public) — or the phase leaves COMBAT / UNITE or the round changes, so the loss is never subtracted twice.
|
||||
* 联防 (UNITE): a leaker finally loses min(cap, the survivors of the 联防 battle that came from them) — never more than
|
||||
* its own battle's count, which stays on show marked `unite` (联防中: teammates may still save part of it) until the
|
||||
* settlement lands. Boss rounds are not handled here (the merged team LP moves live through b.pool / m.public.teamLp).
|
||||
* @param {{ round: any, lp: number, statsLeaks: number|null } | null} base
|
||||
* @param {{ phase: string, round: any, lp: any, statsLeaks?: any, leaks?: any, cap?: number, alive?: boolean }} s
|
||||
* @returns {{ base: { round: any, lp: number, statsLeaks: number|null } | null, pending: number, shown: number|null, unite: boolean }}
|
||||
*/
|
||||
export function liveLp(base, { phase, round, lp, statsLeaks = null, leaks = 0, cap = 10, alive = true }) {
|
||||
if (!Number.isFinite(lp)) return { base: null, pending: 0, shown: null, unite: false };
|
||||
if (!LEAK_PHASES.has(phase) || alive === false) return { base: null, pending: 0, shown: lp, unite: false };
|
||||
const sl = Number.isFinite(statsLeaks) ? statsLeaks : null;
|
||||
const b = base && base.round === round ? base : { round, lp, statsLeaks: sl };
|
||||
const landed = lp !== b.lp || (sl != null && b.statsLeaks != null && sl !== b.statsLeaks);
|
||||
const pending = landed ? 0 : Math.min(lp, pendingLoss(leaks, cap));
|
||||
return { base: b, pending, shown: lp - pending, unite: phase === PHASE.UNITE && pending > 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltip of an LP tower with a pending loss (null without one).
|
||||
* @param {number} lp settled LP @param {number} pending @param {{ unite?: boolean, cap?: number }} [opts]
|
||||
*/
|
||||
export function pendingTip(lp, pending, { unite = false, cap = 10 } = {}) {
|
||||
if (!(pending > 0)) return null;
|
||||
return unite
|
||||
? `目标生命值 ${lp}:联防中,队友正在迎战你漏过的敌人,结算时按联防后剩余的敌人扣除(至多 ${pending} 点)`
|
||||
: `目标生命值 ${lp}:本回合已有 ${pending >= cap ? `${cap} 个以上` : `${pending} 个`}敌人进入蓝门,结算时扣除 ${pending} 点(每回合至多 ${cap} 点)`;
|
||||
}
|
||||
|
||||
// ---- temp overflow row (临时整备区, user playtest #3 item 3) ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pieces waiting in the temp overflow row (m.private temp): how many, and how many are items.
|
||||
* @param {any} priv m.private
|
||||
* @returns {{ count: number, items: number }}
|
||||
*/
|
||||
export function tempInfo(priv) {
|
||||
const list = (Array.isArray(priv?.temp) ? priv.temp : []).filter((p) => p && typeof p === 'object');
|
||||
return { count: list.length, items: list.filter((p) => p.kind === 'item').length };
|
||||
}
|
||||
|
||||
/** What the temp row asks of the player (the ready button's reason, the row's label). */
|
||||
export const TEMP_RULE = '放入整备区或战场、配发或使用后才能准备就绪;休整期结束时仍留在临时整备区的单位将被销毁';
|
||||
|
||||
/** Why 准备就绪 is refused while the temp row holds pieces (null when it is empty). */
|
||||
export function tempReadyReason(priv) {
|
||||
const t = tempInfo(priv);
|
||||
return t.count ? `临时整备区还有 ${t.count} 个单位:${TEMP_RULE}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ready toggle (PREP only). Disabled while the temp row holds pieces — the reason shows under it (not only on hover):
|
||||
* "临时整备区 N 个单位待处理" (user playtest #3 item 3).
|
||||
* @param {{ priv:any, onToggle:(ready:boolean)=>void, busy?:boolean, readyCount?:number, total?:number }} props
|
||||
*/
|
||||
export function ReadyToggle({ priv, onToggle, busy, readyCount, total }) {
|
||||
const ready = !!priv?.ready;
|
||||
const reason = !ready ? shopBlockReason('ready', { priv, editable: true }) : null;
|
||||
const temp = tempInfo(priv);
|
||||
const reason = !ready ? tempReadyReason(priv) || shopBlockReason('ready', { priv, editable: true }) : null;
|
||||
const btn = html`<button type="button" class=${cx('readybtn', 'tapx', ready && 'is-on', busy && 'is-busy')} disabled=${!!reason || busy}
|
||||
aria-pressed=${ready ? 'true' : 'false'} onClick=${() => onToggle(!ready)}>
|
||||
aria-pressed=${ready ? 'true' : 'false'} aria-describedby=${!ready && temp.count ? 'readywrap-why' : undefined} onClick=${() => onToggle(!ready)}>
|
||||
<span class="readybtn__box">${ready ? html`<${Icon} name="check" />` : null}</span>
|
||||
<span class="readybtn__label">${ready ? '取消准备' : '准备就绪'}</span>
|
||||
<kbd class="readybtn__key">Space</kbd>
|
||||
</button>`;
|
||||
return html`<div class="readywrap">
|
||||
${reason ? html`<${Tooltip} text=${reason} placement="bottom">${btn}<//>` : btn}
|
||||
${!ready && temp.count ? html`<span class="readywrap__why" id="readywrap-why" role="status" data-testid="ready-why">
|
||||
<${Icon} name="warn" /><span>临时整备区 <b class="num">${temp.count}</b> 个单位待处理</span></span>` : null}
|
||||
${Number.isFinite(total) && total > 1 ? html`<span class="readywrap__count">已就绪 <b class="num">${readyCount}</b>/<span class="num">${total}</span></span>` : null}
|
||||
</div>`;
|
||||
}
|
||||
@@ -150,14 +244,18 @@ export function PauseButton({ paused, busy = false, onToggle }) {
|
||||
* @param {{ pub:any, priv:any, conn:any, hud:any, total:number|null, drawer:string|null, onExit:Function, onDrawer:(tab:string)=>void,
|
||||
* onReady:(r:boolean)=>void, readyBusy?:boolean, readyCount?:number, playerCount?:number,
|
||||
* pen?:boolean, penAvail?:boolean, onPen?:(on:boolean)=>void, config?: any, frozenAt?: number|null,
|
||||
* pause?: { show: boolean, paused: boolean, busy?: boolean, onToggle: () => void } | null }} props
|
||||
* pause?: { show: boolean, paused: boolean, busy?: boolean, onToggle: () => void } | null,
|
||||
* live?: { pending: number, unite: boolean } | null }} props
|
||||
* frozenAt: the server time every clock shows while the solo match is paused (null = live)
|
||||
* live: the own battle's pending LP loss (liveLp): the tower shows lp − pending in red with a −N tick, 联防中 during 联防
|
||||
*/
|
||||
export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {},
|
||||
config = null, frozenAt = null, pause = null }) {
|
||||
config = null, frozenAt = null, pause = null, live = null }) {
|
||||
const phase = pub?.phase;
|
||||
const boss = isBossPhase(phase);
|
||||
const lp = boss && Number.isFinite(pub?.teamLp) ? pub.teamLp : Number.isFinite(priv?.lp) ? priv.lp : null;
|
||||
// normal rounds: the leaks of the own battle so far (boss rounds: the team LP above already moves live)
|
||||
const pending = !boss && Number.isFinite(lp) && live && live.pending > 0 ? Math.min(lp, live.pending) : 0;
|
||||
const hidden = phase === PHASE.HIDDEN_CORE || (Number.isFinite(pub?.lastRound) && pub.round > pub.lastRound);
|
||||
const roundText = hidden ? '??' : pub?.round > 0 ? String(pub.round) : '--';
|
||||
const showReady = phase === PHASE.PREP && priv?.alive !== false;
|
||||
@@ -167,7 +265,8 @@ export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer,
|
||||
const now = Number.isFinite(frozenAt) ? frozenAt : serverNow();
|
||||
const ot = otLive ? overtimeState(pub, now, { perSec: overtimeDrainPerSec(config) }) : null;
|
||||
const draining = ot?.state === 'drain';
|
||||
const lowLp = (Number.isFinite(lp) && lp <= 5) || draining;
|
||||
const lowLp = (Number.isFinite(lp) && lp - pending <= 5) || draining;
|
||||
const cap = Number(config?.lpCapPerRound) > 0 ? Number(config.lpCapPerRound) : 10;
|
||||
const frozenSecs = Number.isFinite(frozenAt) ? remainAt(pub?.deadline, frozenAt) : null;
|
||||
const btn = checkButtons({ pen, penAvail, infoOpen: !!drawer });
|
||||
const onLeft = () => (pen ? onPen(false) : onDrawer('info'));
|
||||
@@ -191,7 +290,8 @@ export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer,
|
||||
<b class="roundbox__num num">${roundText}</b>
|
||||
</div>
|
||||
<${PhaseCapsule} pub=${pub} hud=${hud} />
|
||||
<${LpTower} value=${lp} size="lg" tone=${lowLp ? 'danger' : boss ? 'team' : null} />
|
||||
<${LpTower} value=${lp} size="lg" tone=${lowLp ? 'danger' : boss ? 'team' : null} pending=${pending}
|
||||
note=${pending > 0 && live?.unite ? '联防中' : null} tip=${pendingTip(lp, pending, { unite: !!live?.unite, cap })} />
|
||||
<${Tooltip} text=${btn.right.tip} placement="bottom">
|
||||
<${CheckBtn} sprite=${btn.right.sprite} cls=${cx('enemybtn', btn.right.grey && 'is-grey')} label=${btn.right.label}
|
||||
chev=${btn.right.grey ? null : '▶▶'} disabled=${btn.right.grey && !pen} onClick=${onRight} testid="check-enemy" />
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
// "前往查看" button under the row (when that teammate can be observed now; otherwise the reason is toasted through
|
||||
// onWatch); while observing, the own row shows a "返回战场" button. Without `observe` (server-run combat) a click
|
||||
// watches that player's field at once.
|
||||
// Live LP (user playtest #3 item 2): during a normal round's battle each row's tower shows lp − the loss that player's
|
||||
// leaks so far will cost (red, −N): the own row the top bar's live value (`self`, ui/hud.js liveLp), a teammate's row
|
||||
// m.public players[].pendingLp (server/match/Match.js, ~1 Hz).
|
||||
|
||||
import { useEffect, useState } from '../../vendor/hooks.module.js';
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
import { html, Icon, Tooltip } from './components.js';
|
||||
import { PlayerAvatar, LpTower, GIcon, LocalSprite } from './gameComponents.js';
|
||||
import { EmoteBubble } from './emotes.js';
|
||||
@@ -18,12 +22,29 @@ const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
/** Official status glyphs (ui/battle) per m.public players[].status; CSS glyph fallback. */
|
||||
const STATUS_SPRITE = { ready: 'icon_ready', deciding: 'icon_waiting', done: 'icon_complete', dead: 'icon_dead' };
|
||||
|
||||
/**
|
||||
* A row's LP tower: the settled LP and the pending loss of the round's battle (0 outside COMBAT / UNITE). The own row
|
||||
* takes the top bar's live value when there is one (its m.private lp: the same number as the top bar).
|
||||
* @param {any} p m.public players[] entry @param {any} pub m.public
|
||||
* @param {{ lp?: number|null, pending: number, unite: boolean } | null} [self] the own live value (only for the own row)
|
||||
* @returns {{ lp: number|null, pending: number, unite: boolean }}
|
||||
*/
|
||||
export function rowLp(p, pub, self = null) {
|
||||
const pubLp = Number.isFinite(p?.lp) ? p.lp : null;
|
||||
if ((pub?.phase !== PHASE.COMBAT && pub?.phase !== PHASE.UNITE) || p?.alive === false) return { lp: pubLp, pending: 0, unite: false };
|
||||
const lp = self && Number.isFinite(self.lp) ? self.lp : pubLp;
|
||||
if (lp == null) return { lp, pending: 0, unite: false };
|
||||
const raw = self ? self.pending : p.pendingLp;
|
||||
const pending = Math.min(lp, Math.max(0, Math.trunc(Number(raw) || 0)));
|
||||
return { lp, pending, unite: pending > 0 && (self ? !!self.unite : pub.phase === PHASE.UNITE) };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ pub:any, myId:string, watching:string|null, bubbles: Map<string,{id:string,seq:number}>, onWatch:(p:any)=>void,
|
||||
* compact?: boolean, teamLp?: number|null,
|
||||
* compact?: boolean, teamLp?: number|null, self?: { lp?: number|null, pending: number, unite: boolean } | null,
|
||||
* observe?: null | { canObserve: (p:any) => { fieldId?: string, reason?: string|null, back?: boolean }, observing: boolean, onBack: () => void } }} props
|
||||
*/
|
||||
export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = false, observe = null }) {
|
||||
export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = false, observe = null, self: selfLive = null }) {
|
||||
const [openPid, setOpenPid] = useState(null);
|
||||
const phaseKey = `${pub?.phase}:${pub?.round}`;
|
||||
useEffect(() => { setOpenPid(null); }, [phaseKey, watching, observe?.observing]);
|
||||
@@ -47,6 +68,7 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
|
||||
const open = !!observe && openPid === p.playerId && !self;
|
||||
const back = !!observe && self && observe.observing;
|
||||
const title = observe ? (self ? (observe.observing ? '返回战场' : '你自己') : `查看 ${p.name} 的战场`) : (self ? '查看自己的阵地' : `查看 ${p.name} 的阵地`);
|
||||
const lp = rowLp(p, pub, self ? selfLive : null);
|
||||
return html`<div key=${p.playerId} class=${cx('team__row', self && 'is-self', watched && 'is-watched', p.alive === false && 'is-dead', open && 'is-open')}>
|
||||
<button type="button" class="team__btn" onClick=${() => click(p, self)} title=${title} aria-expanded=${observe && !self ? String(open) : undefined}>
|
||||
<${PlayerAvatar} player=${p} self=${self} />
|
||||
@@ -57,7 +79,8 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
|
||||
<div class="team__info">
|
||||
<span class="team__name">${p.name || '博士'}</span>
|
||||
<div class="team__line">
|
||||
<${LpTower} value=${p.lp} size="sm" tone=${Number.isFinite(p.lp) && p.lp <= 5 ? 'danger' : null} />
|
||||
<${LpTower} value=${lp.lp} size="sm" tone=${Number.isFinite(lp.lp) && lp.lp - lp.pending <= 5 ? 'danger' : null} pending=${lp.pending}
|
||||
tip=${lp.pending > 0 ? `目标生命值 ${lp.lp},${lp.unite ? '联防中,' : ''}结算时扣除${lp.unite ? '至多' : ''} ${lp.pending} 点` : null} />
|
||||
<${Tooltip} text=${offline ? '连接已断开' : meta.text} placement="right">
|
||||
<span class=${cx('team__status', `is-${meta.tone}`, offline && 'is-offline', (offline || STATUS_SPRITE[status]) && localAsset('ui/battle', offline ? 'icon_lost_connect' : STATUS_SPRITE[status]) && 'has-sprite')} aria-label=${meta.text}>
|
||||
${offline ? html`<${LocalSprite} name="icon_lost_connect" fallback=${html`<${Icon} name="wifiOff" />`} />`
|
||||
|
||||
@@ -17,11 +17,18 @@
|
||||
// octagon plate: the sprite is used as a CSS mask over the plate colour (出售 amber like every money action, 销毁 /
|
||||
// 撤退 red) on a dark backing that shows through the glyph cut-out. Without the local sprites the same plate carries
|
||||
// the built-in glyph.
|
||||
//
|
||||
// TempRowNotice (user playtest #3 item 3) — the other board-anchored overlay of the prep: while the temp overflow row
|
||||
// (临时整备区, row 8, the 5 pads in front of the bench) holds pieces, a dashed red frame around that row and a label at
|
||||
// its end say that they block 准备就绪 and are destroyed when the prep ends unless moved to the bench, equipped or used.
|
||||
// Placed through view.tileScreen (useTileScreen, so it follows the camera — the Final Assault prep too); it never takes
|
||||
// the pointer, so the pieces under it stay draggable.
|
||||
|
||||
import { html, HexBadge } from './components.js';
|
||||
import { html, HexBadge, Icon } from './components.js';
|
||||
import { GIcon } from './gameComponents.js';
|
||||
import { useTileScreen } from './facingWheel.js';
|
||||
import { localAsset } from '../data.js';
|
||||
import { GEO } from '../../../shared/constants.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
|
||||
@@ -100,3 +107,68 @@ export function Underframe({ view, uid = null, row, col, actions, name = '', bus
|
||||
</button>` : null}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---- the temp overflow row (临时整备区) ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Screen frame of the temp row from its two end tiles (view.tileScreen of cols TEMP_C0 and TEMP_C0 + TEMP_SIZE − 1): the
|
||||
* quad [back-left, back-right, front-right, front-left] (back = the far edge, higher on screen — also when the Final
|
||||
* Assault prep mirrors the board), its bounds, and the side of the label: left of the row, or right of it when the
|
||||
* left has no room for `labelW` px. Pure (test/ui/playtest3.test.js).
|
||||
* @param {{ poly: Array<[number, number]> } | null} a @param {{ poly: Array<[number, number]> } | null} b
|
||||
* @param {{ labelW?: number, gap?: number, vw?: number }} [opts]
|
||||
* @returns {{ quad: Array<[number, number]>, left: number, right: number, top: number, bottom: number, y: number, side: 'left'|'right' } | null}
|
||||
*/
|
||||
export function tempRowFrame(a, b, { labelW = 0, gap = 8, vw = Infinity } = {}) {
|
||||
const ok = (t) => t && Array.isArray(t.poly) && t.poly.length >= 4 && t.poly.every((p) => Array.isArray(p) && Number.isFinite(p[0]) && Number.isFinite(p[1]));
|
||||
if (!ok(a) || !ok(b)) return null;
|
||||
const backs = [a.poly[0], a.poly[1], b.poly[0], b.poly[1]];
|
||||
const fronts = [a.poly[2], a.poly[3], b.poly[2], b.poly[3]];
|
||||
const minX = (pts) => pts.reduce((m, p) => (p[0] < m[0] ? p : m));
|
||||
const maxX = (pts) => pts.reduce((m, p) => (p[0] > m[0] ? p : m));
|
||||
const quad = [minX(backs), maxX(backs), maxX(fronts), minX(fronts)];
|
||||
const xs = quad.map((p) => p[0]);
|
||||
const ys = quad.map((p) => p[1]);
|
||||
const left = Math.min(...xs), right = Math.max(...xs), top = Math.min(...ys), bottom = Math.max(...ys);
|
||||
const side = left - gap - labelW >= 0 || right + gap + labelW > vw ? 'left' : 'right';
|
||||
return { quad, left, right, top, bottom, y: (top + bottom) / 2, side };
|
||||
}
|
||||
|
||||
/**
|
||||
* What the temp row's label says will happen to its pieces (server/match/PlayerState.js tempDue): a piece is resolved
|
||||
* at the end of the first prep in which the player can act on it. Not ready (or outside PREP): at the end of this / the
|
||||
* coming prep, and 准备就绪 waits for the row to be cleared. Ready in PREP: Ready is refused while the row holds pieces,
|
||||
* so whatever lies there arrived after it — kept through the NEXT prep (cancelling Ready makes it due at this one).
|
||||
* @param {boolean} ready m.private ready (during PREP)
|
||||
*/
|
||||
export function tempRowRule(ready) {
|
||||
return ready
|
||||
? '已准备就绪后进入的单位保留到下个休整期,届时仍在此处的将被销毁(取消准备则在本休整期结束时销毁)'
|
||||
: '放入整备区或战场、配发或使用后才能准备;休整期结束时仍在此处的将被销毁';
|
||||
}
|
||||
|
||||
/**
|
||||
* The temp row's notice: dashed red frame + label (count, what to do, what happens at the end of the prep — tempRowRule).
|
||||
* `label` false (a piece is being dragged / placed): the frame only, nothing over the pieces the player is moving.
|
||||
* @param {{ view: any, count: number, items?: number, label?: boolean, ready?: boolean }} props
|
||||
*/
|
||||
export function TempRowNotice({ view, count, items = 0, label = true, ready = false }) {
|
||||
const a = useTileScreen(view, GEO.TEMP_ROW, GEO.TEMP_C0);
|
||||
const b = useTileScreen(view, GEO.TEMP_ROW, GEO.TEMP_C0 + GEO.TEMP_SIZE - 1);
|
||||
let rem = 100;
|
||||
try { rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 100; } catch { /* default */ }
|
||||
const vw = globalThis.innerWidth || 1920;
|
||||
const f = count > 0 ? tempRowFrame(a, b, { labelW: rem * 2.9, gap: rem * 0.1, vw }) : null;
|
||||
if (!f) return null;
|
||||
const pts = f.quad.map((p) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
|
||||
const pad = rem * 0.1;
|
||||
const style = f.side === 'left' ? `left:${(f.left - pad).toFixed(1)}px;top:${f.y.toFixed(1)}px` : `left:${(f.right + pad).toFixed(1)}px;top:${f.y.toFixed(1)}px`;
|
||||
const what = items > 0 && items === count ? '件道具' : '个单位';
|
||||
return html`<div class="tempnote" aria-hidden="false" data-testid="temp-notice">
|
||||
<svg class="tempnote__frame" aria-hidden="true"><polygon points=${pts} /></svg>
|
||||
${label ? html`<div class=${`tempnote__label is-${f.side}`} style=${style} role="status">
|
||||
<b class="tempnote__title"><${Icon} name="warn" />临时整备区 <span class="num">${count}</span> ${what}待处理</b>
|
||||
<span class="tempnote__rule">${tempRowRule(ready)}</span>
|
||||
</div>` : null}
|
||||
</div>`;
|
||||
}
|
||||
Reference in new issue
Block a user