The own half was already the official one (the right-hand player on the right half with the
right_boss_*_camera_param camera, its layout mirrored col c -> 20 - c with RIGHT <-> LEFT, so its operators
face the leader as before: research 09 §1.2, DESIGN §15); the partner's half stood empty and nothing framed
the pair.
Server: in a boss round's prep m.private.bossMate { playerId, side, units } = the partner's board as UnitInfo
on its half (Match.bossMateView, finalAssault.js bossFieldPlacement); markPrivate marks the partner's
m.private with the mover's, so it follows live; a scout of either player (Match.prepFieldMeta) carries both
halves (`mate`), and its signature covers the partner's board.
Client: render/app.js syncMates draws the partner's units read-only ('m:<uid>' views: never picked, dragged
or swept with the own pieces) and the whole boss field is lit; the team panel frames the pair's avatars
(gameLogic teamFrameIds: the players still in by seat, two by two, as prepCamera — the boss field in the
fight) from the round start through the Final Assault / Hidden Core, with the official ui/battle
bg_team_border tinted the autochess green (#4ed8af; a plain ring without the local art); none for a lone
player, an eliminated player, a spectator seat, or 联防.
Overlap with PR #191 (same-field frames, issue #190; not merged): it published m.public bossPairing and
framed the partner alone in gold, 联防 included, plus a banner naming the partner; here the pairing is read
as the prep camera already does, both avatars get the official green frame, boss rounds only.
[ASSUMED] the partner's bench is not shown; one frame per avatar.
Golden unchanged. Tests: test/match/feedback5-boss-pair-prep.test.js (new, 4), the item 55 test now checks
the scouted player's own pieces (the partner's stand on the other half); SP_E2E
test/ui/feedback5-boss-pair.e2e.test.js (new, 2: both halves and the frames in prep and fight; item 55's
eliminated guest on the boss field with the leader). Docs: design/client.md §10 / §15, design/network.md,
PLAYING.md.
123 lines
7.2 KiB
JavaScript
123 lines
7.2 KiB
JavaScript
// ui/gameLogic/camera.js — prep camera, boss-field tile mapping, owner band. Re-exported from ../gameLogic.js.
|
||
|
||
import { GEO, PHASE } from '../../../../shared/constants.js';
|
||
import { BOSS_ROW_SHIFT, MAX_COL } from '../../render/prepfield.js';
|
||
import { int, isObj } from './shared.js';
|
||
|
||
|
||
/**
|
||
* Camera of the own prep board (research 09 §1.2 "Final Assault, right side", DESIGN §15): in the prep of a boss round
|
||
* (最终攻势 / 隐秘核心: `pub.round` is `bossRound` or `hiddenRound`) the pieces stand on the player's half of the shared
|
||
* boss field — `{ kind: 'bossPrep', opts: { side } }`; the side follows the server's pairing (server/match/
|
||
* finalAssault.js pairPlayers: the alive players in seat order, two by two; the second of a pair is on the mirrored
|
||
* right half 'R', a lone player on 'L'). Any other prep: the own board `{ kind: 'prep', opts: { rect, side: 'L' } }`.
|
||
* @param {any} pub m.public
|
||
* @param {string|null} myId
|
||
* @returns {{ kind: 'prep'|'bossPrep', opts: { rect?: { r0: number, r1: number, c0: number, c1: number }, side: 'L'|'R' } }}
|
||
*/
|
||
export function prepCamera(pub, myId) {
|
||
const normal = { kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L' } };
|
||
const r = pub?.round;
|
||
if (!Number.isInteger(r) || r <= 0 || !(r === pub.bossRound || r === pub.hiddenRound)) return normal;
|
||
const alive = (Array.isArray(pub.players) ? pub.players : []).filter((p) => isObj(p) && p.alive !== false)
|
||
.sort((a, b) => int(a.seat, 99) - int(b.seat, 99));
|
||
const idx = alive.findIndex((p) => p.playerId === myId);
|
||
if (idx < 0) return normal; // eliminated / unknown: no board of its own on the boss field
|
||
return { kind: 'bossPrep', opts: { side: idx % 2 === 1 ? 'R' : 'L' } };
|
||
}
|
||
|
||
const BOSS_PREP_PHASES = new Set([PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP]);
|
||
|
||
/**
|
||
* The players framed in green on the team panel (community report of 2026-10-06, item 51: 「同组两个玩家屏幕左边的头像也像原版
|
||
* 那样用绿框框起来」 — the official co-op frame, ui/battle `bg_team_border`): in a boss round (最终攻势 / 隐秘核心) the viewer
|
||
* and its pair partner — from the round's start (回合开始 / 机变 / 休整期: the pairing `prepCamera` reads, the players still
|
||
* in by seat, two by two — server/match/finalAssault.js pairPlayers) through the fight (the boss field listing the
|
||
* viewer). Empty for a lone player, an eliminated player or a spectator seat (no pair of its own) and in every other
|
||
* round. PR #191 framed the partner alone, in gold (not merged).
|
||
* @param {any} pub m.public @param {string|null} myId
|
||
* @returns {Set<string>}
|
||
*/
|
||
export function teamFrameIds(pub, myId) {
|
||
const none = new Set();
|
||
if (!pub || !myId) return none;
|
||
const players = Array.isArray(pub.players) ? pub.players.filter(isObj) : [];
|
||
const me = players.find((p) => p.playerId === myId);
|
||
if (!me || me.alive === false) return none;
|
||
const r = pub.round;
|
||
const bossRound = Number.isInteger(r) && r > 0 && (r === pub.bossRound || r === pub.hiddenRound);
|
||
if (pub.phase === PHASE.FINAL_ASSAULT || pub.phase === PHASE.HIDDEN_CORE) {
|
||
const f = (Array.isArray(pub.fields) ? pub.fields : []).find((x) => isObj(x) && (x.kind === 'boss' || x.kind === 'hidden') && Array.isArray(x.players) && x.players.includes(myId));
|
||
return f && f.players.length > 1 ? new Set(f.players) : none;
|
||
}
|
||
if (!bossRound || !BOSS_PREP_PHASES.has(pub.phase)) return none;
|
||
const alive = players.filter((p) => p.alive !== false).sort((a, b) => int(a.seat, 99) - int(b.seat, 99)).map((p) => p.playerId);
|
||
const idx = alive.indexOf(myId);
|
||
const pair = alive.slice(idx - (idx % 2), idx - (idx % 2) + 2);
|
||
return pair.length > 1 ? new Set(pair) : none;
|
||
}
|
||
|
||
/**
|
||
* The own prep board's camera request with the shop bar's state (public issue #5 "准备阶段,收起商店界面时,界面并不会进行
|
||
* 缩放"): `prepCamera` plus `shop: !folded` — a folded shop (收起) takes the official shop-collapsed camera
|
||
* (configBlackBoard left_prepare_camera_param / *_boss_prepare_*, render/projection.js presetCamera `shop: false`) and
|
||
* the folded shop's HUD band (ui/fieldHost.js hudBands), an open one the shop camera. `folded` = the shop bar is shown
|
||
* and folded (an eliminated player's board, which shows no bar, keeps the shop camera).
|
||
* @returns {{ kind: 'prep'|'bossPrep', opts: { rect?: object, side: 'L'|'R', shop: boolean } }}
|
||
*/
|
||
export function prepCameraFor(pub, myId, folded = false) {
|
||
const c = prepCamera(pub, myId);
|
||
return { kind: c.kind, opts: { ...c.opts, shop: !folded } };
|
||
}
|
||
|
||
/**
|
||
* The camera request a fold / unfold of the shop bar needs right now (public issue #5), or null: only while the own prep
|
||
* board is on screen (`ownPrep`: prep, not a teammate's scouted board, not a battle), never over the enemy pen (`pen`: it
|
||
* folds the shop itself and returns to the camera it left), deferred while a piece is dragged or its direction is chosen
|
||
* (`busy`: the drop target and the wheel sit on tiles of the camera in use — the caller asks again once that ends), and
|
||
* nothing when the current request (`current` { kind, opts }) already has that shop state.
|
||
* @param {{ pub: any, myId: string|null, ownPrep: boolean, folded: boolean, pen?: boolean, busy?: boolean,
|
||
* current?: { kind: string, opts?: { shop?: boolean } }|null }} s
|
||
* @returns {{ kind: 'prep'|'bossPrep', opts: object }|null}
|
||
*/
|
||
export function foldCamera(s) {
|
||
if (!s || !s.ownPrep || s.pen || s.busy) return null;
|
||
const want = prepCameraFor(s.pub, s.myId, !!s.folded);
|
||
const cur = s.current;
|
||
if (cur && cur.kind === want.kind && (cur.opts?.shop !== false) === want.opts.shop) return null;
|
||
return want;
|
||
}
|
||
|
||
/**
|
||
* The field the own pieces are deployed on (server/match/Match.js deployFieldOf): 'bossL' / 'bossR' in the prep of a
|
||
* boss round (the same pairing as `prepCamera`), else 'normal'. Placement legality (`placementContext` → `deployMap`
|
||
* with `field`) reads that field's tiles.
|
||
* @returns {'normal'|'bossL'|'bossR'}
|
||
*/
|
||
export function deployFieldOf(pub, myId) {
|
||
const cam = prepCamera(pub, myId);
|
||
return cam.kind === 'bossPrep' ? (cam.opts.side === 'R' ? 'bossR' : 'bossL') : 'normal';
|
||
}
|
||
|
||
/**
|
||
* The stage tile [row, col] of board tile (r, c) on deploy field `field` (server/match/board.js fieldTile): the boss
|
||
* prep's display transform (render/prepfield.js bossPrepField: row − 7, the right half mirrored col c → 20 − c).
|
||
*/
|
||
export function fieldTile(field, r, c) {
|
||
if (field !== 'bossL' && field !== 'bossR') return [r, c];
|
||
return [r + BOSS_ROW_SHIFT, field === 'bossR' ? MAX_COL - c : c];
|
||
}
|
||
|
||
/** The board tile of stage tile (r, c) on deploy field `field` (server/match/board.js boardTileOf). */
|
||
export function boardTileOf(field, r, c) {
|
||
if (field !== 'bossL' && field !== 'bossR') return [r, c];
|
||
return [r - BOSS_ROW_SHIFT, field === 'bossR' ? MAX_COL - c : c];
|
||
}
|
||
|
||
/** The band (策略) a player picked, from m.public.players[].bandId (Match.js marksPublic) — the detail card shows it
|
||
* on a teammate's unit (user playtest #2 item 2: watching a teammate revealed nothing about their 策略). */
|
||
export function ownerBandId(pub, ownerId) {
|
||
const p = Array.isArray(pub?.players) ? pub.players.find((x) => x && x.playerId === ownerId) : null;
|
||
return typeof p?.bandId === 'string' && p.bandId ? p.bandId : null;
|
||
}
|