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.
114 lines
5.0 KiB
JavaScript
114 lines
5.0 KiB
JavaScript
// server/match/match/messaging.js — Match methods: messaging — unicast, broadcast, toasts, the ticker lines
|
|
// (config.broadcasts by type; the remake's CUSTOM lines), the dirty marks and flush: m.private per player only when it
|
|
// changed (plus the prep scouts of that player's board), m.public throttled (DELAYS.PUBLIC_THROTTLE) and deduplicated.
|
|
// i18n (docs/I18N.md): a toast / CUSTOM line is a string (its own msgid) or a shared/i18n.js msg(msgid, params); the frame
|
|
// carries `text` (the Chinese rendering, what older clients show) plus `msgid` / `params`; a broadcast line carries its
|
|
// `args` so the client can fill the localized config.broadcasts template.
|
|
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
|
|
|
|
import { DELAYS } from './common.js';
|
|
import { wireMessage } from '../../../shared/i18n.js';
|
|
|
|
export class MatchMessaging {
|
|
sendTo(playerId, msg) {
|
|
if (this.disposed) return false;
|
|
const ps = this.players.get(playerId) || this.spectators.get(playerId);
|
|
if (!ps || ps.isBot || ps.left) return false;
|
|
try { return !!this.sendFn(playerId, msg); } catch (e) { this.reportError('send', e); return false; }
|
|
}
|
|
|
|
broadcast(msg) {
|
|
if (this.disposed) return;
|
|
try { this.broadcastFn(msg); } catch (e) { this.reportError('broadcast', e); }
|
|
}
|
|
|
|
/** @param {string|{ msgid: string, params?: object }} text a string or msg() (shared/i18n.js) */
|
|
toast(ps, kind, text) {
|
|
if (!ps || ps.isBot || ps.left || !ps.connected) return;
|
|
this.sendTo(ps.playerId, { t: 'm.toast', kind, ...wireMessage(text) });
|
|
}
|
|
|
|
/** Broadcast ticker from config.broadcasts by type; `param` picks the variant (SHOP_LEVEL level, BOSS_HIT share…). */
|
|
tickerFor(type, args = [], { playerId = null, to = null, param = null } = {}) {
|
|
const list = Array.isArray(this.gd.config.broadcasts) ? this.gd.config.broadcasts.filter((b) => b && b.type === type) : [];
|
|
let b = list[0];
|
|
if (param != null) b = list.find((x) => Array.isArray(x.params) && x.params.includes(String(param))) || b;
|
|
const tpl = b && typeof b.text === 'string' ? b.text : null;
|
|
if (!tpl) return;
|
|
const text = tpl.replace(/\{(\d)\}/g, (_, i) => (args[Number(i)] != null ? String(args[Number(i)]) : ''));
|
|
const msg = { t: 'm.ticker', text, id: b.id, type, priority: Number(b.priority) || 0, playerId, args: args.map((a) => String(a ?? '')) };
|
|
if (to) this.sendTo(to, msg);
|
|
else this.broadcast(msg);
|
|
}
|
|
|
|
/**
|
|
* A ticker line of the remake's own (type CUSTOM). `priority`: the match-flow notices (隐秘核心已解锁, 联防阶段, a player out
|
|
* or gone) take FLOW_TICKER_PRIORITY so the strip does not hold them behind shop-level lines; other lines 0.
|
|
*/
|
|
tickerText(text, priority = 0) {
|
|
if (!text) return;
|
|
const w = wireMessage(text);
|
|
this.broadcast({ t: 'm.ticker', ...w, text: w.text.slice(0, 200), id: null, type: 'CUSTOM', priority: Number(priority) || 0, playerId: null });
|
|
}
|
|
|
|
markPublic() { this._pubDirty = true; }
|
|
/**
|
|
* A player's state changed: its m.private and (throttled, deduplicated) m.public (level, board, bonds…) — in a boss
|
|
* round's prep also its pair partner's m.private, which shows this board on its other half (bossMate, item 51).
|
|
*/
|
|
markPrivate(ps) {
|
|
if (!ps) return;
|
|
this._privDirty.add(ps);
|
|
this._pubDirty = true;
|
|
const m = this._bossMateOf(ps);
|
|
if (m) this._privDirty.add(m.mate);
|
|
}
|
|
|
|
/** Send pending m.private (per player, only when changed) and m.public (throttled ≤ 10/s). */
|
|
flush(forcePublic = false) {
|
|
if (this.disposed) return;
|
|
if (this._privDirty.size) {
|
|
const list = [...this._privDirty];
|
|
this._privDirty.clear();
|
|
for (const ps of list) {
|
|
if (!(ps.isBot || ps.left || !ps.connected)) this._sendPrivate(ps, false);
|
|
this._notifyPrepScouts(ps);
|
|
}
|
|
}
|
|
if (this._pubDirty || forcePublic) this._maybeSendPublic(forcePublic);
|
|
}
|
|
|
|
_sendPrivate(ps, force) {
|
|
let view;
|
|
try { view = ps.privateView(); } catch (e) { this.reportError('privateView', e); return; }
|
|
const json = JSON.stringify(view);
|
|
if (!force && json === ps._lastPriv) return;
|
|
ps._lastPriv = json;
|
|
this.sendTo(ps.playerId, view);
|
|
}
|
|
|
|
_maybeSendPublic(force) {
|
|
const now = this.sched.now();
|
|
if (!force && now - this._lastPubAt < DELAYS.PUBLIC_THROTTLE) {
|
|
if (!this._pubTimer) {
|
|
const wait = Math.max(1, DELAYS.PUBLIC_THROTTLE - (now - this._lastPubAt));
|
|
this._pubTimer = this.sched.setTimeout(() => {
|
|
this._pubTimer = null;
|
|
if (this.disposed) return;
|
|
try { this._maybeSendPublic(false); } catch (e) { this.reportError('public', e); }
|
|
}, wait);
|
|
}
|
|
return;
|
|
}
|
|
this._pubDirty = false;
|
|
let view;
|
|
try { view = this.publicView(); } catch (e) { this.reportError('publicView', e); return; }
|
|
const { serverNow, ...rest } = view;
|
|
const json = JSON.stringify(rest);
|
|
if (!force && json === this._lastPubJson) return;
|
|
this._lastPubJson = json;
|
|
this._lastPubAt = now;
|
|
this.broadcast(view);
|
|
}
|
|
}
|