feedback5 CB5: an eliminated player (and a spectator seat) follows a player through every phase reset — the one it last watched, else the first player still in — instead of being sent back to its own empty board each round (community report of 2026-10-06, item 56; the idea of PR #189)
Official: research 09 §3.1 — after elimination the client keeps observing (self_dead_dialog KEEP_WATCH,
DeadAutoObDn { obIndex, state, preparation }, FindFirstAvailObTarget). Until 0.2.0 every phase reset cleared
an eliminated player's watch: in 回合开始 / 机变 / 休整期 it saw its own empty board, in battle the first field.
Server (server/match/match/watch.js): Match.watchPref (viewer → the player of its last manual g.watch: a prep
scout, a single-player field, or the new optional g.watch playerId on a shared 联防 / boss pair field — PR
#189's review point); _watchTargetOf = that player while still in, else the first player still in (seat
order, AI seats included); startRound scouts its board (_followScout, kept through 机变 and 休整期), both
combat modes and the boss fields start on its field, a resync / a spectator joining in prep gets the board,
a followed player who quits hands over at once. Client: the screen adopts the pushed board like a 前往查看 tap
(battle/observe.js followedScout; the stale-field fix of PR #189), keeps it through the prep phases (no camera
flight home and back), a spectator no longer asks for the first player itself; g.watch sends the tapped row.
Overlap with PR #189 (not merged): the same preference chain; differences — the fallback includes AI seats
(a lone human with AI teammates had nobody to follow), shared fields record the tapped playerId.
Golden unchanged. Tests: test/match/feedback5-watch-follow.test.js (new, 9), spectator.test.js updated (the
spectator follows the player it scouted). Docs: design/network.md, PLAYING.md.
This commit is contained in:
1 parent
464a823bc5
commit
e8d2b45ad4
14 files changed
+330
-47
No files matched your search
+38
-18
@@ -106,7 +106,7 @@ import { ResultScreen } from './result.js';
|
||||
import { net } from '../net.js';
|
||||
import { store, useStore, shallowEqual, serverNow, isSpectating } from '../store.js';
|
||||
import { battleRunner } from '../battle/runner.js';
|
||||
import { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf, resumedWatch } from '../battle/observe.js';
|
||||
import { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf, resumedWatch, followedScout } from '../battle/observe.js';
|
||||
import { screenStrip, playerBonds, playerLayer, detailBondOwner, toggleBond, popupView } from '../ui/watchBonds.js';
|
||||
import { data, getMode } from '../data.js';
|
||||
import { audio, resultSpeaker, resultVoiceSlot } from '../audio.js';
|
||||
@@ -251,7 +251,7 @@ function MatchScreen() {
|
||||
getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; },
|
||||
getToken: gd.token, getItem: gd.item, getEffect: gd.effect, backups: gd.backups,
|
||||
}), [priv, pub?.stageId, editable, gd.ready, deployField]);
|
||||
live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
|
||||
live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, alive, spectator, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
|
||||
|
||||
// ---- camera: every request goes through setCam, which remembers it for the pen's way back -----------------------
|
||||
// the own prep board: the normal board, or — in the prep of a boss round — the player's half of the boss field
|
||||
@@ -582,8 +582,15 @@ function MatchScreen() {
|
||||
else if (phase === PHASE.FINAL_ASSAULT) audio.sfx(solo ? 'bossRoundSingle' : 'bossRoundTeam');
|
||||
else if (phase === PHASE.HIDDEN_CORE) audio.sfx('bossRoundSecret');
|
||||
else if (phase === PHASE.SP_DRAFT) audio.sfx('draft');
|
||||
setWatching(null); // the server resets every watcher to its own field on phase changes
|
||||
setWatchWho(null);
|
||||
// the server resets every watcher to its own field on phase changes — but an eliminated player / spectator seat
|
||||
// keeps the prep board it follows from 回合开始 through 机变 and 休整期 (Match._followScout re-points it only at the
|
||||
// round start): resetting here would fly the camera home and back at every prep phase (item 56)
|
||||
const keepScout = (phase === PHASE.SP_DRAFT || phase === PHASE.PREP) && (!alive || spectator)
|
||||
&& typeof live.current.watching === 'string' && live.current.watching.startsWith('n:');
|
||||
if (!keepScout) {
|
||||
setWatching(null);
|
||||
setWatchWho(null);
|
||||
}
|
||||
// the pen is a 休整期 view: leaving prep returns the camera (the next setCam would, too)
|
||||
if (phase !== PHASE.PREP && penRef.current.on) togglePenRef.current(false);
|
||||
// a battle unit's panel (live HP of a unit of the fight that just ended) never outlives its battle
|
||||
@@ -699,7 +706,7 @@ function MatchScreen() {
|
||||
const who = playerId ? { fieldId: fid, playerId } : null;
|
||||
setWatching(fid);
|
||||
setWatchWho(who);
|
||||
const ok = await actions.watch(fid);
|
||||
const ok = await actions.watch(fid, playerId);
|
||||
if (!ok) {
|
||||
setWatching((w) => (w === fid ? prev : w));
|
||||
setWatchWho((w) => (w === who ? prevWho : w));
|
||||
@@ -713,8 +720,11 @@ function MatchScreen() {
|
||||
if (L.watching && L.watching !== L.home) {
|
||||
const target = isCombatPhase(L.pub?.phase) ? L.home : ownFieldId(L.myId);
|
||||
// client-side combat without an own field to go back to (a 联防 leaker, an eliminated player): the screen keeps
|
||||
// the field it shows — g.watch of a field that does not exist would only be refused (an error toast)
|
||||
const exists = !isClientCombat(L.pub) || !isCombatPhase(L.pub?.phase) || (Array.isArray(L.pub?.fields) && L.pub.fields.some((f) => f && f.fieldId === target));
|
||||
// the field it shows — g.watch of a field that does not exist would only be refused (an error toast); an
|
||||
// eliminated player / spectator seat has no prep board of its own either
|
||||
const combatNow = isCombatPhase(L.pub?.phase);
|
||||
const exists = combatNow ? (!isClientCombat(L.pub) || (Array.isArray(L.pub?.fields) && L.pub.fields.some((f) => f && f.fieldId === target)))
|
||||
: (L.alive && !L.spectator);
|
||||
if (exists) actions.watch(target);
|
||||
}
|
||||
setWatching(null);
|
||||
@@ -733,7 +743,9 @@ function MatchScreen() {
|
||||
}
|
||||
const self = p.playerId === L.myId;
|
||||
if (self) {
|
||||
if (L.watching && L.watching !== L.home) actions.watch(isCombatPhase(L.pub?.phase) ? L.home : ownFieldId(L.myId));
|
||||
// (an eliminated player / spectator seat has no prep board of its own to ask for: item 56)
|
||||
const combatNow = isCombatPhase(L.pub?.phase);
|
||||
if (L.watching && L.watching !== L.home && (combatNow || (L.alive && !L.spectator))) actions.watch(combatNow ? L.home : ownFieldId(L.myId));
|
||||
setWatching(null);
|
||||
setWatchWho(null);
|
||||
return;
|
||||
@@ -745,17 +757,25 @@ function MatchScreen() {
|
||||
|
||||
const watchField = useCallback((fid) => { requestWatch(fid); }, []);
|
||||
|
||||
// a spectator seat has no board of its own: in 休整期 / 机变 / round start it is shown the first player still in (as a
|
||||
// tap on that row would — g.watch 'n:<pid>', the read-only board), once per phase; a row switches to another player
|
||||
const scoutedRef = useRef(null);
|
||||
// an eliminated player or a spectator seat follows a player through every phase reset (community report of
|
||||
// 2026-10-06, item 56; the idea of PR #189): the server pushes that player's prep board (Match._followScout) — the one
|
||||
// it last watched, else the first player still in — and the screen adopts it like a 前往查看 tap, once per phase and
|
||||
// board (a 返回战场 this phase is not overridden; a row switches to another player, who is followed from then on).
|
||||
// Until 0.2.0 every round's reset dropped an eliminated player onto its own empty board (a spectator seat asked for
|
||||
// the first player itself).
|
||||
const followRef = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!spectator || watching || !pub || scoutedRef.current === phaseKey) return;
|
||||
if (phase !== PHASE.PREP && phase !== PHASE.SP_DRAFT && phase !== PHASE.ROUND_START) return;
|
||||
const first = players.find((p) => p.alive !== false && p.status !== 'left');
|
||||
if (!first) return;
|
||||
scoutedRef.current = phaseKey;
|
||||
requestWatch(ownFieldId(first.playerId), first.playerId);
|
||||
}, [spectator, phaseKey, watching]);
|
||||
const fid = followedScout({ field, watching, alive, spectator, myId });
|
||||
if (!fid || !pub) return;
|
||||
const key = `${phaseKey}:${fid}`;
|
||||
if (followRef.current === key) return;
|
||||
followRef.current = key;
|
||||
// the phase reset's render (watching null) ran the own-prep branch with this scout meta in `field` and marked it
|
||||
// stale: un-mark it, or the enter effect refuses it and the board stays blank until the player moves (PR #189)
|
||||
if (staleFieldRef.current === field) staleFieldRef.current = null;
|
||||
setWatching(fid);
|
||||
setWatchWho({ fieldId: fid, playerId: fid.slice(2) });
|
||||
}, [field, pub, watching, alive, spectator, phaseKey, myId]);
|
||||
|
||||
// ---- view events (drag & drop, clicks) ----------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
|
||||
Reference in new issue
Block a user