Files
ark-stronghold-protocol/public/js/screens/room.js
T
sganggs a0a7660287 feedback5 WM: merge feedback4 (896dd5d, the coming 0.1.4) — PRs #150 #66 #146 #74 #72 #103 #149 #109 #160 ported into the split layout
Ported by hand:
- #109 kit hunks → kits/ops (the tier files stay re-export shims): 宴 chess_char_1_18-utage.js, 野鬃
  chess_char_1_19-wildmn.js, 砾 chess_char_2_12-gravel.js, 斯卡蒂 chess_char_3_05-skadi.js, 伊内丝
  chess_char_4_04-ines.js, 缄默德克萨斯 chess_char_4_16-texas2.js, 耀骑士临光 chess_char_6_17-nearl2.js. The +/- lines
  are identical to #109's tier1/2/3/4/6 diffs.
- #109 generic.js against W5D's own-duration passives (一击即退): the deployment window is computed where #109 computes
  it, with W5D's case included. Both forms run #109's duration lifecycle (activateOnDeploy, spType 'none', the
  skill's own mods), and W5D's 冲锋号令 DP start effect is kept.
- #103 room.js through i18n phase 1: t('{name}邀请你加入卫戍协议:盟约【{difficulty}】') with the translated difficulty
  name (the Chinese text is the PR's), plus its English line in public/i18n/en.json.
- data/assets.json: feedback5's manifest plus #72's audio.bgm.combatAlts (after boss, the plan order). files +3 and
  bytes +5 037 595 as measured on feedback4; the hash is recomputed with the generator's contentHash. No other data/
  file changed.
- docs: DESIGN §5.6 (#109's timed deployment paragraph before feedback5's trigger rules; "Permanent passive skills are
  always on"), SIM §7.4 (both generic passive cases).
- Auto-merged and checked: #149 bond-bar fold (still in screens/game.js), #160 skills.js / spine.js, #109 skills.js /
  snapshot.js, #66 addon/battle.js (restTile / isDown live in sim/battle/tiles.js), #150 units.js, #72 audio.js /
  plan.mjs, #74 plan.mjs / docs.

Where the PRs met feedback5 work:
- 突袭 (W5L, GitHub #49): raidPoll counts a skill that needs no SP while it is on. That is a passive, as before, or a
  #109 deploy-timed skill while its window runs. Once the window has ended only the idle trigger is left. Without this
  change 缄默德克萨斯 / 宴 lost #49. research 02 §3.18 says so.
- Tests changed to #109's representation: bonds_addon #49 test (kind 'duration', activateOnDeploy) plus a new test
  (window over → idle trigger only), and standin.test.js's 预备干员 PASSIVE branch (the skill's mods instead of the
  generic:passive buff).

Golden regenerated on the merged code. Attribution: each PR's gameplay change was reverted alone in a scratch copy, and
all three together. With all three reverted the digests equal feedback5's exactly. Every moved scenario was replayed
and fields the unit that explains it.
- #160 only (0.5 s skill events of deploy passives; events.skill only): roster-001/006/025/049, bond-arcaneShip-low,
  bond-emptyShip-high, bond-investShip-high, bond-kjeragShip-low, bond-siracusaShip-low/high, bond-victoriaShip-low,
  bond-yanShip-high, boss-boss_2/4/6-solo, hidden-boss_8/10-solo, standin-03/05/08/10, diy-005/006 (普罗旺斯 S1,
  琳琅诗怀雅 S1/S2, 星熊 S2, 凯瑟琳 S1, Misery S3 as stand-in / 自选 prototype).
- #109 only (deploy-timed skill state and lifecycle; 突袭 技能就绪 limited to the window; generic 一击即退):
  roster-003/004/005/021/023/024/039/041/042/046/047, bond-egirShip-high, bond-indomShip-high,
  bond-kazimierzShip-low/high, hidden-boss_8-pair, standin-01, coop4-FUNNY-6, coop2-NORMAL-8-serverrun.
- #66 only (灵巧's aura stays only for a killed member, from its body tile): roster-028, coop2-NORMAL-3,
  coop3-ABYSS-5, coop4-ABYSS-7, solo-HARD-9-boosted.
- #109 + #160: roster-002, roster-029, bond-investShip-low, bond-raidShip-low/high, bond-suntShip-low,
  bond-visiShip-high.
- #66 + #160: roster-008, roster-027.
- #66 + #109: roster-022, roster-040, bond-indomShip-low, boss-boss_2/4/6-pair, hidden-boss_10-pair, standin-06,
  coop2-HARD-4, coop2-NORMAL-14-standins.
- all three: roster-010, roster-012, roster-031.
69 scenarios. Every other digest is unchanged.
2026-10-05 23:59:05 +08:00

336 lines
19 KiB
JavaScript

// Room screen (同盟等待室): 4 seat cards (avatar frame, name, ready state, AI badge, host crown),
// host controls (difficulty picker, add/remove AI in co-op, start), invite code with copy code /
// copy link, ready toggle and leave.
//
// Start rule (server/lobby.js): room.start needs every *other* human connected and ready; the
// host's start counts as the host's ready. So 开始模拟 is enabled exactly then and sends room.start
// alone (no separate room.ready round trip that could leave the host "ready" after a failed start).
// Solo rooms show a single seat.
// Spectator seats (community report #26, a remake feature): a co-op room with spectators shows the 观战席 strip under
// the seats — names, offline marks, the host's ✕ (room.removeSpectator) — and a spectator's own view swaps the ready
// button for 观战中 and offers 入座 (room.join of the room) while a player seat is free.
// Texts go through t() (docs/I18N.md).
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, MAX_SEATS, MAX_SPECTATORS } from '../../../shared/constants.js';
import {
html, Button, Icon, MicroLabel, PingPill, AvatarFrame, DifficultyTag, DifficultyIcon, Tooltip, confirmDialog, doctorNo,
} from '../ui/components.js';
import { toast, toastError } from '../ui/toasts.js';
import { copyText } from '../ui/clipboard.js';
import { GuideButton } from '../ui/guide.js';
import { LoadoutButton } from './loadout.js';
import { net } from '../net.js';
import { store, useStore, shallowEqual, emptyMatch, isSpectating } from '../store.js';
import { difficultyInfo } from './lobby.js';
import { t, tc } from '../../../shared/i18n.js';
/**
* Seats padded to the room's capacity (co-op 4, solo 1), each null or a seat record.
* @param {any} room room.state payload
* @returns {(null | {seat:number, playerId:any, name:string, isBot:boolean, ready:boolean, connected:boolean})[]}
*/
export function normalizeSeats(room) {
const cap = room?.mode === 'solo' ? 1 : MAX_SEATS;
const src = Array.isArray(room?.seats) ? room.seats : [];
const out = [];
for (let i = 0; i < cap; i++) {
const s = src[i];
out.push(s && typeof s === 'object' ? { ...s, seat: Number.isInteger(s.seat) ? s.seat : i } : null);
}
return out;
}
/**
* Derived room facts for the local player.
* @param {any} room
* @param {any} myId
*/
export function roomFacts(room, myId) {
const seats = normalizeSeats(room);
const occupied = seats.filter(Boolean);
const humans = occupied.filter((s) => !s.isBot);
const mine = occupied.find((s) => s.playerId === myId) || null;
const isHost = room?.hostId != null && room.hostId === myId;
const others = humans.filter((s) => s.playerId !== myId);
// The host never readies: starting the match is the host's ready (server rule), so the count
// treats the host as ready — "已就绪 0/1" next to "准许进入模拟" would contradict itself.
const isReady = (s) => !!s.ready || s.playerId === room?.hostId;
const readyHumans = humans.filter(isReady).length;
const othersReady = others.every((s) => s.ready && s.connected !== false);
return {
seats, occupied, humans, mine, isHost, readyHumans, isReady,
emptySeats: seats.filter((s) => !s).length,
canStart: isHost && othersReady && !!mine,
othersReady,
// spectator seats (never players: not in `humans`, never counted for ready / start)
spectators: Array.isArray(room?.spectators) ? room.spectators.filter((s) => s && typeof s === 'object') : [],
spectating: isSpectating(room, myId),
};
}
/** Invite link for a room code (current page URL with ?room=CODE). */
export function inviteLink(code) {
const loc = globalThis.location;
const base = loc ? `${loc.origin}${loc.pathname}` : '';
return `${base}?room=${encodeURIComponent(code)}`;
}
/**
* Copy text to the clipboard (async API with a textarea fallback for insecure contexts). Moved to ui/clipboard.js so
* 干员调配 can use it without importing this screen (which imports loadout.js): re-exported here for existing callers.
* @param {string} text
* @returns {Promise<boolean>}
*/
export { copyText };
function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot, onKick }) {
const coop = room.mode !== 'solo';
if (!seat) {
const canAdd = coop && facts.isHost;
return html`<article class="seat seat--empty" style=${`--seat-i:${index}`}>
<header class="seat__head"><span class="seat__no num">P${index + 1}</span><${MicroLabel}>SEAT ${String(index + 1).padStart(2, '0')}<//></header>
<div class="seat__art seat__art--empty">
<div class="seat__radar" aria-hidden="true"></div>
<span class="seat__wait">${t('等待博士加入')}</span>
<${MicroLabel}>AWAITING DOCTOR<//>
</div>
<footer class="seat__foot">
${canAdd
? html`<${Button} variant="secondary" size="sm" icon="robot" block=${true} loading=${busy === `add`} onClick=${onAddBot}>${t('添加 AI 队友')}<//>`
: html`<span class="seat__state t-dim">${t('空位')}</span>`}
</footer>
</article>`;
}
const isMe = seat.playerId === myId;
const isHostSeat = seat.playerId === room.hostId;
const offline = seat.connected === false && !seat.isBot;
// The host never needs to toggle ready: starting the match readies them (server rule).
const state = offline ? 'offline' : seat.ready || seat.isBot ? 'ready' : isHostSeat ? 'host' : 'waiting';
return html`<article class=${`seat brackets${isMe ? ' is-me' : ''}${isHostSeat ? ' is-host' : ''}${seat.isBot ? ' is-bot' : ''} is-${state}`}
style=${`--seat-i:${index}`}>
<header class="seat__head">
<span class="seat__no num">P${index + 1}</span>
<${MicroLabel}>SEAT ${String(index + 1).padStart(2, '0')}<//>
${isHostSeat ? html`<span class="seat__host"><${Icon} name="crown" />${t('创建者')}</span>` : null}
</header>
<div class="seat__art">
<div class="seat__stripes" aria-hidden="true"></div>
<${AvatarFrame} size="xl" name=${seat.name} seat=${index} bot=${seat.isBot} self=${isMe} ready=${state === 'ready'} offline=${offline} />
${seat.isBot ? html`<span class="seat__bot-label"><${Icon} name="robot" />${t('AI 队友')}</span>` : null}
</div>
<div class="seat__who">
<span class="seat__name">${seat.name || t('博士')}</span>
${isMe ? html`<span class="seat__you">${t('你')}</span>` : null}
</div>
<${MicroLabel}>${seat.isBot ? 'AUTONOMOUS UNIT' : `DOCTOR #${doctorNo(seat.playerId)}`}<//>
<footer class="seat__foot">
<span class=${`seat__state seat__state--${state}`}>
${state === 'ready' ? html`<${Icon} name="check" />${t('已就绪')}`
: state === 'offline' ? html`<${Icon} name="wifiOff" />${t('连接中断')}`
: state === 'host' ? html`<${Icon} name="crown" />${t('待命中')}`
: html`<${Icon} name="hourglass" />${t('准备中')}`}
</span>
${seat.isBot && facts.isHost ? html`<${Tooltip} text=${t('移除该 AI 队友')}>
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rm${index}`} onClick=${() => onRemoveBot(index)} aria-label=${t('移除 AI 队友')} />
<//>` : null}
${!seat.isBot && !isMe && facts.isHost ? html`<${Tooltip} text=${t('将该博士移出同盟')}>
<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `kick${index}`} onClick=${() => onKick(index, seat.name, seat.playerId)} aria-label=${t('移出该博士')} />
<//>` : null}
</footer>
</article>`;
}
/** 观战席: the room's spectators (host: ✕ frees a seat), and 入座 for a spectator while a player seat is free. */
function SpectatorBar({ facts, myId, busy, onRemove, onSit }) {
if (!facts.spectators.length) return null;
return html`<section class="specbar" aria-label=${t('观战席')}>
<span class="specbar__label"><${Icon} name="eye" />${t('观战席')}<b class="num">${facts.spectators.length}</b><span class="num t-dim">/${MAX_SPECTATORS}</span></span>
${facts.spectators.map((s) => html`<span key=${s.playerId} class=${`specbar__who${s.playerId === myId ? ' is-me' : ''}${s.connected === false ? ' is-offline' : ''}`}>
${s.connected === false ? html`<${Icon} name="wifiOff" />` : null}${s.name || t('博士')}${s.playerId === myId ? html`<span class="seat__you">${t('你')}</span>` : null}
${facts.isHost ? html`<${Button} variant="ghost" size="sm" square=${true} icon="close" loading=${busy === `rs${s.playerId}`}
onClick=${() => onRemove(s.playerId)} aria-label=${t('移出观战者 {name}', { name: s.name || '' })} title=${t('移出该观战者')} />` : null}
</span>`)}
${facts.spectating && facts.emptySeats > 0 ? html`<${Button} size="sm" icon="user" loading=${busy === 'sit'} onClick=${onSit}>${t('入座')}<//>` : null}
</section>`;
}
function InviteBox({ code, name, difficulty }) {
const copy = async (what) => {
// the copied link carries an invitation line (#103): 「{name}邀请你加入卫戍协议:盟约【{difficulty}】」
const invite = t('{name}邀请你加入卫戍协议:盟约【{difficulty}】', { name: name ?? '', difficulty: DIFFICULTY_NAMES[difficulty] ? t(DIFFICULTY_NAMES[difficulty]) : '' });
const ok = await copyText(what === 'code' ? code : `${inviteLink(code)} ${invite}`);
if (ok) toast(what === 'code' ? t('已复制同盟密钥 {code}', { code }) : t('已复制邀请链接'), 'success');
else toast(t('复制失败,请手动复制'), 'warn');
};
return html`<div class="invite brackets">
<div class="invite__label"><${Icon} name="key" /><span>${t('同盟密钥')}</span><${MicroLabel}>ALLIANCE KEY<//></div>
<div class="invite__code num selectable" aria-label=${t('同盟密钥 {code}', { code })}>${[...String(code)].map((ch, i) => html`<span key=${i}>${ch}</span>`)}</div>
<div class="invite__btns">
<${Button} size="sm" icon="copy" onClick=${() => copy('code')}>${t('复制密钥')}<//>
<${Button} size="sm" icon="link" onClick=${() => copy('link')}>${t('复制链接')}<//>
</div>
</div>`;
}
function DifficultyPicker({ room, isHost, busy, onPick }) {
if (!isHost) {
return html`<div class="dpick dpick--ro">
<${DifficultyTag} difficulty=${room.difficulty} size="lg" code=${difficultyInfo(room.mode, room.difficulty).code} />
<span class="t-dim">${t('由创建者选择')}</span>
</div>`;
}
return html`<div class="dpick" role="radiogroup" aria-label=${t('模拟难度')}>
${DIFFICULTIES.map((d) => html`<button key=${d} type="button" role="radio" aria-checked=${room.difficulty === d ? 'true' : 'false'}
class=${`dpick__opt${room.difficulty === d ? ' is-active' : ''}`} style=${`--d-color:${DIFFICULTY_COLORS[d]}`}
disabled=${!!busy} onClick=${() => room.difficulty !== d && onPick(d)}>
<${DifficultyIcon} difficulty=${d} />${t(DIFFICULTY_NAMES[d].replace('模拟', ''))}
</button>`)}
</div>`;
}
/** Room screen component. */
export function RoomScreen() {
const room = useStore((s) => s.room);
const me = useStore((s) => s.me, shallowEqual);
const conn = useStore((s) => s.connection, shallowEqual);
const [busy, setBusy] = useState(null);
const alive = useRef(true);
const inFlight = useRef(false); // synchronous guard against double clicks (state updates are async)
useEffect(() => () => { alive.current = false; }, []);
if (!room) return null;
const online = conn.status === 'online';
const coop = room.mode !== 'solo';
const facts = roomFacts(room, me.playerId);
const myReady = !!facts.mine?.ready;
const info = difficultyInfo(room.mode, room.difficulty);
const run = async (kind, fn) => {
if (inFlight.current) return;
if (!online) { toast(t('连接中断,请稍候重试'), 'warn'); return; }
inFlight.current = true;
setBusy(kind);
try { await fn(); } catch (err) { toastError(err); } finally {
inFlight.current = false;
if (alive.current) setBusy(null);
}
};
const toggleReady = () => run('ready', () => net.request('room.ready', { ready: !myReady }));
const start = () => run('start', () => net.request('room.start', {}));
const addBot = () => run('add', () => net.request('room.addBot', {}));
const removeBot = (seat) => run(`rm${seat}`, () => net.request('room.removeBot', { seat }));
// the host removes a human before the match (community report #17): asked first; the player may join again. The
// confirmed player's id goes along: if they left and someone else took the seat meanwhile, the server refuses it.
const kick = async (seat, name, playerId) => {
if (inFlight.current) return;
const ok = await confirmDialog({ title: t('移出同盟'), text: t('确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。', { name: name || t('博士') }), okText: t('移出'), danger: true });
if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat, playerId }));
};
const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty }));
// spectator seats: the host frees one; a spectator takes a free player seat with room.join of this room
const removeSpectator = (playerId) => run(`rs${playerId}`, () => net.request('room.removeSpectator', { playerId }));
const sit = () => run('sit', () => net.request('room.join', { code: room.code }));
const leave = async () => {
if (inFlight.current) return;
const othersHere = facts.humans.some((s) => s.playerId !== me.playerId);
if (facts.isHost && othersHere) {
const ok = await confirmDialog({ title: t('离开同盟'), text: t('你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?'), okText: t('离开'), danger: true });
if (!ok) return;
}
inFlight.current = true;
setBusy('leave');
try {
await net.request('room.leave', {});
} catch (err) {
if (err?.code !== 'NOT_IN_ROOM') toastError(err);
} finally {
// Leaving locally is always safe: the server either confirmed or no longer has us in the room.
store.set({ room: null, match: emptyMatch() });
inFlight.current = false;
if (alive.current) setBusy(null);
}
};
const statusLine = !online
? html`<span class="t-orange"><${Icon} name="wifiOff" />${t('连接中断,正在重连…')}</span>`
: facts.spectating
? html`<span class="t-lo"><${Icon} name="eye" />${t('观战中 · 不占博士席位,模拟开始后可切换观看各位博士')}</span>`
: !coop
? html`<span class="t-mint">${t('*模拟协议已就绪,准许进入模拟')}</span>`
: facts.isHost
? facts.canStart
? html`<span class="t-mint">${t('*同盟人数达标,准许进入模拟')}</span>`
: html`<span class="t-lo">${t('等待所有博士准备就绪')}</span>`
: myReady
? html`<span class="t-mint">${t('已就绪 · 等待创建者开始模拟')}</span>`
: html`<span class="t-lo">${t('准备就绪后,创建者即可开始模拟')}</span>`;
return html`<div class="screen room-screen">
<header class="topbar">
<div class="topbar__left">
<${Tooltip} text=${t('离开同盟')} placement="bottom">
<${Button} variant="danger" size="lg" square=${true} icon="exit" loading=${busy === 'leave'} onClick=${leave} aria-label=${t('离开同盟')} />
<//>
<div class="room-ping">
<${PingPill} ms=${conn.ping} online=${online} />
<${MicroLabel}>${t('当前延迟')}<//>
</div>
<${GuideButton} class="room-guide" variant="secondary" label=${t('玩法说明')} />
</div>
<div class="topbar__center">
<${MicroLabel} tone="mint">${coop ? 'ALLIANCE LOBBY' : 'SOLO SIMULATION'}<//>
<h1 class="topbar__title">${coop ? t('同盟模拟') : t('独立模拟')}<span class="topbar__sep"></span><${DifficultyTag} difficulty=${room.difficulty} size="lg" /></h1>
</div>
<div class="topbar__right">
${coop ? html`<${InviteBox} code=${room.code} name=${me.name} difficulty=${room.difficulty} />` : html`<div class="solo-note"><${MicroLabel}>SINGLE OPERATOR<//><span>${t('仅限 1 名博士')}</span></div>`}
</div>
</header>
<main class=${`seats${coop ? '' : ' seats--solo'}`}>
${facts.seats.map((s, i) => html`<${SeatCard} key=${s ? `p${s.playerId}` : `e${i}`} seat=${s} index=${i} room=${room} facts=${facts}
myId=${me.playerId} busy=${busy} onAddBot=${addBot} onRemoveBot=${removeBot} onKick=${kick} />`)}
${coop ? null : html`<aside class="solo-brief brackets">
<${MicroLabel} tone="mint">BRIEFING<//>
<h2>${DIFFICULTY_NAMES[room.difficulty] ? t(DIFFICULTY_NAMES[room.difficulty]) : ''}<span class="num t-dim"> ${info.code}</span></h2>
<p>${info.desc}</p>
<ul>
${info.effects.map((e) => html`<li key=${e}>${e}</li>`)}
<li>${t('共')} <b class="num">${info.rounds}</b> ${tc('rounds', '回合')}${info.hidden ? t(',满足条件时进入隐秘核心') : ''}</li>
<li>${t('独立模拟中休整期与机变阶段不限时')}</li>
</ul>
</aside>`}
</main>
<${SpectatorBar} facts=${facts} myId=${me.playerId} busy=${busy} onRemove=${removeSpectator} onSit=${sit} />
<footer class="room-bar">
<div class="room-bar__left">
<span class="room-bar__label">${t('模拟难度')}<${MicroLabel}>DIFFICULTY<//></span>
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
</div>
<div class="room-bar__center">
<div class="ready-count" hidden=${!coop}>
<span class="t-lo">${t('已就绪')}</span>
<b class="num">${facts.readyHumans}</b><span class="num t-dim">/${facts.humans.length}</span>
<span class="ready-count__icons" aria-hidden="true">
${facts.humans.map((s) => html`<${Icon} key=${s.playerId} name="user" class=${facts.isReady(s) ? 'is-on' : ''} />`)}
</span>
</div>
<div class="room-bar__status">${statusLine}</div>
</div>
<div class="room-bar__right">
<${LoadoutButton} from="room" size="lg" class="room-loadout" label=${t('干员调配')} />
${facts.isHost
? html`<${Tooltip} text=${facts.canStart ? null : t('仍有博士未准备就绪')}>
<${Button} variant="primary" size="xl" icon="play" loading=${busy === 'start'} disabled=${!facts.canStart || !online} onClick=${start}>${t('开始模拟')}<//>
<//>`
: facts.spectating
? html`<${Button} variant="secondary" size="xl" icon="eye" disabled=${true}>${t('观战中')}<//>`
: html`<${Button} variant=${myReady ? 'primary' : 'secondary'} size="xl" icon=${myReady ? 'check' : 'hourglass'} active=${myReady}
loading=${busy === 'ready'} disabled=${!online || !facts.mine} onClick=${toggleReady}>${myReady ? t('已就绪') : t('准备就绪')}<//>`}
</div>
</footer>
</div>`;
}