Files
sendou.ink/app/features/scanner/components/ScreenshotPage.tsx
2026-09-09 21:46:10 +03:00

1005 lines
29 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import clsx from "clsx";
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { useSearchParam } from "~/modules/search-params/hooks";
import { mainWeaponImageUrl } from "~/utils/urls";
import { CANONICAL_HEIGHT, CANONICAL_WIDTH, type Roi } from "../core/canonical";
import type { DeathData } from "../core/detectors/death/index";
import * as death from "../core/detectors/death/rois";
import type { KillData } from "../core/detectors/kill/index";
import * as kill from "../core/detectors/kill/rois";
import type { MapStartData } from "../core/detectors/map-start/index";
import * as mapStart from "../core/detectors/map-start/rois";
import type { MinimapData } from "../core/detectors/minimap/index";
import * as minimap from "../core/detectors/minimap/rois";
import type { ObjectiveData } from "../core/detectors/objective/index";
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
type PlayerStatusLayout,
} from "../core/detectors/objective/player-status";
import * as objective from "../core/detectors/objective/rois";
import type { ScoreboardRowDebug } from "../core/detectors/scoreboard/index";
import * as sb from "../core/detectors/scoreboard/rois";
import * as bl from "../core/detectors/scoreboard-battle-log/rois";
import * as replay from "../core/detectors/scoreboard-battle-log-replay/rois";
import type { ScoreboardOwnData } from "../core/detectors/scoreboard-own/index";
import * as own from "../core/detectors/scoreboard-own/rois";
import type { DetectedEvent } from "../core/detectors/types";
import { scannerSearchParams } from "../scanner-search-params";
import { claimInspectFrame } from "../store/inspect";
import { AnalyzerClient } from "../worker/client";
import type { WorkerResponse } from "../worker/protocol";
import { downloadEventsCsv } from "./events-csv";
import { type CardData, downloadExpectedJson } from "./fixture-export";
import {
lobbyLabel,
mainWeaponLabel,
modeLabel,
stageLabel,
weaponLabel,
} from "./labels";
import { drawNormalizedCanvas } from "./normalized-canvas";
import { ScannerDropzone } from "./ScannerChrome";
import styles from "./ScreenshotPage.module.css";
type Result = Extract<WorkerResponse, { kind: "result" }>;
/** Draw a ROI crop from the normalized frame, scaled up. */
export function RoiCrop(props: {
frame: HTMLCanvasElement;
roi: Roi;
scale?: number;
}) {
const { frame, roi, scale = 1.5 } = props;
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current!;
canvas.width = Math.round(roi.w * scale);
canvas.height = Math.round(roi.h * scale);
const ctx = canvas.getContext("2d")!;
ctx.imageSmoothingEnabled = false;
ctx.drawImage(
frame,
roi.x,
roi.y,
roi.w,
roi.h,
0,
0,
canvas.width,
canvas.height,
);
}, [frame, roi.x, roi.y, roi.w, roi.h, scale]);
return <canvas ref={ref} />;
}
function Stat(props: { label: string; raw?: unknown; children: ReactNode }) {
return (
<span className={styles.stat}>
<span className={styles.statLabel}>{props.label}</span>
<span className={styles.statValue}>{props.children}</span>
{props.raw != null && props.raw !== "" ? (
<span className={styles.statRaw}>raw: {String(props.raw)}</span>
) : null}
</span>
);
}
function LabeledCrop(props: {
label: string;
frame: HTMLCanvasElement;
roi: Roi;
scale?: number;
}) {
return (
<figure>
<RoiCrop frame={props.frame} roi={props.roi} scale={props.scale} />
<figcaption>{props.label}</figcaption>
</figure>
);
}
/** One pill per player slot: number = alive, ★ = special held, ✗ = splatted. */
function StatusSlots(props: { data: PlayerStatusData }) {
return (
<span className={styles.statusSlots}>
{([0, 1] as const).map((side) => (
<span key={side} className={styles.statusSide}>
{props.data.dead[side].map((dead, slot) => {
const special = !dead && props.data.special[side][slot];
return (
<span
key={slot}
className={clsx(styles.statusSlot, {
[styles.dead]: dead,
[styles.special]: special,
})}
>
{dead ? "✗" : special ? "★" : slot + 1}
</span>
);
})}
</span>
))}
</span>
);
}
/** Per-row parse ROIs, in the same order as the event's players array. */
interface RowRois {
weapon: Roi;
name: Roi;
paint: Roi;
stats: [Roi, Roi, Roi];
}
function scoreboardRows(): RowRois[] {
return sb.ROW_CENTERS.map((cy) => ({
weapon: sb.weaponRoi(cy),
name: sb.nameRoi(cy),
paint: sb.paintRoi(cy),
stats: [sb.statRoi(cy, 0), sb.statRoi(cy, 1), sb.statRoi(cy, 2)],
}));
}
/** winnerSide comes from the event debug: players are ordered winners-first. */
function battleLogRows(winnerSide: string): RowRois[] {
const panels = winnerSide === "bottom" ? [bl.PANEL_DY, 0] : [0, bl.PANEL_DY];
return panels.flatMap((dy) =>
bl.ROW_CENTERS.map((base) => {
const cy = base + dy;
return {
weapon: bl.weaponRoi(cy),
name: bl.nameRoi(cy),
paint: bl.paintRoi(cy),
stats: [bl.statRoi(cy, 0), bl.statRoi(cy, 1), bl.statRoi(cy, 2)] as [
Roi,
Roi,
Roi,
],
};
}),
);
}
/** winnerSide comes from the event debug: players are ordered winners-first. */
function replayRows(winnerSide: string): RowRois[] {
const panels =
winnerSide === "right" ? [replay.PANEL_DX, 0] : [0, replay.PANEL_DX];
return panels.flatMap((dx) =>
replay.ROW_CENTERS.map((cy) => ({
weapon: replay.weaponRoi(cy, dx),
name: replay.nameRoi(cy, dx),
paint: replay.paintRoi(cy, dx),
stats: [
replay.statRoi(cy, dx, 0),
replay.statRoi(cy, dx, 1),
replay.statRoi(cy, dx, 2),
] as [Roi, Roi, Roi],
})),
);
}
/** Same marker language as the objective status pills: ✗ dead, ★ special. */
function playerFlags(p: { dead: boolean; specialReady: boolean }) {
return `${p.dead ? " ✗" : ""}${p.specialReady ? " ★" : ""}`;
}
export function formatTimer(time: number | null) {
if (time === null) return "?:??";
return `${Math.floor(time / 60)}:${String(time % 60).padStart(2, "0")}`;
}
/** One-line recap of what a fired detector read, for the gate list. */
function gateSummary(result: Result): string | null {
const event = result.events[0];
if (!event) return null;
const confidence = `${((event.confidence ?? 0) * 100).toFixed(1)}% conf`;
switch (result.detector) {
case "death": {
const data = event.data as DeathData;
return `${confidence} · splatted by ${weaponLabel(data.weaponType, data.weaponId) ?? "?"} (${data.name ?? "?"})`;
}
case "map-start": {
const data = event.data as MapStartData;
return `${confidence} · ${modeLabel(data.mode) ?? "?"} · ${stageLabel(data.stage) ?? "?"}`;
}
case "scoreboard-own": {
const data = event.data as ScoreboardOwnData;
return `${confidence} · ${[lobbyLabel(data.lobby), modeLabel(data.mode), stageLabel(data.stage)].map((v) => v ?? "?").join(" · ")} · ${mainWeaponLabel(data.weaponId) ?? "?"}`;
}
case "minimap": {
const data = event.data as MinimapData;
const players = data.teammates
.map((p) => `${p.name ?? "?"}${playerFlags(p)}`)
.join(", ");
return `${confidence} · ${data.stage ?? "?"} · ${players}`;
}
case "objective": {
const data = event.data as unknown as ObjectiveData;
return `${confidence} · ${formatTimer(data.time)} · score ${data.score[0] ?? "?"}–${data.score[1] ?? "?"}`;
}
case "kill": {
const data = event.data as unknown as KillData;
return `${confidence} · ${formatTimer(data.time)} · splatted ${data.names.map((name) => name ?? "?").join(", ")}`;
}
default: {
const data = event.data as CardData;
return `${confidence} · scores ${JSON.stringify(data.matchScores)} · ${[lobbyLabel(data.lobby), modeLabel(data.mode), stageLabel(data.stage)].map((v) => v ?? "?").join(" · ")}`;
}
}
}
/** Band covering one side's player-status icon strip, for the crop view. */
function statusStripRoi(layout: PlayerStatusLayout, side: 0 | 1): Roi {
const centers =
layout === "even"
? objective.STATUS_SLOT_CENTERS_EVEN[side]
: layout === "narrow-right"
? objective.STATUS_SLOT_CENTERS_NARROW_RIGHT[side]
: objective.STATUS_SLOT_CENTERS_NARROW_LEFT[side];
const first = centers[0]!;
const last = centers[centers.length - 1]!;
return { x: first - 55, y: 25, w: last - first + 110, h: 115 };
}
function drawOverlay(ctx: CanvasRenderingContext2D, detector: string) {
const rect = (roi: Roi, color: string) => {
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.strokeRect(roi.x, roi.y, roi.w, roi.h);
};
if (detector === "death") {
rect(death.SPLAT_LINE1_ROI, "#34d399");
rect(death.WEAPON_LINE_ROI, "#f87171");
for (let row = 0; row < death.ABILITY_ROWS; row++) {
rect(death.abilityMainRoi(row), "#60a5fa");
for (const slot of [0, 1, 2])
rect(death.abilitySubRoi(row, slot), "#e879f9");
}
rect(death.TAG_NAME_OUTER, "#4ade80");
for (const roi of [
...death.GATE_BURST_PROBES,
...death.GATE_PANEL_PROBES,
]) {
rect(roi, "#facc15");
}
return;
}
if (detector === "objective") {
rect(objective.TIMER_DIGIT_ROI, "#34d399");
for (const side of [0, 1] as const) {
rect(objective.SCORE_ROIS[side], "#f87171");
rect(objective.PENALTY_ROIS[side], "#fb923c");
rect(objective.PLATE_PROBE_ROIS[side], "#facc15");
}
for (const [centers, box, color] of [
[
objective.STATUS_SLOT_CENTERS_EVEN,
objective.STATUS_BODY_BOX_EVEN,
"#60a5fa",
],
[
objective.STATUS_SLOT_CENTERS_NARROW_RIGHT,
objective.STATUS_BODY_BOX_NARROW,
"#e879f9",
],
] as const) {
for (const cx of centers.flat()) {
rect({ x: cx + box.dx, y: box.y, w: box.w, h: box.h }, color);
}
}
return;
}
if (detector === "kill") {
for (let row = 0; row < kill.MAX_ROWS; row++) {
rect(kill.textRoi(row), "#34d399");
rect(kill.skullRoi(row), "#60a5fa");
for (const roi of kill.darkProbes(row)) rect(roi, "#facc15");
}
rect(objective.TIMER_DIGIT_ROI, "#f87171");
return;
}
if (detector === "map-start") {
rect(mapStart.MODE_LABEL_ROI, "#34d399");
rect(mapStart.MODE_BLOCK_ROI, "#f87171");
rect(mapStart.STAGE_ROI, "#60a5fa");
rect(mapStart.GATE_INK_BAND, "#e879f9");
for (const roi of mapStart.GATE_DARK_PROBES) rect(roi, "#facc15");
return;
}
if (detector === "scoreboard-own") {
rect(own.WEAPON_TITLE_BAND, "#f87171");
for (let row = 0; row < own.GEAR_ROWS; row++) {
rect(own.gearMainRoi(row), "#60a5fa");
for (const slot of [0, 1, 2]) rect(own.gearSubRoi(row, slot), "#e879f9");
rect(own.gateStripProbe(row), "#facc15");
}
for (const roi of [
...own.GATE_PANEL_PROBES,
...own.GATE_TITLE_TEXT_PROBES,
]) {
rect(roi, "#facc15");
}
rect(sb.HEADER_LOBBY_BAND, "#34d399");
rect(sb.HEADER_LINE_BAND, "#34d399");
return;
}
if (detector === "minimap") {
for (const card of minimap.CARD_LAYOUTS) {
rect(card.name, "#4ade80");
rect(card.weapon, "#f87171");
rect(card.subTile, "#22d3ee");
for (const [cx, cy] of card.badges)
rect(minimap.badgeRoi(cx, cy), "#60a5fa");
rect(card.cross, "#e879f9");
}
for (const cy of minimap.ENEMY_ROW_CYS) {
rect(minimap.enemyWeaponRoi(cy), "#f87171");
rect(minimap.enemySubTileRoi(cy), "#22d3ee");
for (const cx of minimap.ENEMY_BADGE_XS)
rect(minimap.badgeRoi(cx, cy), "#60a5fa");
rect(minimap.enemyCrossRoi(cy), "#e879f9");
}
for (const roi of [
...minimap.GATE_CLOSE_X_BRIGHT,
minimap.GATE_SPAWN_BRIGHT,
...minimap.GATE_CLOSE_DARK_PROBES,
...minimap.GATE_CLOSE_X_DARK,
...minimap.GATE_SPAWN_DARK_PROBES,
]) {
rect(roi, "#facc15");
}
return;
}
if (detector === "scoreboard-battle-log") {
for (const dy of bl.PANEL_DYS) {
for (const base of bl.ROW_CENTERS) {
const cy = base + dy;
rect(bl.weaponRoi(cy), "#f87171");
rect(bl.nameRoi(cy), "#4ade80");
rect(bl.paintRoi(cy), "#60a5fa");
for (const i of [0, 1, 2] as const) rect(bl.statRoi(cy, i), "#e879f9");
rect(bl.gateDarkProbe(cy), "#facc15");
}
rect(bl.teamScoreRoi(dy), "#60a5fa");
rect(bl.resultTagRoi(dy), "#fb923c");
}
for (const roi of bl.MATCH_SCORE_ROIS) rect(roi, "#60a5fa");
for (const roi of bl.GATE_COLOR_PROBES) rect(roi, "#facc15");
rect(bl.HEADER_TOP_BAND, "#34d399");
rect(bl.HEADER_BOTTOM_BAND, "#34d399");
return;
}
if (detector === "scoreboard-battle-log-replay") {
for (const dx of replay.PANEL_XS) {
for (const cy of replay.ROW_CENTERS) {
rect(replay.weaponRoi(cy, dx), "#f87171");
rect(replay.nameRoi(cy, dx), "#4ade80");
rect(replay.paintRoi(cy, dx), "#60a5fa");
for (const i of [0, 1, 2] as const)
rect(replay.statRoi(cy, dx, i), "#e879f9");
rect(replay.gateFlatProbe(cy, dx), "#facc15");
}
rect(replay.teamScoreRoi(dx), "#60a5fa");
rect(replay.resultTagRoi(dx), "#fb923c");
}
for (const roi of replay.MATCH_SCORE_ROIS) rect(roi, "#60a5fa");
for (const roi of replay.GATE_GAP_PROBES) rect(roi, "#facc15");
rect(replay.HEADER_TOP_BAND, "#34d399");
rect(replay.HEADER_BOTTOM_BAND, "#34d399");
rect(replay.REPLAY_CODE_ROI, "#34d399");
return;
}
for (const cy of sb.ROW_CENTERS) {
rect(sb.weaponRoi(cy), "#f87171");
rect(sb.nameRoi(cy), "#4ade80");
rect(sb.paintRoi(cy), "#60a5fa");
for (const i of [0, 1, 2] as const) rect(sb.statRoi(cy, i), "#e879f9");
rect(sb.gateDarkProbe(cy), "#facc15");
}
for (const roi of sb.TEAM_SCORE_ROIS) rect(roi, "#60a5fa");
for (const roi of sb.MATCH_SCORE_ROIS) rect(roi, "#fb923c");
for (const roi of sb.GATE_PANEL_PROBES) rect(roi, "#facc15");
rect(sb.HEADER_LOBBY_BAND, "#34d399");
rect(sb.HEADER_LINE_BAND, "#34d399");
}
export function ScreenshotPage() {
const displayRef = useRef<HTMLCanvasElement>(null);
const clientRef = useRef<AnalyzerClient | null>(null);
const resultRef = useRef<(r: Result) => void>(() => {});
const doneRef = useRef<() => void>(() => {});
const [frame, setFrame] = useState<HTMLCanvasElement | null>(null);
const [results, setResults] = useState<Record<string, Result>>({});
const [busy, setBusy] = useState(false);
const busyRef = useRef(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
clientRef.current = new AnalyzerClient(
(r) => resultRef.current(r),
(message) => {
setError(message);
setBusy(false);
},
() => doneRef.current(),
// one-shot analyses: re-running the same screenshot must always parse
{ suppressSteadyFrames: false },
);
return () => clientRef.current?.dispose();
}, []);
// the detector whose inspector/overlay is shown: the one that fired
const active = Object.values(results).find((r) => r.events.length > 0);
const activeDetector = active?.detector ?? "scoreboard";
useEffect(() => {
if (!frame) return;
const display = displayRef.current!;
display.width = CANONICAL_WIDTH;
display.height = CANONICAL_HEIGHT;
const ctx = display.getContext("2d")!;
ctx.drawImage(frame, 0, 0);
drawOverlay(ctx, activeDetector);
}, [frame, activeDetector]);
const analyze = useCallback(async (file: File | Blob) => {
// one file at a time: in-flight worker results would land in the next
// file's state through the shared resultRef otherwise
if (busyRef.current) return;
busyRef.current = true;
setError(null);
setBusy(true);
setResults({});
try {
const bitmap = await createImageBitmap(file);
// normalized frame for local crop display, same as the pipeline does
setFrame(drawNormalizedCanvas(bitmap, bitmap.width, bitmap.height));
resultRef.current = (r) => {
setResults((prev) => ({ ...prev, [r.detector]: r }));
};
doneRef.current = () => {
busyRef.current = false;
setBusy(false);
};
const client = clientRef.current!;
await client.whenReady();
if (!client.analyze(bitmap, 0)) {
busyRef.current = false;
setBusy(false);
}
} catch (e) {
setError(String(e));
busyRef.current = false;
setBusy(false);
}
}, []);
// frame handed off from an Inspect click in another browser tab; the
// handoff write races this tab's load, so the claim polls briefly
const [inspectKey, setInspectKey] = useSearchParam(
scannerSearchParams,
"inspect",
);
useEffect(() => {
if (!inspectKey) return;
setInspectKey(null);
claimInspectFrame(inspectKey).then(
(claimedFrame) => {
if (claimedFrame) void analyze(claimedFrame);
else
setError(
"Inspected frame did not arrive — go back to the other tab and press Inspect again",
);
},
(e) => setError(String(e)),
);
}, [inspectKey, setInspectKey, analyze]);
const event = active?.events[0] as DetectedEvent<CardData> | undefined;
const rows = (event?.debug?.rows ?? []) as ScoreboardRowDebug[];
const isReplay = activeDetector === "scoreboard-battle-log-replay";
const isScoreboardBattleLog = activeDetector === "scoreboard-battle-log";
const isDeath = activeDetector === "death";
const isMapStart = activeDetector === "map-start";
const isOwn = activeDetector === "scoreboard-own";
const isMinimap = activeDetector === "minimap";
const isObjective = activeDetector === "objective";
const isKill = activeDetector === "kill";
const winnerSide = String(event?.debug?.winnerSide ?? "left");
const rowRois = isReplay
? replayRows(winnerSide)
: isScoreboardBattleLog
? battleLogRows(winnerSide)
: scoreboardRows();
return (
<div>
<ScannerDropzone onFile={(file) => void analyze(file)}>
Drop a frame (PNG/JPEG) here, or{" "}
<label>
pick a file
<input
type="file"
accept="image/png,image/jpeg"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = ""; // allow re-picking the same file
if (file) void analyze(file);
}}
/>
</label>
{busy ? " — analyzing…" : null}
</ScannerDropzone>
{error ? <p className="text-error">{error}</p> : null}
<div
className={styles.frame}
style={{ display: frame ? "block" : "none" }}
>
<canvas ref={displayRef} />
</div>
{frame && !busy ? (
<p>
<button
type="button"
onClick={() =>
downloadExpectedJson(event?.data ?? null, event?.type)
}
>
Download expected.json
</button>{" "}
<button
type="button"
disabled={!event}
onClick={() =>
downloadEventsCsv(
"screenshot-events.csv",
Object.values(results).flatMap((r) => r.events),
)
}
>
Download CSV
</button>
</p>
) : null}
{Object.keys(results).length > 0 ? (
<div className={styles.gateList}>
{Object.values(results).map((result) => (
<div
key={result.detector}
className={clsx(styles.gateRow, {
[styles.fired]: result.gate.pass,
})}
>
<span className={styles.gateBadge}>
{result.gate.pass ? "fired" : "no fire"}
</span>
<span className={styles.gateName}>{result.detector}</span>
<span className={styles.gateScore}>
{result.gate.score.toFixed(3)}
</span>
<span className={styles.gateNote}>{gateSummary(result)}</span>
</div>
))}
</div>
) : null}
{frame && event && isReplay ? (
<div className={styles.detail}>
<div className={styles.detailStats}>
<Stat label="timestamp">{event.data.timestamp ?? "?"}</Stat>
<Stat label="code" raw={event.debug?.codeRaw}>
{event.data.replayCode ?? "?"}
</Stat>
<Stat label="match scores">
{JSON.stringify(event.data.matchScores)}
</Stat>
<Stat label="winner panel">{winnerSide}</Stat>
</div>
<div className={styles.detailCrops}>
<LabeledCrop
label="header"
frame={frame}
roi={replay.HEADER_TOP_BAND}
/>
<LabeledCrop
label="replay code"
frame={frame}
roi={replay.REPLAY_CODE_ROI}
/>
</div>
</div>
) : null}
{frame && event && isScoreboardBattleLog ? (
<div className={styles.detail}>
<div className={styles.detailStats}>
<Stat label="timestamp">{event.data.timestamp ?? "?"}</Stat>
<Stat label="match scores">
{JSON.stringify(event.data.matchScores)}
</Stat>
<Stat label="winner panel">{winnerSide}</Stat>
</div>
<div className={styles.detailCrops}>
<LabeledCrop
label="header top"
frame={frame}
roi={bl.HEADER_TOP_BAND}
/>
<LabeledCrop
label="header bottom"
frame={frame}
roi={bl.HEADER_BOTTOM_BAND}
/>
</div>
</div>
) : null}
{frame && event && isDeath ? (
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as DeathData;
return (
<>
<div className={styles.detailStats}>
<Stat label="weapon" raw={event.debug?.weaponRaw}>
{weaponLabel(data.weaponType, data.weaponId) ?? "?"}
</Stat>
<Stat label="name" raw={event.debug?.nameRaw}>
{data.name ?? "?"}
</Stat>
<Stat label="abilities">
{data.abilities.map((row) => row.join(" ")).join(" | ")}
</Stat>
</div>
<div className={styles.detailCrops}>
<LabeledCrop
label="weapon line"
frame={frame}
roi={death.WEAPON_LINE_ROI}
/>
<LabeledCrop
label="name tag"
frame={frame}
roi={death.TAG_NAME_OUTER}
/>
</div>
</>
);
})()}
</div>
) : null}
{frame && event && isMapStart ? (
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as MapStartData;
return (
<>
<div className={styles.detailStats}>
<Stat label="mode" raw={event.debug?.modeReading}>
{modeLabel(data.mode) ?? "?"}
</Stat>
<Stat label="stage" raw={event.debug?.stageReading}>
{stageLabel(data.stage) ?? "?"}
</Stat>
</div>
<div className={styles.detailCrops}>
<LabeledCrop
label="mode block"
frame={frame}
roi={mapStart.MODE_BLOCK_ROI}
scale={0.75}
/>
<LabeledCrop
label="stage"
frame={frame}
roi={mapStart.STAGE_ROI}
/>
</div>
</>
);
})()}
</div>
) : null}
{frame && event && isOwn ? (
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as ScoreboardOwnData;
return (
<>
<div className={styles.detailStats}>
<Stat label="weapon" raw={event.debug?.weaponReading}>
{mainWeaponLabel(data.weaponId) ?? "?"}
</Stat>
<Stat label="abilities">
{data.abilities.map((row) => row.join(" ")).join(" | ")}
</Stat>
</div>
<div className={styles.detailCrops}>
<LabeledCrop
label="title band"
frame={frame}
roi={own.WEAPON_TITLE_BAND}
/>
{[0, 1, 2].map((row) => (
<LabeledCrop
key={row}
label={`gear ${row + 1}`}
frame={frame}
roi={{
x: own.GEAR_MAIN_CXS[row]! - 36,
y: own.GEAR_BADGE_CY - 32,
w: 200,
h: 64,
}}
/>
))}
</div>
</>
);
})()}
</div>
) : null}
{frame && event && isMinimap ? (
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as MinimapData;
return (
<>
<div className={styles.detailStats}>
<Stat label="stage">{stageLabel(data.stage) ?? "?"}</Stat>
<Stat label="view">
{data.spectator ? "spectator map" : "POV overlay"}
</Stat>
<Stat label="team">
{data.teammates
.map(
(p) =>
`${p.self ? "self: " : ""}${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})${playerFlags(p)}`,
)
.join(", ") || "—"}
</Stat>
{data.enemies.length > 0 ? (
<Stat label="enemies">
{data.enemies
.map(
(p) =>
`${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})${playerFlags(p)}`,
)
.join(", ")}
</Stat>
) : null}
</div>
{!data.spectator ? (
<div className={styles.detailCrops}>
{minimap.CARD_LAYOUTS.map((card, i) => (
<LabeledCrop
key={i}
label={card.self ? "self card" : `card ${i + 1}`}
frame={frame}
roi={card.name}
/>
))}
</div>
) : null}
</>
);
})()}
</div>
) : null}
{frame && event && isObjective ? (
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as ObjectiveData;
const status = active?.events.find(
(e) => e.type === PLAYER_STATUS_EVENT_TYPE,
) as DetectedEvent<PlayerStatusData> | undefined;
return (
<>
<div className={styles.detailStats}>
<Stat label="timer">{formatTimer(data.time)}</Stat>
<Stat label="score">
{data.score[0] ?? "?"}–{data.score[1] ?? "?"}
</Stat>
<Stat label="penalty">
{data.penalty[0] ?? "—"} / {data.penalty[1] ?? "—"}
</Stat>
<Stat label="control">
{data.control[0]
? "left"
: data.control[1]
? "right"
: "none"}
</Stat>
{status ? (
<>
<Stat label="layout">{status.data.layout}</Stat>
<Stat label="players">
<StatusSlots data={status.data} />
</Stat>
</>
) : null}
</div>
<div className={styles.detailCrops}>
<LabeledCrop
label="left count"
frame={frame}
roi={objective.SCORE_ROIS[0]}
/>
<LabeledCrop
label="timer"
frame={frame}
roi={objective.TIMER_DIGIT_ROI}
/>
<LabeledCrop
label="right count"
frame={frame}
roi={objective.SCORE_ROIS[1]}
/>
{status
? ([0, 1] as const).map((side) => (
<LabeledCrop
key={side}
label={side === 0 ? "left team" : "right team"}
frame={frame}
roi={statusStripRoi(status.data.layout, side)}
scale={0.75}
/>
))
: null}
</div>
</>
);
})()}
</div>
) : null}
{frame && event && isKill ? (
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as KillData;
const killRows = (event.debug?.rows ?? []) as { raw?: string }[];
return (
<>
<div className={styles.detailStats}>
<Stat label="timer" raw={event.debug?.timerRaw}>
{formatTimer(data.time)}
</Stat>
{data.names.map((name, row) => (
<Stat
key={row}
label={`row ${row}`}
raw={killRows[row]?.raw}
>
{name ?? "?"}
</Stat>
))}
</div>
<div className={styles.detailCrops}>
{data.names.map((_, row) => (
<LabeledCrop
key={row}
label={`row ${row}`}
frame={frame}
roi={kill.textRoi(row)}
/>
))}
<LabeledCrop
label="timer"
frame={frame}
roi={objective.TIMER_DIGIT_ROI}
/>
</div>
</>
);
})()}
</div>
) : null}
{frame &&
event &&
!isDeath &&
!isMapStart &&
!isOwn &&
!isMinimap &&
!isObjective &&
!isKill ? (
<table className={styles.inspector}>
<thead>
<tr>
<th>row</th>
<th>weapon crop</th>
<th>top candidates</th>
<th>name</th>
<th>paint</th>
<th>stats (splats / deaths / specials)</th>
</tr>
</thead>
<tbody>
{rowRois.map((roi, i) => {
const player = event.data.players[i];
const dbg = rows[i];
return (
<tr key={i}>
<td>{i}</td>
<td>
<RoiCrop frame={frame} roi={roi.weapon} />
</td>
<td>
<div
className={clsx(
styles.candidates,
styles.weaponCandidates,
)}
>
{dbg?.weapon?.top.map((c) => (
<span className={styles.candidate} key={c.id}>
<img
className={styles.weaponIcon}
src={`${mainWeaponImageUrl(Number(c.id) as MainWeaponId)}.avif`}
alt={c.id}
/>
{c.id}
<span className="text-xxs text-lighter">
{c.score.toFixed(3)}
</span>
</span>
))}
</div>
</td>
<td>
<RoiCrop frame={frame} roi={roi.name} />
<b>{player?.name || "—"}</b>{" "}
<span className="text-xxs text-lighter">
{dbg?.nameScore.toFixed(3)}
</span>
</td>
<td>
<RoiCrop frame={frame} roi={roi.paint} />
<b>{player?.paint ?? "—"}</b>{" "}
<span className="text-xxs text-lighter">
{dbg?.paintScore.toFixed(3)}
</span>
</td>
<td>
<div className={styles.candidates}>
{([0, 1, 2] as const).map((s) => (
<span className={styles.candidate} key={s}>
<RoiCrop frame={frame} roi={roi.stats[s]} scale={2} />
<b>{[player?.ka, player?.d, player?.s][s] ?? "—"}</b>
<span className="text-xxs text-lighter">
{dbg?.statScores[s].toFixed(2)}
</span>
</span>
))}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
) : null}
</div>
);
}