0.2.6-dev: port PR #552 by IceCodeNew — an optional 「弹药数量」 setting (default off: the official HUD has no such number, the maintainer's decision of 2026-10-13) shows an ammo skill's rounds left / magazine under its bar, from the exact b.snap ammo counts; sanitised with the other settings, applied by the field view live, all four language packs, the diagnostics line; the simplified board has no ammo bar; DESIGN §31.9; golden: unchanged

This commit is contained in:
sganggs committed 2026-10-13 04:43:39 +08:00
1 parent 231da0efbc
commit 461a7747e1
16 files changed
+244 -17

No files matched your search

+87
View File
@@ -3,6 +3,7 @@
// [[id, left, max]] and `neg` [[id, fill]] beside the unit tuples and samples them from the older snapshot; render/units.js
// draws the ammo skill's segmented yellow bar in place of the SP bar, 伺夜's 狼影 as a row of diamonds and the 我执 pool
// as the red bar over the drained HP bar; an ammo skill has no standing aura (headless fake PIXI, test/render/fakepixi.js).
// 设置「弹药数量」 (PR #552 by @IceCodeNew, off by default) adds the magazine's count under the ammo bar.
// The server side is test/sim/hud-readouts.test.js and test/content/op_hsgma2.test.js.
// Run: node --test test/render/hud-readouts.test.js
@@ -166,6 +167,92 @@ describe('UnitView: the ammo skill\'s segmented bar', () => {
});
});
// 设置「弹药数量」 (PR #552 by @IceCodeNew, DESIGN §31.9): the official HUD has no number under the ammo bar, so the count is an
// option, off by default (the maintainer's decision of 2026-10-13); the settings side is test/ui/ammo-count.test.js
describe('UnitView: the optional ammo count under the bar (设置「弹药数量」)', () => {
const counting = (on = true) => view({}, { settings: { damageNumbers: true, quality: 'high', ammoCount: on } });
const coinY = (v) => v.coinHud.root.position.y;
test('off (the default): nothing is drawn under the magazine, and the rows below stay where they were', () => {
const v = view();
const w = counting(false);
try {
for (const u of [v, w]) { u.sync(sample({ flags: UF.SKILL, ammo: [13, 14], coins: [3, 10] })); frames(u); }
assert.equal(v.ammoText, null, 'never built without the setting');
assert.equal(w.ammoText, null, 'nor with it off');
assert.equal(v.hud.children.some((c) => c instanceof fake.P.Text && /\d+\/14/.test(c.text)), false);
const plain = view();
plain.sync(sample({ coins: [3, 10] }));
frames(plain);
assert.equal(coinY(v), coinY(w));
assert.ok(coinY(v) > coinY(plain), 'the magazine row (thicker than the SP bar) is all that sits above the purse');
plain.destroy();
} finally { v.destroy(); w.destroy(); }
});
test('on: the exact count through spending, refills and capacity changes, also where the cells are too narrow for cuts', () => {
const v = counting();
try {
for (const [left, max] of [[13, 14], [12, 14], [137, 240], [136, 240], [260, 260], [0, 260]]) {
v.sync(sample({ flags: UF.SKILL, ammo: [left, max] }));
frames(v);
assert.equal(v.ammoText?.text, `${left}/${max}`);
assert.equal(v.ammoText.visible, true);
if (max >= 240) assert.equal(v.ammoCuts.filter((c) => c.visible).length, 0, 'a dense magazine draws no cuts, the count still reads');
}
const bw = barWidth(v);
assert.ok(Math.abs(v.ammoText.position.x - (v.spFill.position.x + bw / 2)) < 1e-9, 'centred under the bar');
assert.ok(v.ammoText.position.y > v.spFill.position.y, 'below the magazine');
assert.equal(v.ammoText.anchor.x, 0.5);
assert.equal(v.ammoText.style.fill, COLORS.ammo, 'in the bar\'s yellow');
} finally { v.destroy(); }
});
test('on: hidden when the skill ends, without the snapshot row, in the prep view, after death and for enemies; the rows below make room', () => {
const v = counting();
try {
v.sync(sample({ flags: UF.SKILL, ammo: [5, 14], coins: [3, 10] }));
frames(v);
const withCount = coinY(v);
v.sync(sample({ sp: 6, spMax: 24, coins: [3, 10] })); // the magazine is spent: the SP bar again
frames(v);
assert.equal(v.ammoText.visible, false, 'no stale number after the skill');
assert.ok(withCount > coinY(v), 'the purse row moved down by the count\'s height while it showed');
v.sync(sample({ flags: UF.SKILL, ammo: [5, 14] }));
v.prep = true;
frames(v);
assert.equal(v.ammoText.visible, false, 'the prep view draws no bars');
v.prep = false;
frames(v);
assert.equal(v.ammoText.visible, true);
v.die();
frames(v);
assert.equal(v.ammoText.visible, false, 'a dead unit has no bars');
} finally { v.destroy(); }
const e = view({ id: 2, side: 'enemy', kind: 'enemy', defId: 'enemy_x' }, { settings: { damageNumbers: true, quality: 'high', ammoCount: true } });
try {
e.sync(sample({ hp: 500, flags: UF.SKILL, ammo: [3, 4] }));
frames(e);
assert.equal(e.ammoText, null, 'enemies have no magazine, so no count');
} finally { e.destroy(); }
});
test('turning the setting off takes the count away at once (the field view shares its settings object with the units)', () => {
const v = counting();
try {
v.sync(sample({ flags: UF.SKILL, ammo: [9, 14] }));
frames(v);
assert.equal(v.ammoText.visible, true);
v.ctx.settings.ammoCount = false;
frames(v);
assert.equal(v.ammoText.visible, false);
v.ctx.settings.ammoCount = true;
frames(v);
assert.equal(v.ammoText.visible, true);
} finally { v.destroy(); }
});
});
describe('UnitView: 伺夜\'s 狼影 pips', () => {
const pack = (extra = {}) => view({ kind: 'token', defId: 'token_10028_vigil_wolf', ...extra });
+58
View File
@@ -0,0 +1,58 @@
// test/ui/ammo-count.test.js — 设置「弹药数量」 (PR #552 by @IceCodeNew, ported in 0.2.6, DESIGN §31.9): the remaining ammo
// count under an ammo skill's bar is not part of the official HUD, so it is an option, off by default (the maintainer's
// decision of 2026-10-13, like 「官方伤害数字」). Settings normalisation, the dialog's toggle and its hint in every language
// pack, the field view's live setting, the diagnostics line. What the renderer draws: test/render/hud-readouts.test.js.
// Run: node --test test/ui/ammo-count.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { sanitizeSettings, DEFAULT_SETTINGS } from '../../public/js/ui/gameLogic.js';
import { buildDiagnostics } from '../../public/js/diag.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
const LABEL = '弹药数量';
const HINT = '弹药类技能开启时,在弹药条下方显示剩余弹药 / 总弹药数(如 13/14)。官方界面没有这个数字。';
test('ammoCount is stored with the other settings: off by default and for a profile saved before it, a boolean kept, junk off', () => {
assert.equal(DEFAULT_SETTINGS.ammoCount, false);
assert.equal(sanitizeSettings(null).ammoCount, false);
assert.equal(sanitizeSettings({ bgm: 0.4, officialDamageNumbers: true }).ammoCount, false, 'a profile saved before 0.2.6');
assert.equal(sanitizeSettings({ ammoCount: true }).ammoCount, true);
assert.equal(sanitizeSettings({ ammoCount: false }).ammoCount, false);
for (const bad of ['true', 1, 0, null, {}, []]) assert.equal(sanitizeSettings({ ammoCount: bad }).ammoCount, false, JSON.stringify(bad));
// independent of the damage-number switches
assert.deepEqual(sanitizeSettings({ ammoCount: true, damageNumbers: false, officialDamageNumbers: true }), { ...sanitizeSettings({ damageNumbers: false, officialDamageNumbers: true }), ammoCount: true });
});
test('the settings dialog has the toggle and a hint that the official interface has no such number, in every language pack', () => {
const src = read('public/js/ui/settings.js');
assert.match(src, /<\$\{Toggle\} label=\$\{t\('弹药数量'\)\} micro="AMMO COUNT" value=\$\{s\.ammoCount\} onChange=\$\{\(v\) => updateSettings\(\{ ammoCount: v \}\)\} \/>/);
assert.ok(src.includes(`t('${HINT}')`), 'the hint under it');
assert.ok(src.indexOf("t('官方伤害数字')") < src.indexOf(`t('${LABEL}')`), 'beside the damage-number options');
for (const lang of ['en', 'ja', 'ko', 'zh-TW']) {
const pack = JSON.parse(read(`public/i18n/${lang}.json`));
for (const id of [LABEL, HINT]) assert.ok(typeof pack[id] === 'string' && pack[id].length > 0, `${lang}: ${id}`);
assert.match(pack[HINT], /13\/14/, `${lang}: the example count`);
}
});
test('the field view takes the setting at creation and live (setSettings); the units read it from their shared settings', () => {
const app = read('public/js/render/app.js');
assert.match(app, /const settings = \{ damageNumbers: true, officialDamageNumbers: false, ammoCount: false, quality: 'high', \.\.\.\(opts\.settings \|\| \{\}\) \};/);
assert.match(app, /if \(typeof s\.ammoCount === 'boolean'\) settings\.ammoCount = s\.ammoCount;/);
const units = read('public/js/render/units.js');
assert.match(units, /this\._updateAmmoCount\(showSp && !!ammo && this\.ctx\.settings\?\.ammoCount === true, x0 \+ bw \/ 2, rowsTop, s\)/);
// the simplified board draws no ammo bar (its SP bar is the tuple's SP), so it has nothing to count under
assert.doesNotMatch(read('public/js/ui/fallbackField.js'), /ammo/);
});
test('the diagnostics name the option when it is on', () => {
const state = { me: {}, room: null, match: { public: null, battle: null }, connection: { status: 'online' } };
const line = (settings) => buildDiagnostics({ state, env: {}, settings }).split('\n').find((l) => l.startsWith('settings:'));
assert.equal(line({ quality: 'high', damageNumbers: true, ammoCount: true }), 'settings: quality high | damage numbers | ammo count');
assert.equal(line({ quality: 'high', damageNumbers: true, ammoCount: false }), 'settings: quality high | damage numbers');
});
+1 -1
View File
@@ -722,7 +722,7 @@ describe('keyboard & settings', () => {
test('sanitizeSettings', () => {
assert.deepEqual(sanitizeSettings(null), { ...DEFAULT_SETTINGS });
assert.deepEqual(sanitizeSettings({ bgm: 3, sfx: -1, voice: 2, muted: 'yes', damageNumbers: false, quality: 'ultra' }),
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', voiceOverrides: {}, muted: false, damageNumbers: false, officialDamageNumbers: false, quality: 'high', textSize: 'sm', keys: { ...DEFAULT_SETTINGS.keys } },
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', voiceOverrides: {}, muted: false, damageNumbers: false, officialDamageNumbers: false, ammoCount: false, quality: 'high', textSize: 'sm', keys: { ...DEFAULT_SETTINGS.keys } },
'a saved profile without `keys` (before 0.2.0) gets the default key map (test/ui/feedback5-hotkeys.test.js)');
assert.equal(sanitizeSettings({ bgm: 0.5 }).voice, DEFAULT_SETTINGS.voice, 'a saved profile without `voice` gets the default');
// 语音语言 (0.2.2): 中文 by default — a profile saved before it, or any other value, plays the Chinese dub