Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar

- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player;
  placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call)
- #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿
- #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text
- #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool,
  and the team LP reaching 0 first is a defeat (PRTS)
- #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones
- #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10)
- #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage
  triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range
- #9 official per-enemy model scales (威龙)
- #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding
- #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider
- #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes
- #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20
- #18 炎佑 is 孤立: no heals or ally buffs
- #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators
- DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites
  green; dev recordings regenerated
This commit is contained in:
sganggs committed 2026-10-01 19:08:23 +08:00
1 parent e6628f0cfe
commit 6464e4b46a
138 files changed
+7946 -1379

No files matched your search

+41 -13
View File
@@ -7,7 +7,12 @@
// 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).
// m.public players[].pendingLp (server/match/Match.js, ~1 Hz). 联防 (user playtest #6 item 7): a leaker's row adds the
// runner tag ×N — its enemies still standing on the 联防 field, uncapped and live (falling as the helpers kill them,
// rising when one splits) — next to lp − min(lpCapPerRound, N): the own row the top bar's value, a teammate's row the
// local 联防 replica's count while it is on screen (`uniteLocal`: the battle runner's state().uniteLeft, the same battle
// the player watches, so the number moves with the kills on screen), else m.public players[].uniteLeft (the authority's
// report, ~1 Hz).
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { PHASE } from '../../../shared/constants.js';
@@ -15,6 +20,7 @@ import { html, Icon, Tooltip } from './components.js';
import { PlayerAvatar, LpTower, GIcon, LocalSprite } from './gameComponents.js';
import { EmoteBubble } from './emotes.js';
import { STATUS_META, sortedPlayers } from './gameLogic.js';
import { MissTag, uniteRemaining } from './hud.js';
import { localAsset } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -24,27 +30,48 @@ const STATUS_SPRITE = { ready: 'icon_ready', deciding: 'icon_waiting', done: 'ic
/**
* 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).
* takes the top bar's live value when there is one (its m.private lp: the same number as the top bar). `left`: during
* 联防 a leaker's enemies still standing (the ×N tag), else null — for a teammate the local 联防 replica's count when
* one is given (`uniteLocal`, the runner's state().uniteLeft: { [playerId]: n }, absent = none left; the pending loss
* is then min(cap, left)), else m.public players[].uniteLeft / pendingLp.
* @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 }}
* @param {{ lp?: number|null, pending: number, unite: boolean, left?: number|null } | null} [self] the own live value (only for the own row)
* @param {{ uniteLocal?: Record<string, number> | null, cap?: number }} [opts]
* @returns {{ lp: number|null, pending: number, unite: boolean, left: number|null }}
*/
export function rowLp(p, pub, self = null) {
export function rowLp(p, pub, self = null, { uniteLocal = null, cap = 10 } = {}) {
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 };
if ((pub?.phase !== PHASE.COMBAT && pub?.phase !== PHASE.UNITE) || p?.alive === false) return { lp: pubLp, pending: 0, unite: false, left: null };
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 leaker = pub.phase === PHASE.UNITE && Array.isArray(pub.unite?.leakers) && pub.unite.leakers.includes(p.playerId);
const local = !self && leaker && uniteLocal && typeof uniteLocal === 'object' ? uniteRemaining(uniteLocal[p.playerId] ?? 0, null) : null;
const rawLeft = pub.phase !== PHASE.UNITE ? null : self ? self.left : local != null ? local : p.uniteLeft;
const left = Number.isFinite(rawLeft) && rawLeft >= 0 ? Math.trunc(rawLeft) : null;
if (lp == null) return { lp, pending: 0, unite: false, left };
const raw = self ? self.pending : local != null ? Math.min(cap, local) : 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) };
return { lp, pending, unite: (pending > 0 || left != null) && (self ? !!self.unite : pub.phase === PHASE.UNITE), left };
}
/**
* Tooltip of a row's LP tower with a pending loss (null without one).
* @param {{ lp: number|null, pending: number, unite: boolean, left: number|null } | null} lp rowLp(...)
* @param {number} [cap] lpCapPerRound
*/
export function rowLpTip(lp, cap = 10) {
if (!lp || !(lp.pending > 0)) return null;
if (lp.unite && lp.left != null) return `目标生命值 ${lp.lp},联防中:漏过的敌人还剩 ${lp.left} 个,按现在结算扣除 ${lp.pending} 点(每回合至多 ${cap} 点)`;
return `目标生命值 ${lp.lp},${lp.unite ? '联防中,' : ''}结算时扣除${lp.unite ? '至多' : ''} ${lp.pending} 点`;
}
/**
* @param {{ pub:any, myId:string, watching:string|null, bubbles: Map<string,{id:string,seq:number}>, onWatch:(p:any)=>void,
* compact?: boolean, teamLp?: number|null, self?: { lp?: number|null, pending: number, unite: boolean } | null,
* compact?: boolean, teamLp?: number|null, self?: { lp?: number|null, pending: number, unite: boolean, left?: number|null } | null,
* cap?: number, uniteLocal?: Record<string, number> | null,
* observe?: null | { canObserve: (p:any) => { fieldId?: string, reason?: string|null, back?: boolean }, observing: boolean, onBack: () => void } }} props
* uniteLocal: the local 联防 replica's per-leaker counts while it is on screen (battle runner state().uniteLeft), else null
*/
export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = false, observe = null, self: selfLive = null }) {
export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = false, observe = null, self: selfLive = null, cap = 10, uniteLocal = null }) {
const [openPid, setOpenPid] = useState(null);
const phaseKey = `${pub?.phase}:${pub?.round}`;
useEffect(() => { setOpenPid(null); }, [phaseKey, watching, observe?.observing]);
@@ -68,7 +95,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);
const lp = rowLp(p, pub, self ? selfLive : null, { uniteLocal, cap });
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} />
@@ -80,7 +107,8 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
<span class="team__name">${p.name || '博士'}</span>
<div class="team__line">
<${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} />
tip=${rowLpTip(lp, cap)} />
${lp.left != null ? html`<${MissTag} n=${lp.left} name=${self ? null : p.name || '博士'} />` : 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" />`} />`