Files
sendou.ink/app/features/scanner/core/resources.ts
Kalle 41070f4594
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled
Scanner improvements (#3331)
2026-08-09 18:40:52 +03:00

232 lines
6.8 KiB
TypeScript

/**
* Environment-agnostic assembly of ScoreboardResources. Node (filesystem)
* and the worker (HTTP) inject only the four IO primitives; every resource
* key, icon directory, template option set, and atlas name lives here once,
* so adding a resource cannot desync the two loaders. Icon/atlas decoding is
* eager (cheap), but template prep and atlas slicing are deferred behind
* memoized getters — a test process loads the full bundle while its one
* detector touches only a slice, and unused sets (four weapon-template
* builds alone are ~7k resizes) dominated startup.
*/
import { abilities as abilityList } from "~/modules/in-game-lists/abilities";
import {
mainWeaponIds,
specialWeaponIds,
subWeaponIds,
} from "~/modules/in-game-lists/weapon-ids";
import { prepareAbilityTemplates } from "./detectors/death/abilities";
import { BURST_ICON_TEMPLATE_SIZES } from "./detectors/death/rois";
import { prepareMinimapAbilityTemplates } from "./detectors/minimap/abilities";
import {
CARD_WEAPON_BACKGROUND,
MINIMAP_WEAPON_INK_THRESHOLD,
MINIMAP_WEAPON_TEMPLATE_SIZES,
SPECIAL_READY_BACKGROUND,
SPECIAL_READY_INK_THRESHOLD,
SUB_TILE_TEMPLATE_SIZES,
} from "./detectors/minimap/rois";
import type { PlannerStage } from "./detectors/minimap/stage";
import {
STRIP_WEAPON_INK_THRESHOLD,
STRIP_WEAPON_TEMPLATE_BACKGROUND,
STRIP_WEAPON_TEMPLATE_SIZES,
} from "./detectors/objective/rois";
import type { ScoreboardResources } from "./detectors/scoreboard/index";
import { prepareSpecialTemplates } from "./detectors/scoreboard/specials";
import { prepareWeaponTemplates } from "./detectors/scoreboard/weapons";
import { prepareOwnAbilityTemplates } from "./detectors/scoreboard-own/abilities";
import type { GlyphSet } from "./glyphs";
import type { FrameData } from "./image";
export interface ResourceIO {
/** decoded RGBA of the shared game icon img/<dir>/<id>.avif */
readIcon(dir: string, id: string): Promise<FrameData>;
/** glyph atlas by name as a (possibly lazy) getter; () => null when absent */
loadAtlas(name: string): Promise<() => GlyphSet | null>;
/** planner signature atlas as a getter; () => null when absent */
loadPlannerStages(): Promise<() => PlannerStage[] | null>;
}
/** resource key → atlas name under assets/cv/glyphs/ */
const ATLASES = {
paintDigits: "scoreboard-paint-digits",
statDigits: "scoreboard-stat-digits",
teamDigits: "scoreboard-team-digits",
nameGlyphs: "scoreboard-names",
headerLobbyGlyphs: "scoreboard-header-lobby",
headerLineGlyphs: "scoreboard-header-line",
replayCodeGlyphs: "scoreboard-replay-code",
replayResultGlyphs: "scoreboard-replay-result",
deathWeaponGlyphs: "death-weapon",
deathWeaponJaGlyphs: "death-weapon-ja",
deathTagNameGlyphs: "death-tag-name",
mapStartModeGlyphs: "map-start-mode",
mapStartStageGlyphs: "map-start-stage",
} as const;
/** Memoize an expensive template/atlas build for the lazy resource getters. */
function lazy<T>(build: () => T): () => T {
let value: T;
let built = false;
return () => {
if (!built) {
value = build();
built = true;
}
return value;
};
}
/** Requires loadOpenCV() to have resolved. */
export async function assembleScoreboardResources(
io: ResourceIO,
): Promise<ScoreboardResources> {
const icons = (dir: string, ids: readonly (number | string)[]) =>
Promise.all(
ids.map(async (id) => ({
id: String(id),
image: await io.readIcon(dir, String(id)),
})),
);
const [
weaponIcons,
specialIcons,
subIcons,
abilityIcons,
plannerStages,
atlasEntries,
] = await Promise.all([
icons("main-weapons", mainWeaponIds),
icons("special-weapons", specialWeaponIds),
icons("sub-weapons", subWeaponIds),
// UNKNOWN is the garbled-badge template: it competes in matching and
// wins on unreadable slots (see toAbilityWithUnknown in scanner-types.ts)
icons("abilities", [
...abilityList.map((ability) => ability.name),
"UNKNOWN",
]),
io.loadPlannerStages(),
Promise.all(
(Object.entries(ATLASES) as [keyof typeof ATLASES, string][]).map(
async ([key, name]) => [key, await io.loadAtlas(name)] as const,
),
),
]);
const atlas = Object.fromEntries(atlasEntries) as Record<
keyof typeof ATLASES,
() => GlyphSet | null
>;
const weapons = lazy(() => prepareWeaponTemplates(weaponIcons));
const deathBurstWeapons = lazy(() =>
prepareWeaponTemplates(weaponIcons, BURST_ICON_TEMPLATE_SIZES),
);
const minimapCardWeapons = lazy(() =>
prepareWeaponTemplates(weaponIcons, MINIMAP_WEAPON_TEMPLATE_SIZES, {
background: CARD_WEAPON_BACKGROUND,
inkThreshold: MINIMAP_WEAPON_INK_THRESHOLD,
cropToArt: true,
}),
);
const minimapLightWeapons = lazy(() =>
prepareWeaponTemplates(weaponIcons, MINIMAP_WEAPON_TEMPLATE_SIZES, {
background: SPECIAL_READY_BACKGROUND,
inkThreshold: SPECIAL_READY_INK_THRESHOLD,
cropToArt: true,
}),
);
const stripWeapons = lazy(() =>
prepareWeaponTemplates(weaponIcons, STRIP_WEAPON_TEMPLATE_SIZES, {
background: STRIP_WEAPON_TEMPLATE_BACKGROUND,
inkThreshold: STRIP_WEAPON_INK_THRESHOLD,
cropToArt: true,
}),
);
const specials = lazy(() => prepareSpecialTemplates(specialIcons));
const minimapSubWeapons = lazy(() =>
prepareSpecialTemplates(subIcons, SUB_TILE_TEMPLATE_SIZES),
);
const abilities = lazy(() => prepareAbilityTemplates(abilityIcons));
const ownAbilities = lazy(() => prepareOwnAbilityTemplates(abilityIcons));
const minimapAbilities = lazy(() =>
prepareMinimapAbilityTemplates(abilityIcons),
);
return {
get weapons() {
return weapons();
},
get deathBurstWeapons() {
return deathBurstWeapons();
},
get minimapCardWeapons() {
return minimapCardWeapons();
},
get minimapLightWeapons() {
return minimapLightWeapons();
},
get stripWeapons() {
return stripWeapons();
},
get specials() {
return specials();
},
get minimapSubWeapons() {
return minimapSubWeapons();
},
get abilities() {
return abilities();
},
get ownAbilities() {
return ownAbilities();
},
get minimapAbilities() {
return minimapAbilities();
},
get plannerStages() {
return plannerStages();
},
get paintDigits() {
return atlas.paintDigits();
},
get statDigits() {
return atlas.statDigits();
},
get teamDigits() {
return atlas.teamDigits();
},
get nameGlyphs() {
return atlas.nameGlyphs();
},
get headerLobbyGlyphs() {
return atlas.headerLobbyGlyphs();
},
get headerLineGlyphs() {
return atlas.headerLineGlyphs();
},
get replayCodeGlyphs() {
return atlas.replayCodeGlyphs();
},
get replayResultGlyphs() {
return atlas.replayResultGlyphs();
},
get deathWeaponGlyphs() {
return atlas.deathWeaponGlyphs();
},
get deathWeaponJaGlyphs() {
return atlas.deathWeaponJaGlyphs();
},
get deathTagNameGlyphs() {
return atlas.deathTagNameGlyphs();
},
get mapStartModeGlyphs() {
return atlas.mapStartModeGlyphs();
},
get mapStartStageGlyphs() {
return atlas.mapStartStageGlyphs();
},
};
}