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:
sganggs committed 2026-10-07 02:41:19 +08:00
commit 37558f0d5e
40 files changed
+1815 -607

No files matched your search

+6 -6
View File
@@ -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);
}
/**
+2 -1
View File
@@ -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' }),
+1 -1
View File
@@ -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';
+32 -1
View File
@@ -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
+23
View File
@@ -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
+11 -4
View File
@@ -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}