From 2723a3daeac8d0d5ea843bf19082146b45201a0b Mon Sep 17 00:00:00 2001 From: sganggs Date: Sun, 4 Oct 2026 20:38:26 +0800 Subject: [PATCH] =?UTF-8?q?feedback3=20WJ:=20grey=20the=20=E6=8A=98?= =?UTF-8?q?=E5=B0=84=20line=20and=20drop=20its=20icon=20while=20silenced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RES bonus was already off. The enemy card now greys a SILENCE-format line from the live silenced flag, and the status row leaves the 折射 icon out while silence is up. --- public/css/screens/game-panels.css | 2 ++ public/js/render/style.js | 14 ++++++++++++++ public/js/render/textures.js | 9 ++++++++- public/js/render/units.js | 3 ++- public/js/ui/abilityLines.js | 17 +++++++++++++++++ public/js/ui/detailPanel.js | 3 ++- shared/protocol.js | 2 ++ test/match/unitStats.test.js | 1 + test/ui/refraction-view.test.js | 26 ++++++++++++++++++++++++++ 9 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 public/js/ui/abilityLines.js create mode 100644 test/ui/refraction-view.test.js diff --git a/public/css/screens/game-panels.css b/public/css/screens/game-panels.css index 58f34f4f..f1ce6bc2 100644 --- a/public/css/screens/game-panels.css +++ b/public/css/screens/game-panels.css @@ -236,6 +236,8 @@ .dfaction { display: inline-flex; align-items: center; gap: .04rem; font-size: .13rem; color: var(--amber); padding: 0 .08rem 0 .02rem; border: 1px solid rgba(246, 163, 41, .45); } .dfaction img { width: .22rem; height: .22rem; object-fit: contain; } .dabil { margin: 0; padding-left: .2rem; display: flex; flex-direction: column; gap: .05rem; font-size: .15rem; line-height: 1.6; color: var(--text-md); } +.dabil li.is-off { color: var(--text-dim); } +.dabil li.is-off .rt-term { color: var(--text-dim); border-bottom-color: transparent; } /* ---- bond popup ---- */ .bpop { diff --git a/public/js/render/style.js b/public/js/render/style.js index 1eadac19..5bfbdaa4 100644 --- a/public/js/render/style.js +++ b/public/js/render/style.js @@ -129,6 +129,8 @@ export const STATUS_ICON = Object.freeze({ substitute: 'doll', // 禁疗 (sim status 'healFree': 史尔特尔's 余烬 — no heal reaches her until she leaves) healFree: 'healFree', + // 折射 (sim buff 'ab:refraction', visible while the RES bonus is on). The tail of 'ab:refraction' hits this key. + refraction: 'refraction', }); /** Keyword fallbacks for namespaced / content status keys ('ab:frost', 'reed2:scorch', 'skill:shotst_shred' …). */ @@ -140,6 +142,18 @@ const STATUS_GUESS = [ [/neural/i, 'neural'], [/necro|apopt|erosion/i, 'necrosis'], ]; +/** + * The 折射 icon is not drawn while the unit is silenced: the RES bonus is already off (enemies.js refraction) + * and the status must not keep looking active. Other icons stay. + * @param {string} key a b.ev status key + * @param {Set|string[]|null} statuses + */ +export function statusIconSuppressed(key, statuses) { + if (key !== 'ab:refraction' && key !== 'refraction') return false; + if (!statuses) return false; + return typeof statuses.has === 'function' ? statuses.has('silence') : Array.isArray(statuses) && statuses.includes('silence'); +} + /** Icon key (textures STATUS_KEYS) for a b.ev status key or flag name; null when it has no icon. */ export function statusIconKey(key) { if (typeof key !== 'string' || !key) return null; diff --git a/public/js/render/textures.js b/public/js/render/textures.js index c525c0cc..85290290 100644 --- a/public/js/render/textures.js +++ b/public/js/render/textures.js @@ -835,7 +835,7 @@ export function fxAtlas() { return _fx; } -export const STATUS_KEYS = ['stun', 'freeze', 'cold', 'stealth', 'shield', 'fragile', 'sleep', 'invuln', 'silence', 'slow', 'bind', 'fear', 'weaken', 'levitate', 'taunt', 'burn', 'neural', 'necrosis', 'blocked', 'skill', 'doll', 'healFree']; +export const STATUS_KEYS = ['stun', 'freeze', 'cold', 'stealth', 'shield', 'fragile', 'sleep', 'invuln', 'silence', 'slow', 'bind', 'fear', 'weaken', 'levitate', 'taunt', 'burn', 'neural', 'necrosis', 'blocked', 'skill', 'doll', 'healFree', 'refraction']; function drawStatusIcon(c, key, x, y, s) { const cx = x + s / 2, cy = y + s / 2; @@ -883,6 +883,13 @@ function drawStatusIcon(c, key, x, y, s) { c.fillStyle = '#d8e0ff'; c.font = 'bold 14px sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText('Zz', cx, cy + 1); break; } + case 'refraction': { + // 折射: a split beam. Drawn only while the buff is on; units.js drops it under silence. + disc('rgba(12,36,48,0.92)', '#7ee0ff'); + c.strokeStyle = '#d8f7ff'; c.lineWidth = 2; + c.beginPath(); c.moveTo(cx - 8, cy + 6); c.lineTo(cx - 1, cy - 8); c.lineTo(cx + 8, cy + 6); c.moveTo(cx + 2, cy - 2); c.lineTo(cx + 9, cy - 8); c.stroke(); + break; + } case 'silence': { disc('rgba(45,20,55,0.92)', '#e08bff'); c.strokeStyle = '#f0c4ff'; c.lineWidth = 2.2; diff --git a/public/js/render/units.js b/public/js/render/units.js index a63b050d..457ae5d3 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -68,7 +68,7 @@ import { UF, ANIM } from '../../../shared/constants.js'; import { SpineActor } from './spine.js'; import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc, HUD_DISC, ELEMENT_RING } from './textures.js'; -import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, PROJ, statusIconKey } from './style.js'; +import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, PROJ, statusIconKey, statusIconSuppressed } from './style.js'; import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js'; const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); @@ -1359,6 +1359,7 @@ export class UnitView { // a burst's lock ('burnBurst', 'neuralBurst' … — the 爆发冷却) is shown by the element gauge row under the bars // (b.snap `elem`); only a feed without gauges (an older recording) shows it as a status if (this.el && k.endsWith('Burst')) continue; + if (statusIconSuppressed(k, this.statuses)) continue; // 折射 while silenced const icon = statusIconKey(k); // flag-driven states are authoritative (a stale 'stun' status must not outlive the flag) if (!icon || icon === 'stun' || icon === 'freeze' || icon === 'sleep' || icon === 'stealth' || icon === 'invuln') continue; diff --git a/public/js/ui/abilityLines.js b/public/js/ui/abilityLines.js new file mode 100644 index 00000000..4624a350 --- /dev/null +++ b/public/js/ui/abilityLines.js @@ -0,0 +1,17 @@ +// Enemy-card ability lines (detailPanel EnemyDetail). A line whose data format is SILENCE +// (折射: `<$ba.refraction>折射`, format "SILENCE") is off while the enemy is silenced: the card greys it. +// The sim already drops the RES bonus; this is only the line. + +/** + * @param {any[]|null} abilities enemies.json abilities + * @param {boolean} silenced live unit (`unitStatsEntry.silenced`) + * @returns {Array<{ text: string, off: boolean }>} + */ +export function abilityRows(abilities, silenced) { + const list = Array.isArray(abilities) ? abilities : []; + return list.map((a) => { + const text = typeof a === 'string' ? a : (a && (a.textRaw || a.text)) || ''; + const off = !!(silenced && a && typeof a === 'object' && a.format === 'SILENCE'); + return { text, off }; + }); +} diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index 85615250..61cc1f2c 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -35,6 +35,7 @@ import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } fr import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js'; import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings } from './gameLogic.js'; import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js'; +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'; @@ -522,7 +523,7 @@ function EnemyDetail({ enemy, snapHp, count, live = null }) { ${imm.length ? html`

<${Icon} name="shield" />免疫:${imm.join('、')}

` : null} ${Array.isArray(enemy.abilities) && enemy.abilities.length ? html`<${Section} title="能力" micro="ABILITIES"> - + ` : enemy.descRaw || enemy.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${enemy.descRaw || enemy.desc} class="dtext" />` : null}`; } diff --git a/shared/protocol.js b/shared/protocol.js index 10fd5a1b..3dd2ec4c 100644 --- a/shared/protocol.js +++ b/shared/protocol.js @@ -219,6 +219,8 @@ export function unitStatsEntry(u, s = null) { ...statView(cur), base: statView(base), ...(range ? { range } : {}), + // the enemy card greys a SILENCE-format line (折射) from this; absent flags ⇒ not silenced + silenced: !!(cur.flags && cur.flags.silence), }; } diff --git a/test/match/unitStats.test.js b/test/match/unitStats.test.js index 4a6e9c04..f5a3d159 100644 --- a/test/match/unitStats.test.js +++ b/test/match/unitStats.test.js @@ -51,6 +51,7 @@ test('unitStatsEntry: effective stats next to the base, rounded for display; the id: 3, uid: 44, defId: 'chess_x', hp: 813, alive: true, maxHp: 1250, atk: 420, def: 90, res: 12.3, interval: 1.08, blockCnt: 3, moveSpeed: 0, base: { maxHp: 1000, atk: 300, def: 100, res: 10, interval: 1.2, blockCnt: 2, moveSpeed: 0 }, + silenced: false, }); const plain = unitStatsEntry(u); assert.equal(plain.atk, 300, 'no aggregated stats: the base'); diff --git a/test/ui/refraction-view.test.js b/test/ui/refraction-view.test.js new file mode 100644 index 00000000..b44dc3f4 --- /dev/null +++ b/test/ui/refraction-view.test.js @@ -0,0 +1,26 @@ +// 折射 while silenced: the enemy card greys the SILENCE-format line; the status icon is mapped and not drawn +// under silence. The sim already drops the RES bonus (enemies.js) — not asserted here. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { abilityRows } from '../../public/js/ui/abilityLines.js'; +import { statusIconKey, statusIconSuppressed } from '../../public/js/render/style.js'; + +const scout = JSON.parse(readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8')).enemy_1166_dusbr; + +test('折射: the card line greys only while silenced; the icon key exists and is suppressed under silence', () => { + const on = abilityRows(scout.abilities, false); + assert.equal(on.length, 1); + assert.match(on[0].text, /折射/); + assert.equal(on[0].off, false); + const off = abilityRows(scout.abilities, true); + assert.equal(off[0].off, true, 'grey the 折射 line'); + assert.equal(off[0].text, on[0].text, 'the words stay'); + assert.equal(abilityRows([{ text: '普通攻击', format: 'NONE' }], true)[0].off, false); + assert.equal(abilityRows(['plain'], true)[0].off, false); + assert.equal(statusIconKey('ab:refraction'), 'refraction'); + const both = new Set(['ab:refraction', 'silence']); + assert.equal(statusIconSuppressed('ab:refraction', both), true, 'drop the icon while silenced'); + assert.equal(statusIconSuppressed('silence', both), false, 'the silence icon itself stays'); + assert.equal(statusIconSuppressed('ab:refraction', new Set(['ab:refraction'])), false, 'the icon shows while 折射 is on'); +});