Scanner kill feed event

This commit is contained in:
Kalle
2026-09-09 20:51:50 +03:00
parent 9fc1dde2ee
commit e6ab2e2c8e
56 changed files with 1740 additions and 99 deletions

View File

@@ -22,6 +22,8 @@ import {
LOCALIZED_WEAPON_NAMES,
} from "../../app/features/scanner/core/detectors/death/localized-messages";
import { ALL_WEAPON_ENTRIES } from "../../app/features/scanner/core/detectors/death/weapon-names";
import { KILL_MESSAGE_TEMPLATES } from "../../app/features/scanner/core/detectors/kill/localized-messages";
import { KILL_TEXT_HEIGHT } from "../../app/features/scanner/core/detectors/kill/rois";
import type { AtlasMeta } from "../../app/features/scanner/core/glyphs";
import {
ALL_LOBBY_ENTRIES,
@@ -456,3 +458,18 @@ await build("death-tag-name", 42, [
{ family: "BlitzBold", pxs: [53, 54], chars: nameCharset() },
{ family: "Rowdy", pxs: [52, 54], chars: nameCharset() },
]);
// kill feed: the "Splatted <name>!" rows bottom-center, BlitzMain with ~24px
// caps (kill/rois.ts) — the scoreboard-names charset plus every language's row text
const killFeedTexts = KILL_MESSAGE_TEMPLATES.flatMap((t) => [t.pre, t.post]);
await build("kill-feed", KILL_TEXT_HEIGHT, [
{
family: "BlitzMain",
pxs: [28, 29],
chars: [
...nameCharset(),
...NAME_GREEK,
...nameSymbols("BlitzMain"),
...localizedChars(killFeedTexts, "BlitzMain"),
],
},
]);

View File

@@ -13,11 +13,14 @@
* LayoutMsg/Mng_Result_00 replay-browser VICTORY / DEFEAT tags
* LayoutMsg/VS_Beaten_00 (999) death-burst message; the weapon placeholder sits on
* line 1 or 2 by language, so it becomes a per-language template
* LayoutMsg/VS_BeatMessage_00 (000) kill-feed row ("Splatted <name>!"); the name placeholder
* splits it into a per-language pre/post text pair
* CommonMsg/Weapon/WeaponName_* weapon names, mapped to canonical entries via USen
*
* Usage: pnpm scanner:build-localized-entries [path-to-splat3]
* Writes app/features/scanner/core/localized-entries.ts
* and app/features/scanner/core/detectors/death/localized-messages.ts
* and app/features/scanner/core/detectors/kill/localized-messages.ts
*/
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
@@ -38,6 +41,10 @@ const OUT_MESSAGES = new URL(
"../../app/features/scanner/core/detectors/death/localized-messages.ts",
import.meta.url,
).pathname;
const OUT_KILL_MESSAGES = new URL(
"../../app/features/scanner/core/detectors/kill/localized-messages.ts",
import.meta.url,
).pathname;
const CANONICAL_LANG = "USen";
const misc = gameMisc as Record<string, string>;
@@ -270,6 +277,30 @@ for (const lang of languages) {
});
}
interface KillTemplate {
langs: string[];
pre: string;
post: string;
}
// the name placeholder is the first group; KRko carries a second (a particle
// chosen by the name's final syllable) that clean() drops along with markup
const killTemplates: KillTemplate[] = [];
for (const lang of languages) {
const raw = dumps.get(lang)!["LayoutMsg/VS_BeatMessage_00"]!["000"]!;
const line = raw.replace(PLACEHOLDER, SENTINEL).replace(/\[[^\]]*\]/g, "");
if (!line.includes(SENTINEL) || line.includes("\n"))
throw new Error(
`${lang}: kill message is not one line with a name: ${raw}`,
);
const [pre, post] = line
.split(SENTINEL)
.map((s) => s.replace(/[ \t]+/g, " ")) as [string, string];
const existing = killTemplates.find((t) => t.pre === pre && t.post === post);
if (existing) existing.langs.push(lang);
else killTemplates.push({ langs: [lang], pre, post });
}
const WEAPON_MSGS = [
"CommonMsg/Weapon/WeaponName_Main",
"CommonMsg/Weapon/WeaponName_Sub",
@@ -410,11 +441,37 @@ export const LOCALIZED_WEAPON_NAMES: Readonly<
`,
);
writeFileSync(
OUT_KILL_MESSAGES,
`${banner(
`Per-language kill-feed row templates: the "Splatted <name>!" row
* wraps the splatted player's name in language-specific text on either side
* (spaces kept as rendered; either side may be empty).`,
)}
export interface KillMessageTemplate {
/** languages sharing this exact template */
langs: readonly string[];
/** constant text before the name (may end with a space, or be empty) */
pre: string;
/** constant text after the name (may start with a space, or be empty) */
post: string;
}
export const KILL_MESSAGE_TEMPLATES: readonly KillMessageTemplate[] = ${JSON.stringify(
killTemplates,
null,
2,
)};
`,
);
console.info(
`localized-entries: ${languages.length} languages, ` +
`${languageEntries.reduce((n, l) => n + l.stages.length, 0)} stage strings`,
);
console.info(
`localized-messages: ${templates.length} death templates, ` +
`${Object.values(localizedWeaponNames).reduce((n, e) => n + e.length, 0)} localized weapon names`,
`${Object.values(localizedWeaponNames).reduce((n, e) => n + e.length, 0)} localized weapon names, ` +
`${killTemplates.length} kill templates`,
);

View File

@@ -5,8 +5,10 @@
*/
import { loadOpenCV, type Mat } from "../../app/features/scanner/core/cv";
import * as death from "../../app/features/scanner/core/detectors/death/rois";
import * as kill from "../../app/features/scanner/core/detectors/kill/rois";
import * as mapStart from "../../app/features/scanner/core/detectors/map-start/rois";
import * as minimap from "../../app/features/scanner/core/detectors/minimap/rois";
import { TIMER_DIGIT_ROI } from "../../app/features/scanner/core/detectors/objective/rois";
import * as sb from "../../app/features/scanner/core/detectors/scoreboard/rois";
import * as bl from "../../app/features/scanner/core/detectors/scoreboard-battle-log/rois";
import * as replay from "../../app/features/scanner/core/detectors/scoreboard-battle-log-replay/rois";
@@ -22,7 +24,7 @@ const [imagePath, outPath = "roi-overlay.png", detector = "scoreboard"] =
process.argv.slice(2);
if (!imagePath) {
console.error(
"usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/overlay-rois.ts <image> [out.png] [scoreboard|scoreboard-battle-log-replay|scoreboard-battle-log|death|map-start|minimap]",
"usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/overlay-rois.ts <image> [out.png] [scoreboard|scoreboard-battle-log-replay|scoreboard-battle-log|death|kill|map-start|minimap]",
);
process.exit(1);
}
@@ -105,6 +107,13 @@ if (detector === "scoreboard") {
for (const roi of [...death.GATE_BURST_PROBES, ...death.GATE_PANEL_PROBES]) {
rect(frame, roi, [255, 255, 0]);
}
} else if (detector === "kill") {
for (let row = 0; row < kill.MAX_ROWS; row++) {
rect(frame, kill.textRoi(row), [0, 255, 0]);
rect(frame, kill.skullRoi(row), [0, 128, 255]);
for (const roi of kill.darkProbes(row)) rect(frame, roi, [255, 255, 0]);
}
rect(frame, TIMER_DIGIT_ROI, [255, 0, 0]);
} else if (detector === "map-start") {
rect(frame, mapStart.MODE_LABEL_ROI, [0, 255, 0]);
rect(frame, mapStart.MODE_BLOCK_ROI, [255, 0, 0]);

View File

@@ -9,6 +9,10 @@ import {
createDeathDetector,
type DeathData,
} from "../../app/features/scanner/core/detectors/death/index";
import {
createKillDetector,
type KillData,
} from "../../app/features/scanner/core/detectors/kill/index";
import {
createMapStartDetector,
type MapStartData,
@@ -523,3 +527,70 @@ for (const config of configs) {
for (const m of misses) console.info(` ${m}`);
}
}
// Kill fixtures: the feed's stacked rows (names, newest first) and the timer.
{
const detector = createKillDetector(resources);
const fixtures = loadFixtures("kill");
const tally = {
gate: { ok: 0, total: 0 } as Tally,
time: { ok: 0, total: 0 } as Tally,
rows: { ok: 0, total: 0 } as Tally,
names: { ok: 0, total: 0 } as Tally,
};
let charEdits = 0;
let charTotal = 0;
const misses: string[] = [];
for (const fixture of fixtures) {
const { gate, events } = await runDetectorOnFixture<KillData>(
detector,
fixture,
);
const expectPositive = fixture.expected.event === "Kill";
tally.gate.total++;
if (gate.pass === expectPositive) tally.gate.ok++;
if (!expectPositive || !events[0]) continue;
const event = events[0];
const expected = fixture.expected.data ?? {};
if (expected.time !== undefined) {
tally.time.total++;
if (event.data.time === expected.time) tally.time.ok++;
else
misses.push(
`${fixture.name}: time ${event.data.time} != ${expected.time}`,
);
}
if (expected.names !== undefined) {
tally.rows.total++;
if (event.data.names.length === expected.names.length) tally.rows.ok++;
else
misses.push(
`${fixture.name}: ${event.data.names.length} rows != ${expected.names.length}`,
);
for (const [row, want] of expected.names.entries()) {
if (want === null) continue;
tally.names.total++;
const got = event.data.names[row] ?? "";
const dist = editDistance(got, want);
charEdits += dist;
charTotal += want.length;
if (dist === 0) tally.names.ok++;
else misses.push(`${fixture.name}: row ${row} "${got}" != "${want}"`);
}
}
}
console.info(`\n=== kill (${fixtures.length} fixtures) ===`);
console.info(`gate ${pct(tally.gate)}`);
console.info(`time ${pct(tally.time)}`);
console.info(`rows ${pct(tally.rows)}`);
console.info(`names ${pct(tally.names)}`);
console.info(
`name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`,
);
if (misses.length > 0) {
console.info("misses:");
for (const m of misses) console.info(` ${m}`);
}
}