feedback5: merge CB5 — community reports (match / UI): module 特性追加 shown after the base trait (text only), a shop upgrade fills its new slot at once, spectators follow the player they picked through every phase (g.watch carries the playerId — PR #189's idea), the boss shows in boss-round prep scouting, the final-boss prep shows the pair together with green avatar frames (PR #191's idea, official colour); §25.19 after the hotkeys' §25.15; golden: the 18 bot matches (the upgrade's new card), battles unchanged
This commit is contained in:
commit
37558f0d5e
40 files changed
+1815
-607
No files matched your search
@@ -307,6 +307,15 @@
|
||||
.team__row.is-self .pavatar { box-shadow: 0 0 0 2px var(--mint-500), 0 0 .18rem rgba(23, 249, 183, .35); }
|
||||
.team__row.is-watched .pavatar { box-shadow: 0 0 0 2px var(--amber), 0 0 .18rem rgba(246, 163, 41, .45); }
|
||||
.team__row.is-dead .team__name { color: var(--text-dim); text-decoration: line-through; }
|
||||
/* 最终攻势 / 隐秘核心: the viewer's pair framed in the official green from the round's start (item 51) — the official
|
||||
ui/battle bg_team_border (a white frame with its glow) tinted through a mask; a plain green ring without the local art.
|
||||
It replaces the own row's mint ring; a watched row keeps its amber one inside the frame. */
|
||||
.team__frame {
|
||||
position: absolute; inset: -.07rem; pointer-events: none; z-index: 1;
|
||||
background: var(--mint-500); -webkit-mask: var(--frame) center / 100% 100% no-repeat; mask: var(--frame) center / 100% 100% no-repeat;
|
||||
}
|
||||
.team__frame--plain { inset: -.04rem; background: none; -webkit-mask: none; mask: none; border: 2px solid var(--mint-500); box-shadow: 0 0 .14rem rgba(78, 216, 175, .65); }
|
||||
.team__row.is-self.is-team .pavatar { box-shadow: none; }
|
||||
.team__bubble { position: absolute; left: calc(100% + .06rem); top: 50%; transform: translateY(-50%); width: max-content; max-width: 3.2rem; }
|
||||
/* 前往查看 / 返回战场 (research 09 §3.1: tapping a teammate's avatar expands a mint button under it) */
|
||||
.team__row.is-open .pavatar { box-shadow: 0 0 0 2px var(--mint-400), 0 0 .2rem rgba(23, 249, 183, .45); }
|
||||
|
||||
@@ -239,6 +239,7 @@
|
||||
"充能": "Charges",
|
||||
"不装备模组:精锐干员以基础属性、特性与天赋作战。": "No Module: the Elite Operator fights with its base stats, Trait and Talents.",
|
||||
"特性": "Trait",
|
||||
"特性追加": "Added Trait",
|
||||
"属性": "Stats",
|
||||
"无属性加成": "No stat bonus",
|
||||
"天赋": "Talent",
|
||||
@@ -676,6 +677,7 @@
|
||||
"刷新商店 · {key}": "Refresh Shop · {key}",
|
||||
"免费 ×{free}": "Free ×{free}",
|
||||
"查看 {name} 的战场": "View {name}'s Battlefield",
|
||||
"与你在同一战场": "On your field",
|
||||
"查看自己的阵地": "View your Field",
|
||||
"查看 {name} 的阵地": "View {name}'s Field",
|
||||
"连接已断开": "Disconnected",
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
// replica of their battle; 返回战场 goes back;
|
||||
// * 联防 / 最终攻势: the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half of the own field; the other pair's
|
||||
// boss field is never shown to a fighting player;
|
||||
// * eliminated: anything.
|
||||
// * eliminated: anything — each phase reset starts on the player it follows (the server's prep scout / b.start,
|
||||
// followedScout).
|
||||
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
import { data } from '../data.js';
|
||||
@@ -82,6 +83,23 @@ export function resumedWatch(b, { pub = null, myId = '', alive = true, watching
|
||||
return { seen: b.battleId, fieldId: b.fieldId };
|
||||
}
|
||||
|
||||
/**
|
||||
* The prep board the server pushed to a viewer that follows a player (an eliminated player or a spectator seat:
|
||||
* Match._followScout — the player it last watched, else the first player still in; community report of 2026-10-06,
|
||||
* item 56, the idea of PR #189), to adopt as the watched board like a 前往查看 tap: `field` is such a scout (m.field
|
||||
* `prep`, an `n:<pid>` id other than the own) and the screen watches nothing (`watching` null — a 返回战场 this phase is
|
||||
* not overridden: the caller adopts a board once per phase). Null for a living player: its phase resets keep its own
|
||||
* board.
|
||||
* @param {{ field?: any, watching?: string|null, alive?: boolean, spectator?: boolean, myId?: string }} o
|
||||
* @returns {string|null} the fieldId to watch
|
||||
*/
|
||||
export function followedScout({ field = null, watching = null, alive = true, spectator = false, myId = '' } = {}) {
|
||||
if (watching || (alive && !spectator) || !isObj(field) || !field.prep) return null;
|
||||
const fid = field.fieldId;
|
||||
if (typeof fid !== 'string' || !fid.startsWith('n:') || fid === `n:${myId}`) return null;
|
||||
return fid;
|
||||
}
|
||||
|
||||
/** Teammates' progress for the waiting pill: [{ playerId, name, killed, total, done, isBot }]. */
|
||||
export function teammateProgress(pub, myId) {
|
||||
const out = [];
|
||||
|
||||
+36
-2
@@ -78,7 +78,10 @@
|
||||
// boss rows 2–5, bench 7 → 0, temp 8 → 1, side 'R' mirrored col c → 20 − c with RIGHT ↔ LEFT. Every public
|
||||
// coordinate stays in BOARD space (pieceDrop targets, canPlace, highlightTiles, tileScreen, holdPiece, setPieceDir,
|
||||
// stored `dir`); `view.prepField()` → { kind, side, mirror } and `tileScreen(...).mirror` tell the direction wheel
|
||||
// that a screen-right swipe means board LEFT on the mirrored half.
|
||||
// that a screen-right swipe means board LEFT on the mirrored half. The pair partner's board stands on the other half
|
||||
// (`priv.bossMate` units, already in boss-field coordinates — server/match/match/views.js bossMateView), read-only
|
||||
// ('m:<uid>' views: never picked or dragged), and the lit rect is the whole boss field: both players of a pair are
|
||||
// shown together, as in the battle (community report of 2026-10-06, item 51).
|
||||
// Lost WebGL context of the 3D board: the 2D board takes over at once and the 3D board is rebuilt on a fresh context
|
||||
// a moment later (up to 3 tries; a context lost again right away counts as a failure; `setBoardMode('2d')` stops it).
|
||||
//
|
||||
@@ -458,7 +461,8 @@ export async function createFieldView(host, options = {}) {
|
||||
if (k === 'pen') return { r0: 14, r1: 18, c0: 7, c1: 13 };
|
||||
// prep lights the bench (hand row 7 / temp row 8) with the field, like the official prep view
|
||||
if (camOpts.rect) { const r = normRect(camOpts.rect); return k === 'prep' ? { ...r, r0: Math.min(r.r0, GEO.HAND_ROW) } : r; }
|
||||
if (k === 'bossPrep') return camOpts.side === 'R' ? { r0: 0, r1: 5, c0: 10, c1: 20 } : { r0: 0, r1: 5, c0: 0, c1: 10 };
|
||||
// the boss round's prep lights the whole boss field: the pair partner's half is shown with its pieces (item 51)
|
||||
if (k === 'bossPrep') return { ...GEO.BOSS_RECT };
|
||||
return k === 'boss' ? { ...GEO.BOSS_RECT } : k === 'unite' ? { ...GEO.UNITE_RECT } : k === 'prep' ? { r0: 7, r1: 12, c0: 0, c1: 10 } : { ...GEO.NORMAL_RECT };
|
||||
}
|
||||
|
||||
@@ -768,12 +772,42 @@ export async function createFieldView(host, options = {}) {
|
||||
if (e.area === 'board' && info.dir && !held.has(e.uid) && typeof v.setDir === 'function') v.setDir(info.dir);
|
||||
}
|
||||
for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k);
|
||||
// the boss round's prep: the pair partner's board on its half of the boss field (item 51; display only)
|
||||
syncMates(prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : []);
|
||||
prepPieces = list.filter((e) => e.key && views.has(e.key));
|
||||
if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); }
|
||||
holdScene(false); // the prep pieces reference their models now (a battle's hold ends here)
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pair partner's pieces in the Final Assault / Hidden Core prep (m.private bossMate units: UnitInfo in boss-field
|
||||
* coordinates, facing as the battle will place them): read-only 'm:<uid>' views — not prep pieces, so they are never
|
||||
* picked, dragged or swept with the own ones; a view is rebuilt when its body, tile or facing changes.
|
||||
*/
|
||||
function syncMates(units) {
|
||||
const keep = new Set();
|
||||
for (const u of units) {
|
||||
const info = u && Number.isInteger(u.uid) ? renderInfo({ ...u, id: `m:${u.uid}` }) : null;
|
||||
if (!info) continue;
|
||||
keep.add(info.id);
|
||||
const sig = `${info.defId}|${info.golden ? 1 : 0}|${info.spine || ''}|${info.x},${info.y}|${info.dir || ''}|${(info.items || []).join(',')}`;
|
||||
let v = views.get(info.id);
|
||||
if (v && v._sig !== sig) { dropView(info.id); v = null; }
|
||||
if (v) continue;
|
||||
v = new UnitView(ctx, { ...info, maxHp: 1 }, { prep: true });
|
||||
v._sig = sig;
|
||||
v.setWorld(info.x, info.y, heightAt(info.y, info.x));
|
||||
if (info.dir && typeof v.setDir === 'function') v.setDir(info.dir);
|
||||
v._showFacing = true;
|
||||
if (v.setItems && Array.isArray(info.items) && info.items.length) {
|
||||
v.setItems(info.items.map((it) => { const r = data.item(it); return assets.itemIcon ? assets.itemIcon(r ? { trapId: r.trapId, iconId: r.iconId } : it) : null; }));
|
||||
}
|
||||
views.set(info.id, v);
|
||||
}
|
||||
for (const k of [...views.keys()]) if (String(k).startsWith('m:') && !keep.has(k)) dropView(k);
|
||||
}
|
||||
|
||||
/**
|
||||
* A summon stack of several copies (凯瑟琳's 2 devices, user playtest #6) dropped on a board tile stays in the hand
|
||||
* and one copy — a new piece of the same token — lands there: the state already shows the move, the stack goes home.
|
||||
|
||||
@@ -13,7 +13,7 @@ import { html, Icon, Button, TierChip } from '../ui/components.js';
|
||||
import { Img, RichText, BondGlyph } from '../ui/gameComponents.js';
|
||||
import { chessAvatarUrl, chessPortraitUrl, profIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
|
||||
import { data } from '../data.js';
|
||||
import { PROF_NAME, skillLabel, moduleBadge } from '../ui/loadoutModel.js';
|
||||
import { PROF_NAME, skillLabel, moduleBadge, fullTraitText } from '../ui/loadoutModel.js';
|
||||
import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
@@ -117,7 +117,8 @@ function SkillRow({ m, s, on, locked, onPick }) {
|
||||
function ModuleRow({ id, rec, on, locked, onPick }) {
|
||||
const none = id == null;
|
||||
const talents = rec && Array.isArray(rec.talentChanges) ? rec.talentChanges.filter((x) => x && x.desc && !x.hidden) : [];
|
||||
const trait = rec?.traitOverride ? (rec.traitOverride.moduleDescRaw || rec.traitOverride.moduleDesc || rec.traitOverride.descRaw || rec.traitOverride.desc) : null;
|
||||
// the trait this module gives: the class trait (or the module's rewrite), then its extra line (item 16.2)
|
||||
const trait = rec?.traitOverride ? fullTraitText(rec.traitOverride) || null : null;
|
||||
return html`<button type="button" role="radio" aria-checked=${on ? 'true' : 'false'} data-module=${none ? 'none' : id} disabled=${locked}
|
||||
class=${cx('diy-choice', 'diy-choice--mod', on && 'is-on')} onClick=${() => onPick(id)}>
|
||||
<span class="diy-choice__icon">${none ? html`<b>—</b>` : html`<${Img} src=${moduleTypeIconUrl(data.get('local'), rec.typeName)} fallback=${html`<b class="num">${moduleBadge(rec)}</b>`} />`}</span>
|
||||
|
||||
+42
-19
@@ -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
|
||||
@@ -400,7 +400,10 @@ function MatchScreen() {
|
||||
const pf = (Array.isArray(pub?.fields) ? pub.fields : []).find((f) => f && f.fieldId === field.fieldId);
|
||||
const members = Array.isArray(pf?.players) ? pf.players : Array.isArray(field.players) ? field.players : [];
|
||||
const sides = field.sides && typeof field.sides === 'object' ? field.sides : null;
|
||||
const side = sides && sides[myId] ? sides[myId] : members.length > 1 && members.indexOf(myId) === 1 ? 'R' : 'L';
|
||||
// a scouted board of a boss round's prep (Match.prepFieldMeta: the boss-field rows, `side` = the scouted player's
|
||||
// half) is framed by the boss-field prep camera of that half — the leader standing at its spawn (item 55)
|
||||
const side = field.prep && (field.side === 'L' || field.side === 'R') ? field.side
|
||||
: sides && sides[myId] ? sides[myId] : members.length > 1 && members.indexOf(myId) === 1 ? 'R' : 'L';
|
||||
// local simulation (client-side combat) feeds a frame per animation frame: no network jitter buffer
|
||||
view.raw?.setLocalFeed?.({ on: !!field.local, speed: field.speed });
|
||||
setLayer('ALL');
|
||||
@@ -582,8 +585,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 +709,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 +723,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 +746,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 +760,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(() => {
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useStore } from '../store.js';
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
import {
|
||||
MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice,
|
||||
changedCount, skillLabel, moduleBadge, attrRows, skillTags, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
|
||||
changedCount, skillLabel, moduleBadge, attrRows, skillTags, traitLines, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
|
||||
} from '../ui/loadoutModel.js';
|
||||
import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js';
|
||||
import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js';
|
||||
@@ -198,7 +198,7 @@ function SkillOption({ m, opt, on, level, onPick }) {
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function ModuleInfo({ m, golden, opt }) {
|
||||
export function ModuleInfo({ m, golden, opt }) {
|
||||
if (!golden) return null;
|
||||
const rec = opt.id === MODULE_NONE ? null : opt.rec;
|
||||
if (!rec) {
|
||||
@@ -209,8 +209,9 @@ function ModuleInfo({ m, golden, opt }) {
|
||||
</div>`;
|
||||
}
|
||||
const rows = attrRows(rec.attr);
|
||||
const trait = rec.traitOverride;
|
||||
const traitText = trait ? (trait.moduleDescRaw || trait.moduleDesc || trait.descRaw || trait.desc) : null;
|
||||
// 特性: the trait the elite fights with under this module (the class trait, or the module's rewrite of it), then the
|
||||
// module's extra line under PRTS's own label 特性追加 — the line adds to the class trait (item 16.2)
|
||||
const lines = traitLines(rec.traitOverride);
|
||||
const talents = (Array.isArray(rec.talentChanges) ? rec.talentChanges : []).filter((t) => t && (t.name || t.desc) && !t.hidden);
|
||||
return html`<div class="lo-minfo">
|
||||
<div class="lo-minfo__title"><${Img} src=${moduleIconOf(m, rec)} class="lo-minfo__icon" /><span class="lo-minfo__type num">${rec.typeName || ''}</span><b>${rec.name || rec.uniEquipId}</b>
|
||||
@@ -219,7 +220,8 @@ function ModuleInfo({ m, golden, opt }) {
|
||||
<span class="lo-minfo__k">${t('属性')}</span>
|
||||
<span class="lo-minfo__v lo-attrs">${rows.length ? rows.map((r) => html`<span key=${r.key} class=${cx('lo-attr', r.positive ? 'is-up' : 'is-down')}>${r.label}<b class="num">${r.text}</b></span>`) : html`<span class="t-dim">${t('无属性加成')}</span>`}</span>
|
||||
</div>
|
||||
${traitText ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">${t('特性')}</span><${RichText} class="lo-minfo__v" text=${traitText} /></div>` : null}
|
||||
${lines.base ? html`<div class="lo-minfo__row" data-trait="base"><span class="lo-minfo__k">${t('特性')}</span><${RichText} class="lo-minfo__v" text=${lines.base} /></div>` : null}
|
||||
${lines.added ? html`<div class="lo-minfo__row" data-trait="added"><span class="lo-minfo__k">${t('特性追加')}</span><${RichText} class="lo-minfo__v" text=${lines.added} /></div>` : null}
|
||||
${talents.map((tal, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">${t('天赋')}</span>
|
||||
<span class="lo-minfo__v">${tal.name ? html`<b class="lo-minfo__tname">${tal.name}</b>` : null}<${RichText} text=${tal.descRaw || tal.desc || ''} /></span></div>`)}
|
||||
</div>`;
|
||||
|
||||
@@ -40,7 +40,7 @@ import { abilityRows } from './abilityLines.js';
|
||||
import { data } from '../data.js';
|
||||
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
|
||||
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
|
||||
import { moduleBadge } from './loadoutModel.js';
|
||||
import { moduleBadge, fullTraitText } from './loadoutModel.js';
|
||||
import { t, tc, N_ } from '../../../shared/i18n.js';
|
||||
import { audio } from '../audio.js';
|
||||
|
||||
@@ -277,11 +277,11 @@ export function BondChips({ bondIds, bonds = [], onBond = null, off = null, gran
|
||||
*/
|
||||
export function traitText(c, golden, lo) {
|
||||
const t = (lo?.record || c).trait || {};
|
||||
const base = t.descRaw || t.desc || '';
|
||||
if (!golden) return base;
|
||||
// the record's own module line, also for a record cloned for another skill or module (a 不装备 record carries none) —
|
||||
// until 0.1.2 a clone fell back to the class trait (Grok review of GitHub #64; the in-match card had it too)
|
||||
return t.moduleDescRaw || base;
|
||||
if (!golden) return t.descRaw || t.desc || '';
|
||||
// the class trait (or the module's rewrite of it), then the record's own module line — also for a record cloned for
|
||||
// another skill or module (a 不装备 record carries none). Until 0.1.2 a clone fell back to the class trait (Grok review
|
||||
// of GitHub #64); until 0.2.0 the module line replaced the class trait (community report of 2026-10-06, item 16.2).
|
||||
return fullTraitText(t);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,7 +67,8 @@ export const actions = {
|
||||
choice: (idx) => act('g.choice', { idx }),
|
||||
ready: (ready) => act('g.ready', { ready }, { sfx: ready ? 'ready' : 'back' }),
|
||||
emote: (id) => act('g.emote', { id }, { quiet: true }),
|
||||
watch: (fieldId) => act('g.watch', { fieldId }, { sfx: 'tab' }),
|
||||
// `playerId`: the player tapped in the team panel (a shared field shows two) — what an eliminated viewer follows
|
||||
watch: (fieldId, playerId = null) => act('g.watch', typeof playerId === 'string' && playerId ? { fieldId, playerId } : { fieldId }, { sfx: 'tab' }),
|
||||
autoplay: (on) => act('g.autoplay', { on }),
|
||||
// solo battles only (ui/matchStatus.js pauseAvailable): m.public.paused follows
|
||||
pause: (on) => act('g.pause', { on: !!on }, { sfx: on ? 'click' : 'confirm' }),
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
export { clamp, tileKey } from './gameLogic/shared.js';
|
||||
export { phaseMode, isCombatPhase, showDeadPill, isBossPhase } from './gameLogic/phases.js';
|
||||
export { prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, boardTileOf, ownerBandId } from './gameLogic/camera.js';
|
||||
export { prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, boardTileOf, ownerBandId, teamFrameIds } from './gameLogic/camera.js';
|
||||
export { phaseBanner, prepCapsuleLabel, countdownState, phaseTotalSeconds } from './gameLogic/phases.js';
|
||||
export { STATUS_META } from './gameLogic/format.js';
|
||||
export { sortedPlayers } from './gameLogic/shared.js';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// ui/gameLogic/camera.js — prep camera, boss-field tile mapping, owner band. Re-exported from ../gameLogic.js.
|
||||
|
||||
import { GEO } from '../../../../shared/constants.js';
|
||||
import { GEO, PHASE } from '../../../../shared/constants.js';
|
||||
import { BOSS_ROW_SHIFT, MAX_COL } from '../../render/prepfield.js';
|
||||
import { int, isObj } from './shared.js';
|
||||
|
||||
@@ -26,6 +26,37 @@ export function prepCamera(pub, myId) {
|
||||
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
|
||||
|
||||
@@ -325,6 +325,29 @@ export function moduleBadge(rec, id = null) {
|
||||
return m ? m[1] : t.slice(-1) || '?';
|
||||
}
|
||||
|
||||
/**
|
||||
* The two lines of a trait record (data `trait` / `traitBase`, ModuleRecord `traitOverride`, DATA.md §2): `base` = the
|
||||
* 特性 the unit fights with — the class trait, or the module's own wording where the module rewrites it (official
|
||||
* `overrideDescripton`) — and `added` = the module's extra line (official `additionalDescription`), or null. The extra
|
||||
* line comes after the class trait, never instead of it: PRTS flags it 「特性追加」 on every such module, and the sim keeps
|
||||
* the class trait with the module equipped (community report of 2026-10-06, item 16.2: until 0.2.0 the 干员调配 module
|
||||
* card, its 局内数值 and the detail card showed the extra line alone on 114 of the 164 modules the screen offers).
|
||||
* @param {any} trait
|
||||
* @returns {{ base: string, added: string|null }}
|
||||
*/
|
||||
export function traitLines(trait) {
|
||||
if (!isObj(trait)) return { base: '', added: null };
|
||||
const base = String(trait.descRaw || trait.desc || '');
|
||||
const added = trait.moduleDescRaw || trait.moduleDesc || null;
|
||||
return { base, added: added ? String(added) : null };
|
||||
}
|
||||
|
||||
/** The whole 特性 text of a trait record: its base line, then the module's extra line (`\n` between; RichText breaks it). */
|
||||
export function fullTraitText(trait) {
|
||||
const { base, added } = traitLines(trait);
|
||||
return base && added ? `${base}\n${added}` : base || added || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Module stat bonus as display rows (non-zero entries only).
|
||||
* @param {Record<string, number> | null | undefined} attr
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Left team panel (research 06 §11.1, research 09 §3.1): one row per seat — avatar (band icon once picked), name, LP
|
||||
// tower, status glyph (… acting / ✓ ready / ⌛ deciding / ⚔ combat / door left / ✕ dead), AI badge, "you" marker, the
|
||||
// field being watched (eye badge), and emote bubbles.
|
||||
// field being watched (eye badge), and emote bubbles. In a boss round the viewer's pair is framed in green from the
|
||||
// round's start (gameLogic teamFrameIds; community report of 2026-10-06, item 51).
|
||||
// Observing (client-side combat, `observe` prop — the official flow): tapping a teammate's avatar expands a mint
|
||||
// "前往查看" button under the row (when that teammate can be observed now; otherwise the reason is toasted through
|
||||
// onWatch); while observing, the own row shows a "返回战场" button. Without `observe` (server-run combat) a click
|
||||
@@ -19,7 +20,7 @@ import { PHASE } from '../../../shared/constants.js';
|
||||
import { html, Icon, Tooltip } from './components.js';
|
||||
import { PlayerAvatar, LpTower, GIcon, LocalSprite } from './gameComponents.js';
|
||||
import { EmoteBubble } from './emotes.js';
|
||||
import { STATUS_META, sortedPlayers } from './gameLogic.js';
|
||||
import { STATUS_META, sortedPlayers, teamFrameIds } from './gameLogic.js';
|
||||
import { MissTag, uniteRemaining } from './hud.js';
|
||||
import { localAsset } from '../data.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
@@ -79,6 +80,10 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
|
||||
useEffect(() => { setOpenPid(null); }, [phaseKey, watching, observe?.observing]);
|
||||
const players = sortedPlayers(pub);
|
||||
if (!players.length) return null;
|
||||
// a boss round: the viewer's pair framed in green from the round's start (item 51 — the official bg_team_border,
|
||||
// tinted like the official green; a plain green ring without the local art)
|
||||
const team = teamFrameIds(pub, myId);
|
||||
const frameArt = team.size ? localAsset('ui/battle', 'bg_team_border') : null;
|
||||
const click = (p, self) => {
|
||||
if (!observe) { onWatch(p); return; }
|
||||
if (self) { if (observe.observing) observe.onBack(); setOpenPid(null); return; }
|
||||
@@ -98,9 +103,11 @@ export function TeamPanel({ pub, myId, watching, bubbles, onWatch, compact = fal
|
||||
const back = !!observe && self && observe.observing;
|
||||
const title = observe ? (self ? (observe.observing ? t('返回战场') : t('你自己')) : t('查看 {name} 的战场', { name: p.name })) : (self ? t('查看自己的阵地') : t('查看 {name} 的阵地', { name: p.name }));
|
||||
const lp = rowLp(p, pub, self ? selfLive : null, { uniteLocal, cap });
|
||||
return html`<div key=${p.playerId} class=${cx('team__row', self && 'is-self', watched && 'is-watched', p.alive === false && 'is-dead', open && 'is-open')}>
|
||||
<button type="button" class="team__btn" onClick=${() => click(p, self)} title=${title} aria-expanded=${observe && !self ? String(open) : undefined}>
|
||||
const inTeam = team.has(p.playerId);
|
||||
return html`<div key=${p.playerId} class=${cx('team__row', self && 'is-self', inTeam && 'is-team', watched && 'is-watched', p.alive === false && 'is-dead', open && 'is-open')}>
|
||||
<button type="button" class="team__btn" onClick=${() => click(p, self)} title=${inTeam && !self ? `${title} · ${t('与你在同一战场')}` : title} aria-expanded=${observe && !self ? String(open) : undefined}>
|
||||
<${PlayerAvatar} player=${p} self=${self} />
|
||||
${inTeam ? html`<span class=${cx('team__frame', !frameArt && 'team__frame--plain')} style=${frameArt ? `--frame:url("${frameArt}")` : undefined} aria-hidden="true"></span>` : null}
|
||||
<span class="team__seat num">P${(p.seat ?? 0) + 1}</span>
|
||||
${p.isBot ? html`<span class="team__ai">AI</span>` : null}
|
||||
${self ? html`<span class="team__you"><${Icon} name="user" /></span>` : null}
|
||||
|
||||
Reference in new issue
Block a user