- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently). - Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision). - 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified). - Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage. - Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue. - Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia). - golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
130 lines
7.4 KiB
JavaScript
130 lines
7.4 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';
|
||
|
||
/** Server-owned random pairs. Seat inference is only for an older server without the field. */
|
||
function prepPair(pub, myId) {
|
||
const alive = (Array.isArray(pub?.players) ? pub.players : []).filter(p => isObj(p) && p.alive !== false);
|
||
if (!alive.some(p => p.playerId === myId)) return [];
|
||
if (pub.bossPairs !== undefined) {
|
||
return (Array.isArray(pub.bossPairs) ? pub.bossPairs : []).find(g => Array.isArray(g) && g.includes(myId)) || [];
|
||
}
|
||
const ids = alive.sort((a, b) => int(a.seat, 99) - int(b.seat, 99)).map(p => p.playerId);
|
||
const i = ids.indexOf(myId);
|
||
return ids.slice(i - i % 2, i - i % 2 + 2);
|
||
}
|
||
|
||
/**
|
||
* 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, sent in m.public.bossPairs; 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 idx = prepPair(pub, myId).indexOf(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 the server's saved pairs — 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 pair = prepPair(pub, myId);
|
||
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;
|
||
}
|