Scanner improvements (#3331)
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

This commit is contained in:
Kalle
2026-08-09 18:40:52 +03:00
committed by GitHub
parent f3a744abbe
commit 41070f4594
155 changed files with 7515 additions and 1810 deletions

3
.gitignore vendored
View File

@@ -37,3 +37,6 @@ notepad.txt
# proprietary game fonts for the scanner glyph-atlas builders (scripts/scanner)
/assets/fonts/
*.mp4
*.mkv

View File

@@ -0,0 +1,120 @@
.root {
position: relative;
display: flex;
flex-direction: column;
gap: var(--s-3);
/* horizontal touch drags scrub, vertical ones keep scrolling the page */
touch-action: pan-y;
}
/* pinned to the shared plot area: both cards pad horizontally by --s-3 and
keep their y-labels inside the same fixed gutter */
.plotOverlay {
position: absolute;
inset: 0 var(--s-3) 0 calc(var(--s-3) + var(--plot-gutter));
pointer-events: none;
}
.scrubLine {
position: absolute;
top: 0;
bottom: 0;
border-left: 2px dotted var(--color-text-high);
}
.readout {
position: absolute;
z-index: 2;
display: flex;
flex-direction: column;
gap: var(--s-1-5);
max-width: 16rem;
padding: var(--s-2) var(--s-2-5);
border: var(--border-style);
border-radius: var(--radius-box);
background-color: var(--color-bg);
font-size: var(--font-xs);
transform: translateY(-50%);
white-space: nowrap;
}
.readoutPinned {
top: 0;
left: 50%;
transform: translateX(-50%);
max-width: calc(100% - var(--s-2));
white-space: normal;
}
.readoutTitle {
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.readoutTeam {
display: flex;
flex-direction: column;
gap: var(--s-1);
}
.readoutTeamHeader {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s-1-5);
}
.readoutTeamName {
font-weight: var(--weight-semi);
}
.readoutScore {
font-variant-numeric: tabular-nums;
}
.readoutPenalty,
.readoutControl {
color: var(--color-text-high);
}
.swatch {
flex-shrink: 0;
width: 8px;
height: 8px;
border-radius: 2px;
&.swatchAlpha {
background-color: var(--color-chart-alpha);
}
&.swatchBravo {
background-color: var(--color-chart-bravo);
}
}
.readoutStatusRow {
display: flex;
align-items: center;
gap: var(--s-1-5);
padding-left: calc(8px + var(--s-1-5));
}
.readoutStatusLabel {
&.statusLabelDead {
color: var(--color-error);
}
&.statusLabelSpecial {
color: var(--color-info);
}
}
.readoutWeapons {
display: flex;
align-items: center;
gap: var(--s-1);
}
.unknownWeapon {
opacity: 0.5;
}

View File

@@ -0,0 +1,377 @@
/**
* A game's two scanned-timeline charts stacked on one shared time axis and
* plot width: per-player status bands above the objective-counter chart.
* Hovering scrubs over both — a dotted cursor line spans the charts and a
* readout next to the cursor shows the moment's elapsed time, match clock,
* scores, penalties, who was in control, who was splatted and who had their
* special ready. The chart's own tooltip is turned off in favor of the
* readout.
*/
import clsx from "clsx";
import { memo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { clamp } from "remeda";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { abilityImageUrl } from "~/utils/urls";
import styles from "./GameTimeline.module.css";
import { Image, WeaponImage } from "./Image";
import {
ObjectiveTimeline,
type ObjectiveTimelineEvent,
} from "./ObjectiveTimeline";
import {
formatElapsed,
smoothPenalties,
TIMELINE_PLOT_GUTTER_PX,
} from "./objective-timeline-utils";
import {
PLAYER_STATUS_TAIL_SECONDS,
PlayerStatusTimeline,
type PlayerStatusTimelineSample,
type PlayerStatusTimelineTeam,
statusSpans,
} from "./PlayerStatusTimeline";
/** Cursor position past this fraction of the plot flips the readout to its left side. */
const READOUT_FLIP_RATIO = 0.55;
const READOUT_CURSOR_GAP_PX = 12;
interface GameTimelineProps {
objectiveEvents?: readonly ObjectiveTimelineEvent[];
playerStatusSamples?: readonly PlayerStatusTimelineSample[];
teams: readonly [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam];
}
interface ScrubPosition {
/** px from the plot area's left edge */
x: number;
/** px from the plot area's top edge */
y: number;
width: number;
/** touch scrubs pin the readout to the top so the finger doesn't hide it */
pinned: boolean;
}
export function GameTimeline({
objectiveEvents,
playerStatusSamples,
teams,
}: GameTimelineProps) {
const [scrub, setScrub] = useState<ScrubPosition | null>(null);
const plotRef = useRef<HTMLDivElement>(null);
const objective = (objectiveEvents ?? []).toSorted((a, b) => a.t - b.t);
const samples = (playerStatusSamples ?? []).toSorted((a, b) => a.t - b.t);
const domain = timelineDomain(objective, samples);
if (!domain) return null;
const handlePointer = (event: React.PointerEvent) => {
const rect = plotRef.current?.getBoundingClientRect();
if (!rect || rect.width <= 0) return;
setScrub({
x: clamp(event.clientX - rect.left, { min: 0, max: rect.width }),
y: clamp(event.clientY - rect.top, { min: 0, max: rect.height }),
width: rect.width,
pinned: event.pointerType === "touch",
});
};
return (
<div
className={styles.root}
style={
{
"--plot-gutter": `${TIMELINE_PLOT_GUTTER_PX}px`,
} as React.CSSProperties
}
onPointerDown={handlePointer}
onPointerMove={handlePointer}
onPointerLeave={(event) => {
// touch fires a leave as the finger lifts; keep the readout up instead
if (event.pointerType !== "touch") setScrub(null);
}}
>
<TimelineCharts
objectiveEvents={objectiveEvents}
playerStatusSamples={playerStatusSamples}
teams={teams}
/>
<div className={styles.plotOverlay} ref={plotRef}>
{scrub ? (
<ScrubReadout
scrub={scrub}
domain={domain}
objective={objective}
samples={samples}
teams={teams}
/>
) : null}
</div>
</div>
);
}
/** Memoized so scrubbing re-renders only the overlay, not the chart canvas. */
const TimelineCharts = memo(function TimelineCharts({
objectiveEvents,
playerStatusSamples,
teams,
}: GameTimelineProps) {
const objective = (objectiveEvents ?? []).toSorted((a, b) => a.t - b.t);
const samples = (playerStatusSamples ?? []).toSorted((a, b) => a.t - b.t);
const domain = timelineDomain(objective, samples);
if (!domain) return null;
return (
<>
{samples.length > 0 ? (
<PlayerStatusTimeline samples={samples} teams={teams} domain={domain} />
) : null}
{objective.length > 0 ? (
<ObjectiveTimeline
events={objective}
teamLabels={[teams[0].label, teams[1].label]}
domain={domain}
showTooltip={false}
/>
) : null}
</>
);
});
function ScrubReadout({
scrub,
domain,
objective,
samples,
teams,
}: {
scrub: ScrubPosition;
domain: [number, number];
objective: readonly ObjectiveTimelineEvent[];
samples: readonly PlayerStatusTimelineSample[];
teams: GameTimelineProps["teams"];
}) {
const { t } = useTranslation(["common"]);
const [min, max] = domain;
const time = min + (scrub.x / scrub.width) * (max - min);
const objectiveNow = objectiveStateAt(objective, time);
const statusNow = playerStatusAt(samples, time);
const flipped = scrub.x > scrub.width * READOUT_FLIP_RATIO;
return (
<>
<div className={styles.scrubLine} style={{ left: scrub.x }} />
<div
className={clsx(styles.readout, {
[styles.readoutPinned]: scrub.pinned,
})}
style={
scrub.pinned
? undefined
: {
top: scrub.y,
left: flipped ? undefined : scrub.x + READOUT_CURSOR_GAP_PX,
right: flipped
? scrub.width - scrub.x + READOUT_CURSOR_GAP_PX
: undefined,
}
}
>
<div className={styles.readoutTitle}>
{formatElapsed(time)}
{objectiveNow?.clock != null
? ` · ${t("common:objectiveTimeline.timeLeft", {
time: formatElapsed(objectiveNow.clock),
})}`
: null}
</div>
{([0, 1] as const).map((side) => (
<div key={side} className={styles.readoutTeam}>
<div className={styles.readoutTeamHeader}>
<span
className={clsx(
styles.swatch,
side === 0 ? styles.swatchAlpha : styles.swatchBravo,
)}
/>
<span className={styles.readoutTeamName}>
{teams[side].label}
</span>
{objectiveNow ? (
<span className={styles.readoutScore}>
{objectiveNow.scores[side] ?? "?"}
</span>
) : null}
{objectiveNow?.penalties[side] != null ? (
<span className={styles.readoutPenalty}>
{t("common:objectiveTimeline.penalty", {
value: objectiveNow.penalties[side],
})}
</span>
) : null}
{objectiveNow?.control[side] ? (
<span className={styles.readoutControl}>
{t("common:objectiveTimeline.inControl")}
</span>
) : null}
</div>
{statusNow ? (
<>
<StatusWeaponsRow
label={t("common:playerStatusTimeline.splatted")}
kind="dead"
slots={statusNow.dead[side]}
weapons={teams[side].weapons}
/>
<StatusWeaponsRow
label={t("common:playerStatusTimeline.specialReady")}
kind="special"
slots={statusNow.special[side]}
weapons={teams[side].weapons}
/>
</>
) : null}
</div>
))}
</div>
</>
);
}
function StatusWeaponsRow({
label,
kind,
slots,
weapons,
}: {
label: string;
kind: "dead" | "special";
slots: number[];
weapons: (MainWeaponId | null)[];
}) {
if (slots.length === 0) return null;
return (
<div className={styles.readoutStatusRow}>
<span
className={clsx(
styles.readoutStatusLabel,
kind === "dead" ? styles.statusLabelDead : styles.statusLabelSpecial,
)}
>
{label}
</span>
<span className={styles.readoutWeapons}>
{slots.map((slot) =>
weapons[slot] != null ? (
<WeaponImage
key={slot}
weaponSplId={weapons[slot]}
variant="badge"
size={20}
/>
) : (
<Image
key={slot}
path={abilityImageUrl("UNKNOWN")}
alt="?"
size={20}
className={styles.unknownWeapon}
/>
),
)}
</span>
</div>
);
}
function timelineDomain(
objective: readonly ObjectiveTimelineEvent[],
samples: readonly PlayerStatusTimelineSample[],
): [number, number] | null {
const start = Math.min(
objective[0]?.t ?? Number.POSITIVE_INFINITY,
samples[0]?.t ?? Number.POSITIVE_INFINITY,
);
const end = Math.max(
objective[objective.length - 1]?.t ?? Number.NEGATIVE_INFINITY,
samples.length > 0
? samples[samples.length - 1]!.t + PLAYER_STATUS_TAIL_SECONDS
: Number.NEGATIVE_INFINITY,
);
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
return [start, Math.max(end, start + 1)];
}
interface ObjectiveStateAtTime {
/** seconds shown on the match timer at the latest read; null = unreadable */
clock: number | null;
/** last readable count per team at the scrubbed moment */
scores: [number | null, number | null];
penalties: [number | null, number | null];
control: [boolean, boolean];
}
/** State implied by the last objective read at or before `time`, scores carried across unreadable reads. */
function objectiveStateAt(
sorted: readonly ObjectiveTimelineEvent[],
time: number,
): ObjectiveStateAtTime | null {
let index = -1;
for (let i = 0; i < sorted.length; i++) {
if (sorted[i]!.t > time) break;
index = i;
}
if (index === -1) return null;
const scores: [number | null, number | null] = [null, null];
for (let i = 0; i <= index; i++) {
scores[0] = sorted[i]!.data.score[0] ?? scores[0];
scores[1] = sorted[i]!.data.score[1] ?? scores[1];
}
const penalties = ([0, 1] as const).map(
(side) =>
smoothPenalties(
sorted.map((event) => ({
t: event.t,
penalty: event.data.penalty[side],
})),
)[index] ?? null,
) as [number | null, number | null];
const latest = sorted[index]!;
return {
clock: latest.data.time,
scores,
penalties,
control: [latest.data.control[0], latest.data.control[1]],
};
}
interface PlayerStatusAtTime {
/** slot indexes inside a splatted band at the scrubbed moment, per side */
dead: [number[], number[]];
/** slot indexes inside a special-ready band at the scrubbed moment, per side */
special: [number[], number[]];
}
/** Matches the rendered bands: a player counts only while inside a drawn span. */
function playerStatusAt(
sorted: readonly PlayerStatusTimelineSample[],
time: number,
): PlayerStatusAtTime | null {
if (sorted.length === 0) return null;
const activeSlots = (side: 0 | 1, kind: "dead" | "special") =>
[0, 1, 2, 3].filter((slot) =>
statusSpans(sorted, (sample) => sample[kind][side][slot]!).some(
(span) => span.start <= time && time <= span.end,
),
);
return {
dead: [activeSlots(0, "dead"), activeSlots(1, "dead")],
special: [activeSlots(0, "special"), activeSlots(1, "special")],
};
}

View File

@@ -30,7 +30,11 @@ import { Line } from "react-chartjs-2";
import { useTranslation } from "react-i18next";
import { useThemeColors } from "~/hooks/useThemeColors";
import styles from "./ObjectiveTimeline.module.css";
import { smoothPenalties } from "./objective-timeline-utils";
import {
formatElapsed,
smoothPenalties,
TIMELINE_PLOT_GUTTER_PX,
} from "./objective-timeline-utils";
ChartJS.register(
LinearScale,
@@ -68,9 +72,15 @@ export interface ObjectiveTimelineEvent {
export function ObjectiveTimeline({
events,
teamLabels,
domain,
showTooltip = true,
}: {
events: readonly ObjectiveTimelineEvent[];
teamLabels: readonly [string, string];
/** x-axis range override, to share the player-status timeline's axis */
domain?: [number, number];
/** off when a parent renders its own scrub readout over the chart */
showTooltip?: boolean;
}) {
const { t } = useTranslation(["common"]);
const colors = useThemeColors({
@@ -166,17 +176,19 @@ export function ObjectiveTimeline({
animation: false,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
layout: { autoPadding: false },
scales: {
x: {
type: "linear",
min: sorted[0]!.t,
max: sorted[sorted.length - 1]!.t,
min: domain?.[0] ?? sorted[0]!.t,
max: domain?.[1] ?? sorted[sorted.length - 1]!.t,
grid: { color: colors.border },
border: { color: colors.borderHigh },
ticks: {
color: colors.text,
maxRotation: 0,
maxTicksLimit: 8,
align: "inner",
callback: (value) => formatElapsed(Number(value)),
},
},
@@ -192,6 +204,9 @@ export function ObjectiveTimeline({
afterBuildTicks: (axis) => {
axis.ticks = countAxisTicks(axis.max);
},
afterFit: (axis) => {
axis.width = TIMELINE_PLOT_GUTTER_PX;
},
ticks: { color: colors.text, autoSkip: false },
},
},
@@ -205,6 +220,7 @@ export function ObjectiveTimeline({
},
},
tooltip: {
enabled: showTooltip,
filter: (item) => item.datasetIndex < 2,
callbacks: {
title: (items) => {
@@ -266,16 +282,6 @@ function gridColor(
return value === 0 ? colors.borderHigh : colors.border;
}
/** Position on the x-axis: m:ss, growing an hours part only when needed. */
function formatElapsed(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const rest = String(Math.floor(seconds % 60)).padStart(2, "0");
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${rest}`
: `${minutes}:${rest}`;
}
/** the match timer's M:SS (215 → "3:35") */
function formatClock(seconds: number): string {
const minutes = Math.floor(seconds / 60);

View File

@@ -0,0 +1,94 @@
.container {
display: flex;
flex-direction: column;
gap: var(--s-2);
background-color: var(--color-bg-high);
border-radius: var(--radius-box);
padding: var(--s-2-5) var(--s-3);
}
.legend {
display: flex;
gap: var(--s-4);
justify-content: center;
font-size: var(--font-xs);
color: var(--color-text-high);
}
.legendItem {
display: flex;
align-items: center;
gap: var(--s-1);
}
.legendSwatchDead,
.legendSwatchSpecial {
width: 10px;
height: 10px;
border-radius: 2px;
}
.legendSwatchDead {
background-color: var(--color-error);
}
.legendSwatchSpecial {
background-color: var(--color-info);
}
.team {
display: flex;
flex-direction: column;
gap: var(--s-1);
}
.teamLabel {
font-size: var(--font-xs);
font-weight: 600;
color: var(--color-text-high);
}
.row {
display: flex;
align-items: center;
}
.slotLabel {
display: flex;
justify-content: flex-end;
flex-shrink: 0;
width: var(--plot-gutter, 36px);
padding-right: var(--s-2);
}
.track {
position: relative;
flex: 1;
height: 14px;
overflow: hidden;
border-radius: var(--radius-selector);
background-color: var(--color-bg-higher);
}
.spanDead,
.spanSpecial {
position: absolute;
top: 2px;
bottom: 2px;
min-width: 3px;
border-radius: 2px;
}
.spanDead {
background-color: var(--color-error);
opacity: 0.75;
}
.spanSpecial {
background-color: var(--color-info);
opacity: 0.75;
}
.unknownWeapon {
opacity: 0.5;
}

View File

@@ -0,0 +1,182 @@
/**
* Per-player status bands over a game's scanned icon-strip reads: one row
* per player (weapon icon as the label), a band while the player was
* splatted and another while they held their special, both teams stacked.
* Rendered above the ObjectiveTimeline chart on the same `t` seconds axis —
* pass `domain` so both span the same range. Reads re-confirm an unchanged
* state every few seconds; a longer sample gap means the HUD was not
* observed, so bands never bridge across one (the state there is unknown,
* not continued).
*/
import { useTranslation } from "react-i18next";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { abilityImageUrl } from "~/utils/urls";
import { Image, WeaponImage } from "./Image";
import {
formatElapsed,
TIMELINE_PLOT_GUTTER_PX,
} from "./objective-timeline-utils";
import styles from "./PlayerStatusTimeline.module.css";
/** Consecutive reads further apart than this leave an unknown gap. */
const MAX_BRIDGE_SECONDS = 15;
/** Trailing open band drawn this long past its last confirming read. */
export const PLAYER_STATUS_TAIL_SECONDS = 1;
type PlayerFlags = readonly [boolean, boolean, boolean, boolean];
/** One icon-strip read, sides in `[alpha, bravo]` order. */
export interface PlayerStatusTimelineSample {
/** seconds into the source (video, stream or game) the read was made at */
t: number;
special: readonly [PlayerFlags, PlayerFlags];
dead: readonly [PlayerFlags, PlayerFlags];
}
export interface PlayerStatusTimelineTeam {
label: string;
/** weapon per slot in row order; null/absent slots render a placeholder */
weapons: (MainWeaponId | null)[];
}
export function PlayerStatusTimeline({
samples,
teams,
domain,
}: {
samples: readonly PlayerStatusTimelineSample[];
teams: readonly [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam];
/** x-axis range override, to share the objective chart's axis */
domain?: [number, number];
}) {
const { t } = useTranslation(["common"]);
const sorted = samples.toSorted((a, b) => a.t - b.t);
if (sorted.length === 0) return null;
const min = Math.min(domain?.[0] ?? Number.POSITIVE_INFINITY, sorted[0]!.t);
const max = Math.max(
domain?.[1] ?? 0,
sorted[sorted.length - 1]!.t + PLAYER_STATUS_TAIL_SECONDS,
);
const range = Math.max(1, max - min);
const leftOf = (span: StatusSpan) => `${((span.start - min) / range) * 100}%`;
const widthOf = (span: StatusSpan) =>
`${((span.end - span.start) / range) * 100}%`;
const titleOf = (label: string, span: StatusSpan) =>
`${label} · ${formatElapsed(span.start)}${formatElapsed(span.end)}`;
return (
<div
className={styles.container}
style={
{
"--plot-gutter": `${TIMELINE_PLOT_GUTTER_PX}px`,
} as React.CSSProperties
}
>
<div className={styles.legend}>
<span className={styles.legendItem}>
<span className={styles.legendSwatchDead} />
{t("common:playerStatusTimeline.splatted")}
</span>
<span className={styles.legendItem}>
<span className={styles.legendSwatchSpecial} />
{t("common:playerStatusTimeline.specialReady")}
</span>
</div>
{([0, 1] as const).map((side) => (
<div key={side} className={styles.team}>
<div className={styles.teamLabel}>{teams[side].label}</div>
{[0, 1, 2, 3].map((slot) => (
<div key={slot} className={styles.row}>
<div className={styles.slotLabel}>
<SlotWeapon weaponSplId={teams[side].weapons[slot] ?? null} />
</div>
<div className={styles.track}>
{statusSpans(sorted, (sample) => sample.dead[side][slot]!).map(
(span, i) => (
<div
key={`d${i}`}
className={styles.spanDead}
style={{ left: leftOf(span), width: widthOf(span) }}
title={titleOf(
t("common:playerStatusTimeline.splatted"),
span,
)}
/>
),
)}
{statusSpans(
sorted,
(sample) => sample.special[side][slot]!,
).map((span, i) => (
<div
key={`s${i}`}
className={styles.spanSpecial}
style={{ left: leftOf(span), width: widthOf(span) }}
title={titleOf(
t("common:playerStatusTimeline.specialReady"),
span,
)}
/>
))}
</div>
</div>
))}
</div>
))}
</div>
);
}
function SlotWeapon({ weaponSplId }: { weaponSplId: MainWeaponId | null }) {
if (weaponSplId === null) {
return (
<Image
path={abilityImageUrl("UNKNOWN")}
alt="?"
size={22}
className={styles.unknownWeapon}
/>
);
}
return <WeaponImage weaponSplId={weaponSplId} variant="badge" size={22} />;
}
interface StatusSpan {
start: number;
end: number;
}
/**
* Contiguous stretches where the flag held true: a span opens at its first
* true read and closes at the read that shows false — or one second past
* its last confirmation when the next read is too far away (or the series
* ends) to know what happened in between.
*/
export function statusSpans(
sorted: readonly PlayerStatusTimelineSample[],
flagOf: (sample: PlayerStatusTimelineSample) => boolean,
): StatusSpan[] {
const spans: StatusSpan[] = [];
let start: number | null = null;
let lastTrueT = 0;
for (const sample of sorted) {
const flag = flagOf(sample);
if (start !== null && sample.t - lastTrueT > MAX_BRIDGE_SECONDS) {
spans.push({ start, end: lastTrueT + PLAYER_STATUS_TAIL_SECONDS });
start = null;
}
if (flag) {
start ??= sample.t;
lastTrueT = sample.t;
} else if (start !== null) {
spans.push({ start, end: sample.t });
start = null;
}
}
if (start !== null)
spans.push({ start, end: lastTrueT + PLAYER_STATUS_TAIL_SECONDS });
return spans;
}

View File

@@ -30,12 +30,11 @@ import { Ability } from "../Ability";
import { Avatar } from "../Avatar";
import { SendouButton } from "../elements/Button";
import { SendouPopover } from "../elements/Popover";
import { GameTimeline } from "../GameTimeline";
import { Image, ModeImage, StageImage, WeaponImage } from "../Image";
import {
ObjectiveTimeline,
type ObjectiveTimelineEvent,
} from "../ObjectiveTimeline";
import type { ObjectiveTimelineEvent } from "../ObjectiveTimeline";
import { matchScoresFromObjective } from "../objective-timeline-utils";
import type { PlayerStatusTimelineSample } from "../PlayerStatusTimeline";
import styles from "./MatchTimeline.module.css";
import { type InferredSubstitution, inferSubstitutions } from "./utils";
import type { WeaponPoolWeapon } from "./WeaponPool";
@@ -93,6 +92,8 @@ export interface TimelineMap {
bravo: TimelineScoreboardPlayer[];
/** Objective-counter reads ([alpha, bravo] values) charted above the stats tables. */
objective?: ObjectiveTimelineEvent[];
/** Per-player splat/special bands ([alpha, bravo]) charted above the objective chart. */
playerStatus?: PlayerStatusTimelineSample[];
};
}
@@ -442,12 +443,20 @@ function TimelineScoreboardSection({
</button>
{isExpanded ? (
<div className={styles.scoreboardPanel}>
{scoreboard.objective && scoreboard.objective.length > 0 ? (
<ObjectiveTimeline
events={scoreboard.objective}
teamLabels={[teams.alpha.name, teams.bravo.name]}
/>
) : null}
<GameTimeline
objectiveEvents={scoreboard.objective}
playerStatusSamples={scoreboard.playerStatus}
teams={[
{
label: teams.alpha.name,
weapons: scoreboard.alpha.map((player) => player.weaponSplId),
},
{
label: teams.bravo.name,
weapons: scoreboard.bravo.map((player) => player.weaponSplId),
},
]}
/>
<div className={styles.scoreboardTables}>
<ScoreboardTable
name={teams.alpha.name}

View File

@@ -1,5 +1,12 @@
const PENALTY_BRIDGE_SECONDS = 6;
/**
* Width of the label gutter left of the plot area, shared by the objective
* chart (its y-axis is forced to this width) and the player-status rows (their
* weapon-icon column), so both plots span exactly the same x-range.
*/
export const TIMELINE_PLOT_GUTTER_PX = 36;
/** The count a knockout wins at: the counter runs out and the team takes all of it. */
const FULL_COUNT = 100;
@@ -90,6 +97,19 @@ export function matchScoresFromObjective(
return [lastCountTaken(0), lastCountTaken(1)];
}
/**
* Seconds into the source formatted for display: m:ss, growing an hours
* part only when needed.
*/
export function formatElapsed(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const rest = String(Math.floor(seconds % 60)).padStart(2, "0");
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${rest}`
: `${minutes}:${rest}`;
}
function medianFilterValues(
values: readonly (number | null)[],
): (number | null)[] {

View File

@@ -23,12 +23,16 @@ import { MatchResultTab } from "~/components/match-page/MatchResultTab";
import { MatchRosterTab } from "~/components/match-page/MatchRosterTab";
import { MatchTabs } from "~/components/match-page/MatchTabs";
import type { ObjectiveTimelineEvent } from "~/components/ObjectiveTimeline";
import type { PlayerStatusTimelineSample } from "~/components/PlayerStatusTimeline";
import { logger } from "~/utils/logger";
import type { SendouRouteHandle } from "~/utils/remix.server";
/** Counter reads of a made-up zones game, for previewing the timeline chart. */
const MOCK_OBJECTIVE_EVENTS = mockObjectiveEvents();
/** Icon-strip reads of the same made-up game, for previewing the status bands. */
const MOCK_PLAYER_STATUS_SAMPLES = mockPlayerStatusSamples();
type ActionVariant =
| "winner"
| "counterpick-stage"
@@ -699,6 +703,7 @@ export default function MatchPageTestRoute() {
},
scoreboard: {
objective: MOCK_OBJECTIVE_EVENTS,
playerStatus: MOCK_PLAYER_STATUS_SAMPLES,
scores: [100, 0],
alpha: [
{
@@ -912,3 +917,32 @@ function mockObjectiveEvents(): ObjectiveTimelineEvent[] {
return events;
}
/**
* Staggered respawn and special cycles per player over the same game as
* `mockObjectiveEvents`, sampled at the same cadence.
*/
function mockPlayerStatusSamples(): PlayerStatusTimelineSample[] {
const DURATION_SECONDS = 190;
const SAMPLE_EVERY_SECONDS = 2;
const samples: PlayerStatusTimelineSample[] = [];
for (
let t = SAMPLE_EVERY_SECONDS;
t <= DURATION_SECONDS;
t += SAMPLE_EVERY_SECONDS
) {
const flags = (kind: "dead" | "special", side: number) =>
[0, 1, 2, 3].map((slot) => {
const phase = t + slot * 17 + side * 31;
return kind === "dead" ? phase % 61 < 8 : phase % 47 < 12;
}) as [boolean, boolean, boolean, boolean];
samples.push({
t,
dead: [flags("dead", 0), flags("dead", 1)],
special: [flags("special", 0), flags("special", 1)],
});
}
return samples;
}

View File

@@ -299,6 +299,7 @@ function testMatch(partial: Partial<ScannerMatch> = {}): ScannerMatch {
replayCode: null,
cast: false,
objective: null,
playerStatus: null,
teams: [
{ players: NAMES.slice(0, 4).map((n, i) => player(n, WEAPONS[i]!)) },
{ players: NAMES.slice(4).map((n, i) => player(n, WEAPONS[4 + i]!)) },

View File

@@ -37,6 +37,7 @@ function testMatch(partial: Partial<ScannerMatch> = {}): ScannerMatch {
replayCode: null,
cast: false,
objective: null,
playerStatus: null,
teams: [
{ players: NAMES.slice(0, 4).map((n, i) => player(n, WEAPONS[i]!)) },
{ players: NAMES.slice(4).map((n, i) => player(n, WEAPONS[4 + i]!)) },
@@ -283,3 +284,53 @@ describe("mergeMatches", () => {
expect(merged.matchScores).toEqual([100, 52]);
});
});
describe("playerStatus", () => {
const STATUS: NonNullable<ScannerMatch["playerStatus"]> = {
samples: [
{
t: 60,
time: 240,
special: [
[true, false, false, false],
[false, false, false, false],
],
dead: [
[false, false, false, false],
[false, true, false, false],
],
},
],
};
it("merges whole-series first-ingest-wins", () => {
const filled = Matches.mergeMatches(
testMatch(),
testMatch({ playerStatus: STATUS }),
);
expect(filled.merged.playerStatus).toEqual(STATUS);
expect(filled.changed).toBe(true);
const kept = Matches.mergeMatches(
testMatch({ playerStatus: STATUS }),
testMatch({ playerStatus: { samples: [] } }),
);
expect(kept.merged.playerStatus).toEqual(STATUS);
});
it("side-aligning an incoming match swaps its status samples too", () => {
const incoming = sideSwapped(testMatch({ playerStatus: STATUS }));
const { merged } = Matches.mergeMatches(
testMatch({ winner: null, matchScores: null, playerStatus: null }),
incoming,
);
expect(merged.playerStatus!.samples[0]!.dead).toEqual([
[false, true, false, false],
[false, false, false, false],
]);
expect(merged.playerStatus!.samples[0]!.special).toEqual([
[false, false, false, false],
[true, false, false, false],
]);
});
});

View File

@@ -7,6 +7,7 @@ import type {
ScannerMatch,
ScannerMatchObjective,
ScannerMatchPlayer,
ScannerMatchPlayerStatus,
ScannerMatchTeam,
} from "~/features/scanner/core/scanner-match";
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
@@ -52,6 +53,11 @@ export function canonicalMatch(match: ScannerMatch): ScannerMatch {
cast: match.cast,
objective:
match.objective === null ? null : canonicalObjective(match.objective),
// `?? null` also normalizes rows stored before the field existed
playerStatus:
match.playerStatus == null
? null
: canonicalPlayerStatus(match.playerStatus),
teams: [canonicalTeam(match.teams[0]), canonicalTeam(match.teams[1])],
winner: match.winner,
pov:
@@ -128,6 +134,7 @@ export function mergeMatches(
// whole-series first-ingest-wins: interleaving two partial sample
// series from different scans is not attempted
objective: existing.objective ?? oriented.objective,
playerStatus: existing.playerStatus ?? oriented.playerStatus,
teams: [
mergeTeam(existing.teams[0], oriented.teams[0]),
mergeTeam(existing.teams[1], oriented.teams[1]),
@@ -167,6 +174,19 @@ function canonicalObjective(
};
}
function canonicalPlayerStatus(
playerStatus: ScannerMatchPlayerStatus,
): ScannerMatchPlayerStatus {
return {
samples: playerStatus.samples.map((sample) => ({
t: sample.t,
time: sample.time,
special: [[...sample.special[0]], [...sample.special[1]]],
dead: [[...sample.dead[0]], [...sample.dead[1]]],
})),
};
}
function canonicalTeam(team: ScannerMatchTeam): ScannerMatchTeam {
return {
players: team.players.map(canonicalPlayer),
@@ -291,6 +311,16 @@ function swapSides(match: ScannerMatch): ScannerMatch {
control: [sample.control[1], sample.control[0]],
})),
},
playerStatus:
match.playerStatus == null
? null
: {
samples: match.playerStatus.samples.map((sample) => ({
...sample,
special: [sample.special[1], sample.special[0]],
dead: [sample.dead[1], sample.dead[0]],
})),
},
};
}

View File

@@ -3,6 +3,7 @@ import type {
ScannerMatch,
ScannerMatchObjective,
ScannerMatchPlayer,
ScannerMatchPlayerStatus,
} from "~/features/scanner/core/scanner-match";
import type { ScannerLobby } from "~/features/scanner/scanner-types";
import type {
@@ -58,6 +59,7 @@ function testMatch({
abilities = {},
povIndex = null,
objective = null,
playerStatus = null,
}: {
t?: number;
mode?: ModeShort | null;
@@ -68,6 +70,7 @@ function testMatch({
abilities?: Record<number, AbilityWithUnknown[][]>;
povIndex?: number | null;
objective?: ScannerMatchObjective | null;
playerStatus?: ScannerMatchPlayerStatus | null;
} = {}): ScannerMatch {
const players = names.map(
(name, i): ScannerMatchPlayer => ({
@@ -91,6 +94,7 @@ function testMatch({
replayCode: null,
cast: false,
objective,
playerStatus,
teams: [{ players: players.slice(0, 4) }, { players: players.slice(4) }],
winner: 0,
pov:
@@ -122,6 +126,25 @@ function testObjective(): ScannerMatchObjective {
};
}
function testPlayerStatus(): ScannerMatchPlayerStatus {
return {
samples: [
{
t: 595,
time: 305,
special: [
[true, false, false, false],
[false, false, false, false],
],
dead: [
[false, false, false, false],
[false, true, false, false],
],
},
],
};
}
/** The same game reported with sides in the other on-screen order. */
function swapSides(match: ScannerMatch): ScannerMatch {
return {
@@ -139,6 +162,16 @@ function swapSides(match: ScannerMatch): ScannerMatch {
control: [sample.control[1], sample.control[0]],
})),
},
playerStatus:
match.playerStatus === null
? null
: {
samples: match.playerStatus.samples.map((sample) => ({
...sample,
special: [sample.special[1], sample.special[0]],
dead: [sample.dead[1], sample.dead[0]],
})),
},
winner: match.winner === null ? null : match.winner === 0 ? 1 : 0,
matchScores:
match.matchScores === null
@@ -482,6 +515,43 @@ describe("deriveScoreboardData", () => {
expect(swapped!.objective).toEqual(straight!.objective);
});
it("rebases status samples onto the same origin as the counter's", () => {
const data = derive([
{
data: testMatch({
objective: testObjective(),
playerStatus: testPlayerStatus(),
}),
povUserId: null,
},
]);
// the status read at 595 came first, so it is the shared origin
expect(data!.playerStatus!.samples[0]!.t).toBe(0);
expect(data!.objective!.samples.map((sample) => sample.t)).toEqual([5, 35]);
});
it("derives status samples winner-first", () => {
const straight = derive([
{
data: testMatch({ playerStatus: testPlayerStatus() }),
povUserId: null,
},
]);
const swapped = derive([
{
data: swapSides(testMatch({ playerStatus: testPlayerStatus() })),
povUserId: null,
},
]);
expect(straight!.playerStatus!.samples[0]!.dead).toEqual([
[false, false, false, false],
[false, true, false, false],
]);
expect(swapped!.playerStatus).toEqual(straight!.playerStatus);
});
it("leaves out the objective of a match with no counter reads", () => {
const data = derive([{ data: testMatch(), povUserId: null }]);

View File

@@ -1,6 +1,7 @@
import type {
ScannerMatch,
ScannerMatchObjective,
ScannerMatchPlayerStatus,
} from "~/features/scanner/core/scanner-match";
import type { ScannerLobby } from "~/features/scanner/scanner-types";
import type {
@@ -219,6 +220,12 @@ export interface IngestedScoreboardData {
* was read.
*/
objective?: ScannerMatchObjective;
/**
* per-player special/death samples, teams winner-first and `t` rebased
* onto the same origin as `objective` so both chart on one axis. Absent
* when the icon strip was never read.
*/
playerStatus?: ScannerMatchPlayerStatus;
}
/**
@@ -271,6 +278,7 @@ export function deriveScoreboardData({
scores: view.scores,
players,
...(view.objective ? { objective: view.objective } : null),
...(view.playerStatus ? { playerStatus: view.playerStatus } : null),
};
}
@@ -298,6 +306,8 @@ interface WinnerFirstView {
players: WinnerFirstPlayer[];
/** counter progress with both the sides and `t` already winner-first */
objective: ScannerMatchObjective | null;
/** status samples winner-first, on the same rebased `t` axis */
playerStatus: ScannerMatchPlayerStatus | null;
povIndex: number | null;
/** chronological walk key: wall-clock, else video time, else input order */
order: number;
@@ -331,6 +341,8 @@ function winnerFirstView(
return null;
}
const progressFirstT = firstProgressT(match);
return {
lobby: match.lobby,
mode: match.mode,
@@ -343,7 +355,16 @@ function winnerFirstView(
...player,
name: player.name ?? "",
})),
objective: winnerFirstObjective(match.objective, match.winner),
objective: winnerFirstObjective(
match.objective,
match.winner,
progressFirstT,
),
playerStatus: winnerFirstPlayerStatus(
match.playerStatus ?? null,
match.winner,
progressFirstT,
),
povIndex:
match.pov === null
? null
@@ -354,20 +375,34 @@ function winnerFirstView(
};
}
/**
* The shared `t` origin of a match's progress series: the earliest counter
* or status read, so both rebase onto one axis and stay aligned without
* the source video.
*/
function firstProgressT(match: ScannerMatch): number {
const ts = [
...(match.objective?.samples ?? []).map((sample) => sample.t),
...(match.playerStatus?.samples ?? []).map((sample) => sample.t),
];
return ts.length > 0 ? Math.min(...ts) : 0;
}
/**
* Puts a match's counter samples in derived-scoreboard shape: per-team
* values winner-first like `scores` and `players`, and `t` rebased to the
* game's first read so the samples stay meaningful without the source video.
* game's first progress read so the samples stay meaningful without the
* source video.
*/
function winnerFirstObjective(
objective: ScannerMatchObjective | null,
winner: 0 | 1,
firstT: number,
): ScannerMatchObjective | null {
if (!objective || objective.samples.length === 0) return null;
const winnerFirst = <T>(pair: [T, T]): [T, T] =>
winner === 0 ? [pair[0], pair[1]] : [pair[1], pair[0]];
const firstT = Math.min(...objective.samples.map((sample) => sample.t));
return {
mode: objective.mode,
@@ -381,6 +416,27 @@ function winnerFirstObjective(
};
}
/** The status samples winner-first on the shared rebased `t` axis. */
function winnerFirstPlayerStatus(
playerStatus: ScannerMatchPlayerStatus | null,
winner: 0 | 1,
firstT: number,
): ScannerMatchPlayerStatus | null {
if (!playerStatus || playerStatus.samples.length === 0) return null;
const winnerFirst = <T>(pair: [T, T]): [T, T] =>
winner === 0 ? [pair[0], pair[1]] : [pair[1], pair[0]];
return {
samples: playerStatus.samples.map((sample) => ({
t: sample.t - firstT,
time: sample.time,
special: winnerFirst(sample.special),
dead: winnerFirst(sample.dead),
})),
};
}
/**
* Attributes each linked match's POV seat to its POV user on the merged
* rows: the seat's read name picks the row (unique name match), falling

View File

@@ -10,9 +10,8 @@ detected game per object, every field nullable — which feed `/ingest`
emberz repo; see `MIGRATION.md` there.
Deliberate convention exceptions (dev tool, ported wholesale): the UI is
English-only (no i18next) and styled by one global `components/styles.css`
instead of per-component CSS modules; `tests/node-test-compat.ts` uses a
default export to stay a `node:test` drop-in.
English-only (no i18next) and `tests/node-test-compat.ts` uses a default
export to stay a `node:test` drop-in.
## Commands
@@ -83,12 +82,68 @@ sequenceDiagram
(respawn overlay), `map-start` (match intro), `minimap` (in-match overlay
+ casted 8-player spectator variant), `objective` (ranked counter overlay:
counts, penalties, holder, match timer — a mode-discriminated union with
only the SZ member so far). Objective reads land on `ScannerMatch` as
progress samples anchored to the game clock. Reads grouping into a match
only the SZ member so far). The objective parse also emits a second
event type per read: `PlayerStatus`
(`core/detectors/objective/player-status.ts`), per-player special/dead
flags off the icon strip flanking the timer (POV and casted spectator
geometries; D-pad camera badges prove the cast layout, but broadcasts
can hide them while keeping cast geometry, so a badge-less frame scores
both geometries on how decisively the bodies read and sticks with the
established layout unless the other wins clearly — the special-ready
wash also pulses, so its dim trough is told apart from a splat by its
pale body, and a cast-layout ready read must also see a washed
(ink-poor) body: pale backdrop or the lead banner leaking past an icon
edge fakes the shoulder glow on the overhead map view's badge-less
strip), with
the same `time` value so the two reads pair downstream; its fixtures
live under `tests/fixtures/player-status/`. Within a side the strip's
slot order is the lobby seating, while the results scoreboard re-sorts
each team per game (attested in the sendou-triton VoD: strip [Planetz,
.52, Neo Splash, Snipewriter] vs rows [.52, Neo Splash, Snipewriter,
Planetz], and the orders differ per game while the seating holds) — so
every 5th counter read also samples a `StripWeapons` evidence event: a
ranked weapon-icon match per alive slot (the squid plate's team ink is
hue-knocked-out to flat grey first; splatted slots grey the render out
and are skipped). Single reads rank the true weapon top-1 only about
half the time; the builder aggregates them across the match — plus the
minimap cards' parsed weapons, whose column order mirrors the strip
seating (attested for the enemy column) — and takes the best-scoring of
the 24 slot→row assignments against the scoreboard's weapons
(`core/slot-row-assignment.ts`), falling back to as-drawn order on thin
or tied evidence. The POV overlay's teammate diamond follows neither
order and maps by card name instead. Strip-weapon fixtures live under
`tests/fixtures/strip-weapons/`. The builder additionally
flips sub-2s dead-flag runs flanked by dense opposite reads — a splat
outlasts the respawn wait, so those are misread blips (background ink
bleeding through a crossed-out icon) — and bridges sub-10s not-ready
gaps between ready reads when no death inside the gap explains them (no
special regains that fast, so the gap is the wash's dim pulse trough). Objective reads land on `ScannerMatch` as
progress samples anchored to the game clock; broadcast replay wipes re-run
an earlier moment with the counter intact, so the builder keeps only the
dominant cluster of clock-zero projections (`t + time`) and drops replay
reads outright (timerless reads follow their preceding anchored
neighbor). A displayed count only ever
ticks down, so the builder keeps each side's longest non-increasing score
run and voids reads off it (surviving OCR blips chart as gaps, not dips). Each read also carries a
per-side team ink color (`core/ink-color.ts` — the plate fill in
control, the digit ink otherwise): casted footage keeps the specced
player's team on the left plate, so the builder orients samples by ink
hue and anchors them to `teams` order via the minimap sub-tile colors
(casts never show a results screen). Reads grouping into a match
whose detected mode is not SZ are lookalike misreads: the builder nulls
that match's `objective` and callers discard the events
(`invalidObjectiveEvents`; Live also stops collecting once a MapStart
reveals a non-SZ mode). Parsing details are in each detector's module
reveals a non-SZ mode). PlayerStatus reads follow the objective pipeline
wholesale: same replay-wipe anchor, cast orientation inherited from the
nearest counter read, nulled together on non-SZ matches, and rendered as
per-player splat/special bands (`~/components/PlayerStatusTimeline.tsx`,
shared with the match page) above the objective chart. Minimap reads
feed the same samples: every card/row carries `dead` (respawn
cross-out) and `specialReady` (special camo) flags, merged in timerless
on the shared replay anchor — and mode-agnostic, so a known non-SZ
match keeps its minimap-sourced samples while its counter/status
misreads are voided. Parsing details
are in each detector's module
header; accuracy-critical matching internals in `core/glyphs.ts` and
`core/detectors/scoreboard/weapons.ts` — read those before touching
recognition code.
@@ -105,8 +160,10 @@ sequenceDiagram
the gate. `checkIntervalS` hard-caps both phases; `attachFrame: false`
keeps continuously-firing events from storing a frame PNG each. Frames no
detector is due for skip canvas readback, and everything is counted in
`core/detectors/telemetry.ts` (VoD tab's telemetry panel). A match's
objective reads render as one step-line timeline
`core/detectors/telemetry.ts` — but only when the VoD tab is opened with
`?telemetry=true` (nothing links there); otherwise the workers skip
collection and the panel stays hidden. A match's objective reads render
as one step-line timeline
(`~/components/ObjectiveTimeline.tsx`, shared with the match page).
- VoD scans (`components/VodPage.tsx`): on the WebCodecs path each worker
demuxes + decodes its own contiguous slice (mediabunny in the worker — no

View File

@@ -0,0 +1,13 @@
button.abilityTrigger {
height: auto;
padding: var(--s-0-5);
background: var(--color-bg-higher);
border: 1px solid var(--color-border-high);
border-radius: var(--radius-selector);
& img {
display: block;
width: var(--field-size-icon);
height: var(--field-size-icon);
}
}

View File

@@ -7,6 +7,8 @@ import { Button } from "react-aria-components";
import { Ability } from "~/components/Ability";
import { SendouPopover } from "~/components/elements/Popover";
import type { AbilityWithUnknown } from "~/modules/in-game-lists/types";
import styles from "./AbilityGrid.module.css";
import eventCardStyles from "./EventCard.module.css";
const ROW_LABELS = ["head", "clothes", "shoes"] as const;
@@ -16,7 +18,7 @@ export function AbilityGrid({
abilities: AbilityWithUnknown[][];
}) {
return (
<table className="players">
<table className={eventCardStyles.players}>
<tbody>
{abilities.map((row, i) => (
<tr key={i}>
@@ -48,7 +50,7 @@ export function AbilityPopover({
<SendouPopover
trigger={
<Button
className="ability-trigger"
className={styles.abilityTrigger}
aria-label="Show abilities (from death events)"
>
<Ability ability={trigger} size="TINY" />

View File

@@ -0,0 +1,40 @@
.body {
display: flex;
align-items: center;
gap: var(--s-3);
padding-block: var(--s-0-5);
}
.info {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
min-width: 0;
}
.name {
font-size: var(--font-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.weapon {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.abilities {
display: flex;
align-items: center;
gap: var(--s-2-5);
margin-inline-start: auto;
flex-wrap: wrap;
}
.gear {
display: flex;
align-items: center;
gap: var(--s-0-5);
}

View File

@@ -1,3 +1,4 @@
import clsx from "clsx";
import { Ability } from "~/components/Ability";
import { WeaponImage } from "~/components/Image";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
@@ -5,6 +6,8 @@ import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import styles from "./DeathCard.module.css";
import eventCardStyles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { weaponLabel } from "./labels";
@@ -25,8 +28,8 @@ export function DeathCard(props: {
const weaponName = weaponLabel(data.weaponType, data.weaponId);
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<div className={eventCardStyles.card}>
<div className={eventCardStyles.meta}>
<MetaPills
t={t}
confidence={confidence}
@@ -41,26 +44,26 @@ export function DeathCard(props: {
fixture={{ data, type: "Death" }}
/>
</div>
<div className="teams death">
<div className="team">
<div className="death-body">
<div className={clsx(eventCardStyles.teams, eventCardStyles.death)}>
<div className={eventCardStyles.team}>
<div className={styles.body}>
{data.weaponId !== null && data.weaponType === "MAIN" ? (
<WeaponImage
weaponSplId={data.weaponId as MainWeaponId}
variant="build"
size={28}
className="weapon-icon"
className={eventCardStyles.weaponIcon}
/>
) : null}
<div className="death-info">
<span className="death-name">
<div className={styles.info}>
<span className={styles.name}>
splatted by <b>{data.name ?? "?"}</b>
</span>
<span className="death-weapon">{weaponName ?? "?"}</span>
<span className={styles.weapon}>{weaponName ?? "?"}</span>
</div>
<div className="death-abilities">
<div className={styles.abilities}>
{data.abilities.map((row, i) => (
<div key={i} className="gear">
<div key={i} className={styles.gear}>
{row.map((id, j) => (
<Ability
key={j}

View File

@@ -0,0 +1,142 @@
/* card chrome shared by every detected-event card, plus the sendou.ink send
status strip the feed pages hang under one */
.card {
background: var(--color-bg-high);
border-radius: var(--radius-box);
/* MatchCard's nested event list tightens this */
padding: var(--scanner-card-padding, var(--s-4));
min-width: 0;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: var(--s-2) var(--s-4);
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
font-variant-numeric: tabular-nums;
margin-bottom: var(--s-2);
align-items: center;
}
.teams {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
gap: var(--s-2);
}
/* single hug-content box (own-results cards) */
.solo {
grid-template-columns: max-content;
}
/* single full-width box (death cards) */
.death {
grid-template-columns: 1fr;
}
/* lighter than a solid slab: translucent fill + hairline so the boxes read
as grouping, not chrome */
.team {
border-radius: var(--radius-field);
padding: var(--s-2) var(--s-2-5);
background: color-mix(in oklab, var(--color-bg) 45%, transparent);
border: 1px solid color-mix(in oklab, var(--color-border) 55%, transparent);
min-width: 0;
overflow-x: auto;
& h3 {
margin: 0 0 var(--s-1-5);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-high);
}
&.win h3 {
color: var(--color-error-high);
}
&.lose h3 {
color: var(--color-info-high);
}
}
.players {
width: 100%;
border-collapse: collapse;
font-size: var(--font-xs);
& td {
padding: var(--s-0-5) var(--s-1-5);
white-space: nowrap;
&.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
}
.solo .players {
width: auto;
}
.weaponCell {
display: inline-flex;
align-items: center;
gap: var(--s-1);
}
/* round black pucks, same language as the match card's weapon row */
.weaponIcon {
width: 28px;
height: 28px;
vertical-align: middle;
background: var(--color-bg-badge);
border-radius: var(--radius-full);
padding: var(--s-0-5);
}
/* sendou.ink send status strip under a feed card */
.sendWrap {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.sendStrip {
display: flex;
gap: var(--s-3);
align-items: center;
padding: var(--s-1) var(--s-4);
border-radius: var(--radius-box);
background: var(--color-bg-high);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.sent {
color: var(--color-success-high);
background: var(--color-success-low);
}
.failed {
color: var(--color-error-high);
background: var(--color-error-low);
}
.queued,
.sending {
color: var(--color-info-high);
background: var(--color-info-low);
}
.sendError {
color: inherit;
font-weight: var(--weight-body);
}

View File

@@ -26,6 +26,14 @@ import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
} from "../core/detectors/objective/player-status";
import {
STRIP_WEAPONS_EVENT_TYPE,
type StripWeaponsData,
} from "../core/detectors/objective/strip-weapons";
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
import {
SCOREBOARD_OWN_EVENT_TYPE,
@@ -35,13 +43,16 @@ import { scannerSearchParams } from "../scanner-search-params";
import type { SendStatus } from "../store/events";
import { newInspectKey, putInspectFrame } from "../store/inspect";
import { DeathCard } from "./DeathCard";
import styles from "./EventCard.module.css";
import type { FixtureData } from "./fixture-export";
import { useEventTimeFormatter } from "./format";
import { MapStartCard } from "./MapStartCard";
import { MinimapCard } from "./MinimapCard";
import { ObjectiveCard } from "./ObjectiveCard";
import { PlayerStatusCard } from "./PlayerStatusCard";
import { ScoreboardCard } from "./ScoreboardCard";
import { ScoreboardOwnCard } from "./ScoreboardOwnCard";
import { StripWeaponsCard } from "./StripWeaponsCard";
export type GetFrame = () => Promise<Blob | null | undefined>;
@@ -84,7 +95,7 @@ export function EventCard(props: {
const card = renderCard(type, data, shared, props.abilities);
if (!props.send && !props.onSend) return card;
return (
<div className="send-wrap">
<div className={styles.sendWrap}>
{card}
<SendStrip send={props.send} onSend={props.onSend} />
</div>
@@ -109,12 +120,14 @@ function SendStrip({
const state = send?.state;
const formatSentAt = useEventTimeFormatter();
return (
<div className={clsx("send-strip", state ?? "unsent")}>
<div className={clsx(styles.sendStrip, state ? styles[state] : null)}>
<span>
sendou.ink: {state ? SEND_LABELS[state] : "not sent"}
{state === "sent" && send ? ` ${formatSentAt(send.at)}` : null}
</span>
{send?.error ? <span className="error">{send.error}</span> : null}
{send?.error ? (
<span className={styles.sendError}>{send.error}</span>
) : null}
{onSend && state !== "sent" && state !== "sending" ? (
<button type="button" onClick={onSend}>
{state === "failed" ? "Retry" : "Send"}
@@ -147,6 +160,10 @@ function renderCard(
<MinimapCard {...shared} data={data as MinimapData} />
) : type === OBJECTIVE_EVENT_TYPE ? (
<ObjectiveCard {...shared} data={data as ObjectiveData} />
) : type === PLAYER_STATUS_EVENT_TYPE ? (
<PlayerStatusCard {...shared} data={data as PlayerStatusData} />
) : type === STRIP_WEAPONS_EVENT_TYPE ? (
<StripWeaponsCard {...shared} data={data as StripWeaponsData} />
) : (
<ScoreboardCard
{...shared}

View File

@@ -43,5 +43,5 @@ export function EventTypeIcon({
size?: number;
}) {
const Icon = EVENT_TYPE_ICONS[type] ?? CircleHelp;
return <Icon size={size} aria-hidden className="event-type-icon" />;
return <Icon size={size} aria-hidden />;
}

View File

@@ -0,0 +1,37 @@
.summary {
display: flex;
align-items: center;
gap: var(--s-2-5);
flex-wrap: wrap;
padding: var(--s-0-5) var(--s-1);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.type {
display: inline-flex;
align-items: center;
gap: 5px;
font-variant-numeric: tabular-nums;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: var(--radius-full);
background: var(--color-bg-higher);
color: var(--color-text-high);
}
button.toggle {
border: none;
background: transparent;
color: var(--color-text-accent);
height: auto;
padding: 0;
font-size: var(--font-2xs);
}

View File

@@ -14,6 +14,7 @@ import { SCOREBOARD_EVENT_TYPE } from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log-replay/index";
import { SCOREBOARD_OWN_EVENT_TYPE } from "../core/detectors/scoreboard-own/index";
import styles from "./EventsSummary.module.css";
import { EventTypeIcon } from "./EventTypeIcon";
const EVENT_TYPE_LABELS: Record<string, string> = {
@@ -40,23 +41,23 @@ export function EventsSummary({
const sorted = Object.entries(counts).toSorted((a, b) => b[1] - a[1]);
return (
<div className="events-summary">
<div className={styles.summary}>
{sorted.map(([type, count]) => {
const label = EVENT_TYPE_LABELS[type] ?? type;
return (
<span
key={type}
className="events-summary-type"
className={styles.type}
title={`${count} ${label}${count === 1 ? "" : "s"}`}
>
<span className="events-summary-icon">
<span className={styles.icon}>
<EventTypeIcon type={type} />
</span>
×{count}
</span>
);
})}
<button type="button" className="events-toggle" onClick={onToggle}>
<button type="button" className={styles.toggle} onClick={onToggle}>
{open ? "Hide events" : "Show events"}
</button>
</div>

View File

@@ -0,0 +1,39 @@
/* an unstyled button so the image itself is the target */
button.thumbButton {
height: auto;
padding: 0;
border: none;
background: none;
margin-inline-start: auto;
border-radius: var(--radius-field);
&:hover {
filter: brightness(1.2);
}
}
.thumb {
display: block;
height: 40px;
width: auto;
border-radius: var(--radius-field);
}
.dialog {
width: max-content;
max-width: min(96vw, 1400px);
}
.frameFull {
display: block;
max-width: 100%;
max-height: calc(80dvh - 9rem);
border-radius: var(--radius-field);
}
.frameActions {
display: flex;
justify-content: center;
gap: var(--s-3);
margin-block-start: var(--s-3);
}

View File

@@ -9,6 +9,7 @@ import { ExternalLink, FlaskConical } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import styles from "./FrameThumb.module.css";
import { type FixtureData, saveFixtureFromEvent } from "./fixture-export";
export function FrameThumb({
@@ -60,26 +61,26 @@ export function FrameThumb({
<>
<button
type="button"
className="thumb-button"
className={styles.thumbButton}
title="View frame"
onClick={show}
>
<img className="thumb" src={thumbnail} alt="analyzed frame" />
<img className={styles.thumb} src={thumbnail} alt="analyzed frame" />
</button>
{open ? (
<SendouDialog
isDismissable
aria-label="Analyzed frame"
className="scanner-frame-dialog"
className={styles.dialog}
onClose={() => setOpen(false)}
>
<img
className="frame-full"
className={styles.frameFull}
src={frameUrl ?? thumbnail}
alt="analyzed frame"
/>
{onInspect || onSaveFixture ? (
<div className="frame-actions">
<div className={styles.frameActions}>
{onInspect ? (
<SendouButton
size="small"

View File

@@ -0,0 +1,34 @@
/* the global select is full width, which would break the controls row */
.deviceSelect {
appearance: none;
width: auto;
max-width: 20rem;
border: var(--border-style);
border-radius: var(--radius-field);
background-color: var(--color-bg);
background-image: var(--field-icon-chevron);
background-position: center right var(--field-padding);
background-size: var(--field-size-icon) auto;
background-repeat: no-repeat;
color: var(--color-text);
cursor: pointer;
font-family: inherit;
font-size: var(--font-xs);
height: var(--field-size-sm);
padding: 0 calc(var(--field-size-icon) + var(--field-padding) + var(--s-2)) 0
var(--field-padding);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
outline: none;
&:focus-within {
outline: var(--focus-ring);
outline-offset: 1px;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}

View File

@@ -3,7 +3,7 @@ import { Camera, Ellipsis, FileText, Send, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { ObjectiveTimeline } from "~/components/ObjectiveTimeline";
import { GameTimeline } from "~/components/GameTimeline";
import {
listVideoInputs,
openVirtualCamera,
@@ -16,10 +16,9 @@ import {
type MapStartData,
} from "../core/detectors/map-start/index";
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
import { PLAYER_STATUS_EVENT_TYPE } from "../core/detectors/objective/player-status";
import { STRIP_WEAPONS_EVENT_TYPE } from "../core/detectors/objective/strip-weapons";
import { SCOREBOARD_EVENT_TYPES } from "../core/detectors/registry";
import type { DetectedEvent, GateResult } from "../core/detectors/types";
import type { BuiltMatch } from "../core/match-builder";
@@ -29,6 +28,7 @@ import {
invalidObjectiveEvents,
} from "../core/match-builder";
import { TimelineBuilder } from "../core/timeline/index";
import scannerStyles from "../scanner.module.css";
import {
clearEvents,
deleteEvents,
@@ -44,8 +44,10 @@ import { EventCard } from "./EventCard";
import { EventsSummary } from "./EventsSummary";
import { downloadEventsCsv } from "./events-csv";
import { type FixtureData, saveFixture } from "./fixture-export";
import styles from "./LivePage.module.css";
import { MatchCard } from "./MatchCard";
import { MatchLobbyTabs } from "./MatchLobbyTabs";
import { playerStatusTeams } from "./player-status-view";
import {
aggregateSendStatus,
matchContaining,
@@ -200,7 +202,8 @@ export function LivePage({
for (const event of result.events as DetectedEvent<FixtureData>[]) {
latestParseRef.current = { type: event.type, data: event.data };
if (
event.type === OBJECTIVE_EVENT_TYPE &&
(event.type === OBJECTIVE_EVENT_TYPE ||
event.type === PLAYER_STATUS_EVENT_TYPE) &&
objectiveBlockedRef.current
) {
continue;
@@ -280,7 +283,10 @@ export function LivePage({
const builtMatches = buildScannerMatches(feed);
const skipReasons = ingestSkipReasons(builtMatches);
const groupedEvents = new Set(builtMatches.flatMap((b) => b.sources));
const ungroupedFeed = feed.filter((e) => !groupedEvents.has(e));
// strip weapon evidence is assignment input, not a detection worth a card
const ungroupedFeed = feed.filter(
(e) => !groupedEvents.has(e) && e.type !== STRIP_WEAPONS_EVENT_TYPE,
);
const abilityMap = connectAbilities(feed);
const stop = () => {
@@ -304,7 +310,7 @@ export function LivePage({
return (
<div>
<div className="controls">
<div className={scannerStyles.controls}>
{!running ? (
<>
<button
@@ -318,7 +324,7 @@ export function LivePage({
</button>
<button
type="button"
className="outlined"
className={scannerStyles.outlined}
onClick={() => void start(false)}
>
Start capture (no sending)
@@ -331,7 +337,7 @@ export function LivePage({
</button>
<button
type="button"
className="outlined"
className={scannerStyles.outlined}
disabled={!sendouUser}
title={sendouUser ? undefined : "Log in on sendou.ink first"}
onClick={() => {
@@ -344,7 +350,11 @@ export function LivePage({
</button>
</>
)}
<select value={deviceId} onChange={(e) => setDeviceId(e.target.value)}>
<select
className={styles.deviceSelect}
value={deviceId}
onChange={(e) => setDeviceId(e.target.value)}
>
<option value="">Default camera (OBS Virtual Camera)</option>
{devices.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
@@ -353,17 +363,20 @@ export function LivePage({
))}
</select>
<span
className={clsx("status", {
detected: status === "detected",
watching: status === "watching",
idle: status !== "detected" && status !== "watching",
className={clsx(scannerStyles.status, {
[scannerStyles.detected]: status === "detected",
[scannerStyles.watching]: status === "watching",
[scannerStyles.idle]:
status !== "detected" && status !== "watching",
})}
>
{status}
{gateScore !== null ? ` · gate ${gateScore.toFixed(2)}` : null}
</span>
{liveSend ? (
<span className="status watching">sending matches live</span>
<span className={clsx(scannerStyles.status, scannerStyles.watching)}>
sending matches live
</span>
) : null}
<LiveMenu
canSaveFixture={running}
@@ -389,13 +402,20 @@ export function LivePage({
}}
/>
</div>
{error ? <p className="error">{error}</p> : null}
{sendouError ? <p className="error">{sendouError}</p> : null}
<div className="live-layout">
<video ref={videoRef} className="preview" muted playsInline />
<div className="feed">
{error ? <p className={scannerStyles.error}>{error}</p> : null}
{sendouError ? (
<p className={scannerStyles.error}>{sendouError}</p>
) : null}
<div className={scannerStyles.liveLayout}>
<video
ref={videoRef}
className={scannerStyles.preview}
muted
playsInline
/>
<div className={scannerStyles.feed}>
{feed.length === 0 ? (
<p className="score">No detections yet.</p>
<p className={scannerStyles.score}>No detections yet.</p>
) : null}
<MatchLobbyTabs
matches={builtMatches}
@@ -403,15 +423,19 @@ export function LivePage({
renderMatch={(built, justFormed) => {
const id = built.sources[0]!.id!;
const skipReason = skipReasons.get(built);
// counter reads render as one timeline chart, not a card each;
// a non-SZ match's reads (objective null) are never shown
const objectiveEvents = built.match.objective
? built.sources
.filter((e) => e.type === OBJECTIVE_EVENT_TYPE)
.map((e) => ({ t: e.t, data: e.data as ObjectiveData }))
: [];
// counter reads render as one timeline chart, not a card each --
// from the builder's samples, whose sides are team-stable (raw
// reads follow the specced player on casts); a non-SZ match's
// reads (objective null) are never shown
const objectiveEvents = (
built.match.objective?.samples ?? []
).map((sample) => ({ t: sample.t, data: sample }));
const statusSamples = built.match.playerStatus?.samples ?? [];
const cardEvents = withoutRepeatEvents(built.sources).filter(
(e) => e.type !== OBJECTIVE_EVENT_TYPE,
(e) =>
e.type !== OBJECTIVE_EVENT_TYPE &&
e.type !== PLAYER_STATUS_EVENT_TYPE &&
e.type !== STRIP_WEAPONS_EVENT_TYPE,
);
const newest = built === builtMatches.at(-1);
return (
@@ -428,12 +452,11 @@ export function LivePage({
: undefined
}
>
{objectiveEvents.length > 0 ? (
<ObjectiveTimeline
events={objectiveEvents}
teamLabels={SCANNER_TEAM_LABELS}
/>
) : null}
<GameTimeline
objectiveEvents={objectiveEvents}
playerStatusSamples={statusSamples}
teams={playerStatusTeams(built.match, SCANNER_TEAM_LABELS)}
/>
{cardEvents.map((e) => (
<EventCard
key={e.id}
@@ -519,7 +542,7 @@ function LiveMenu({
trigger={
<SendouButton
icon={<Ellipsis />}
className="icon-menu"
className={scannerStyles.iconMenu}
aria-label="More actions"
/>
}

View File

@@ -2,6 +2,7 @@ import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { modeLabel, stageLabel } from "./labels";
@@ -21,8 +22,8 @@ export function MapStartCard(props: {
props;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}

View File

@@ -0,0 +1,391 @@
.group {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.matchCard {
container: match-card / inline-size;
position: sticky;
top: var(--layout-sticky-top);
z-index: 1;
overflow: hidden;
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
/* only cards the scan just formed animate in — see MatchCard's `enter` */
&.enter {
animation: scanner-card-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* one overlay element all send-state effects draw on */
&::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
}
&.sending {
box-shadow: inset 0 0 0 1px var(--color-info-low);
&::after {
background-image: linear-gradient(
105deg,
transparent 40%,
rgb(255 255 255 / 0.09) 50%,
transparent 60%
);
background-size: 250% 100%;
animation: scanner-sheen 1.4s linear infinite;
}
}
&.flashSent::after {
animation: scanner-sent-pop 0.8s ease-out both;
}
&.live {
box-shadow: inset 0 0 0 1px var(--color-info-low);
}
}
@keyframes scanner-card-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
}
@keyframes scanner-sheen {
from {
background-position: 130% 0;
}
to {
background-position: -130% 0;
}
}
@keyframes scanner-sent-pop {
0% {
opacity: 1;
box-shadow:
inset 0 0 0 2px var(--color-success),
inset 0 0 32px var(--color-success-low);
}
100% {
opacity: 0;
box-shadow:
inset 0 0 0 2px var(--color-success),
inset 0 0 32px var(--color-success-low);
}
}
@keyframes scanner-shake {
20% {
transform: translateX(-4px);
}
50% {
transform: translateX(4px);
}
80% {
transform: translateX(-2px);
}
}
.main {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s-2) var(--s-3);
padding: var(--s-3) var(--s-4);
min-height: 66px;
}
.matchCard.flashFailed .main {
animation: scanner-shake 0.35s ease;
}
.mode {
flex-shrink: 0;
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.5));
}
.headline {
display: flex;
flex-direction: column;
gap: var(--s-1);
/* claims row space so the score/chips can never crush the stage name
out of view in a narrow feed column; wraps instead */
flex: 1 1 0;
min-width: 150px;
}
.title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s-1) var(--s-2);
}
.stage {
font-size: var(--font-sm);
font-weight: var(--weight-extra);
line-height: 1.1;
}
.meta {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.weapons {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--s-0-5);
& .vs {
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
margin: 0 5px;
}
}
/* a team is one wrapping unit: the eight never break up mid-team */
.weaponRow {
display: flex;
align-items: center;
gap: var(--s-0-5);
}
.weapon {
flex-shrink: 0;
width: 32px;
height: 32px;
vertical-align: middle;
background: var(--color-bg-badge);
border-radius: var(--radius-full);
padding: var(--s-0-5);
&.pov {
outline: var(--border-style-high);
background-color: var(--color-bg-high);
outline-offset: 1px;
}
}
.side {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--s-1-5);
margin-inline-start: auto;
align-self: stretch;
flex-shrink: 0;
}
.matchScore {
font-weight: var(--weight-extra);
font-variant-numeric: tabular-nums;
line-height: 1;
padding: 5px var(--s-3);
border-radius: var(--radius-full);
background: color-mix(in oklab, var(--color-bg) 72%, transparent);
backdrop-filter: blur(8px);
& .win {
color: var(--color-error-high);
}
& .lose {
color: var(--color-info-high);
}
}
/* undo the scanner-wide solid button look for the banner icon button */
button.expand {
flex-shrink: 0;
margin-block-start: auto;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: var(--radius-full);
background: color-mix(in oklab, var(--color-bg) 55%, transparent);
backdrop-filter: blur(8px);
color: var(--color-text-high);
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
& svg {
width: var(--field-size-icon);
height: var(--field-size-icon);
}
&:hover {
color: var(--color-text);
background: color-mix(in oklab, var(--color-bg) 78%, transparent);
}
&.expanded {
transform: rotate(180deg);
}
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--s-1-5);
padding: var(--s-0-5) var(--s-2-5);
border-radius: var(--radius-full);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
border: var(--border-style);
background-color: var(--color-bg);
color: var(--color-text-high);
white-space: nowrap;
& .dot {
width: 7px;
height: 7px;
border-radius: var(--radius-full);
background: currentColor;
}
&.live {
color: var(--color-error);
border-color: var(--color-error-low);
text-transform: uppercase;
letter-spacing: 0.08em;
& .dot {
animation: scanner-pulse 1.4s ease-in-out infinite;
}
}
&.inProgress .dot {
animation: scanner-pulse 1.4s ease-in-out infinite;
}
&.queued,
&.sending {
color: var(--color-info-high);
border-color: var(--color-info-low);
background-color: var(--color-info-low);
& .dot {
animation: scanner-pulse 1.4s ease-in-out infinite;
}
}
&.sent {
color: var(--color-success-high);
border-color: var(--color-success-low);
background-color: var(--color-success-low);
& a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
color: var(--color-text-high);
}
}
}
&.unlinked {
color: var(--color-warning-high);
border-color: var(--color-warning-low);
background-color: var(--color-warning-low);
}
&.failed {
color: var(--color-error-high);
border-color: var(--color-error-low);
background-color: var(--color-error-low);
}
}
@keyframes scanner-pulse {
50% {
opacity: 0.25;
transform: scale(0.8);
}
}
.error {
padding: 0 var(--s-4) var(--s-2-5);
font-size: var(--font-2xs);
color: var(--color-error);
}
/* source event cards, nested under their match card via an indent rail */
.events {
--scanner-card-padding: var(--s-2-5) 14px;
display: flex;
flex-direction: column;
gap: var(--s-2);
margin-inline-start: var(--s-2-5);
padding-inline-start: var(--s-3);
border-inline-start: var(--border-width) solid var(--color-border);
animation: scanner-detail-in 0.25s ease both;
}
@keyframes scanner-detail-in {
from {
opacity: 0;
transform: translateY(-4px);
}
}
.setDivider {
display: flex;
align-items: center;
gap: var(--s-2-5);
margin-top: var(--s-1-5);
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
text-transform: uppercase;
letter-spacing: 0.08em;
&::before,
&::after {
content: "";
flex: 1;
height: var(--border-width);
background-color: var(--color-border);
}
}
/* the two teams' weapons no longer fit beside each other: one row each */
@container match-card (width < 460px) {
.weapons {
flex-direction: column;
align-items: flex-start;
row-gap: var(--s-1);
& .vs {
display: none;
}
}
}
@media (prefers-reduced-motion: reduce) {
.matchCard,
.matchCard.sending::after,
.matchCard.flashSent::after,
.matchCard.flashFailed .main,
.events,
.chip.live .dot,
.chip.inProgress .dot,
.chip.queued .dot,
.chip.sending .dot {
animation: none;
}
}

View File

@@ -21,6 +21,7 @@ import type { ScannerMatch } from "../core/scanner-match";
import type { SendStatus } from "../store/events";
import { formatTime, useEventTimeFormatter } from "./format";
import { lobbyLabel, modeLabel, stageLabel } from "./labels";
import styles from "./MatchCard.module.css";
/** the game score a knockout wins at */
const KO_MATCH_SCORE = 100;
@@ -89,24 +90,24 @@ export function MatchCard({
const inner = (
<>
<div className="match-card-main">
<div className={styles.main}>
{match.mode !== null ? (
<ModeImage mode={match.mode} size={30} className="match-mode" />
<ModeImage mode={match.mode} size={30} className={styles.mode} />
) : null}
<div className="match-headline">
<div className="match-title">
<div className="match-stage">
<div className={styles.headline}>
<div className={styles.title}>
<div className={styles.stage}>
{stageLabel(match.stage) ?? "Unknown stage"}
</div>
<StatusChip send={send} skipReason={skipReason} live={live} />
</div>
{meta ? <div className="match-meta">{meta}</div> : null}
{meta ? <div className={styles.meta}>{meta}</div> : null}
<TeamWeapons match={match} />
</div>
<div className="match-side">
<div className={styles.side}>
{live ? (
<span className="match-chip live">
<span className="dot" />
<span className={clsx(styles.chip, styles.live)}>
<span className={styles.dot} />
live
</span>
) : (
@@ -125,7 +126,7 @@ export function MatchCard({
size="small"
shape="circle"
icon={<ChevronDown />}
className={clsx("match-expand", { expanded })}
className={clsx(styles.expand, { [styles.expanded]: expanded })}
aria-expanded={expanded}
aria-label={expanded ? "Hide events" : "Show events"}
onPress={() => setExpanded(!expanded)}
@@ -134,17 +135,21 @@ export function MatchCard({
</div>
</div>
{send?.state === "failed" && send.error ? (
<div className="match-error">{send.error}</div>
<div className={styles.error}>{send.error}</div>
) : null}
</>
);
const className = clsx("match-card", send?.state, {
enter,
live,
"flash-sent": flash === "sent",
"flash-failed": flash === "failed",
});
const className = clsx(
styles.matchCard,
send?.state ? styles[send.state] : null,
{
[styles.enter]: enter,
[styles.live]: live,
[styles.flashSent]: flash === "sent",
[styles.flashFailed]: flash === "failed",
},
);
const card =
match.stage !== null ? (
@@ -157,16 +162,16 @@ export function MatchCard({
if (!children) return card;
return (
<div className="match-card-group">
<div className={styles.group}>
{card}
{expanded ? <div className="match-events">{children}</div> : null}
{expanded ? <div className={styles.events}>{children}</div> : null}
</div>
);
}
/** Labeled rule above the newest card of each set in the feed. */
export function SetDivider({ number }: { number: number }) {
return <div className="set-divider">Set {number}</div>;
return <div className={styles.setDivider}>Set {number}</div>;
}
function timeRangeLabel(match: ScannerMatch): string {
@@ -186,8 +191,8 @@ function Score({
if (match.matchScores === null) {
if (!inProgress) return null;
return (
<span className="match-chip in-progress">
<span className="dot" />
<span className={clsx(styles.chip, styles.inProgress)}>
<span className={styles.dot} />
in progress
</span>
);
@@ -200,12 +205,12 @@ function Score({
// scoreboard-sourced matches list the winners first
const winnerKnown = match.winner !== null;
return (
<div className="match-score">
<span className={winnerKnown ? "win" : undefined}>
<div className={styles.matchScore}>
<span className={winnerKnown ? styles.win : undefined}>
{scoreLabel(alpha, objectiveScores[0])}
</span>
<span className="sep"> </span>
<span className={winnerKnown ? "lose" : undefined}>
<span> </span>
<span className={winnerKnown ? styles.lose : undefined}>
{scoreLabel(bravo, objectiveScores[1])}
</span>
</div>
@@ -251,10 +256,10 @@ function TeamWeapons({ match }: { match: ScannerMatch }) {
if (alpha.length + bravo.length === 0) return null;
return (
<div className="match-weapons">
<div className={styles.weapons}>
{alpha.length > 0 ? <WeaponRow weapons={alpha} /> : null}
{alpha.length > 0 && bravo.length > 0 ? (
<span className="vs">vs</span>
<span className={styles.vs}>vs</span>
) : null}
{bravo.length > 0 ? <WeaponRow weapons={bravo} /> : null}
</div>
@@ -264,14 +269,14 @@ function TeamWeapons({ match }: { match: ScannerMatch }) {
/** One team's weapons, kept together when the card is too narrow for both. */
function WeaponRow({ weapons }: { weapons: TeamWeapon[] }) {
return (
<div className="weapon-row">
<div className={styles.weaponRow}>
{weapons.map((weapon, i) => (
<WeaponImage
key={i}
weaponSplId={weapon.weaponId}
variant="build"
size={22}
className={clsx("weapon-icon", { pov: weapon.pov })}
className={clsx(styles.weapon, { [styles.pov]: weapon.pov })}
/>
))}
</div>
@@ -290,7 +295,7 @@ function StatusChip({
const formatSentAt = useEventTimeFormatter();
if (skipReason) {
return (
<span className="match-chip">
<span className={styles.chip}>
{skipReason === "disconnect" ? "disconnect" : "not ingested"}
</span>
);
@@ -298,7 +303,7 @@ function StatusChip({
if (send?.state === "sent") {
return (
<span
className="match-chip sent"
className={clsx(styles.chip, styles.sent)}
title={`ingested ${formatSentAt(send.at)}`}
>
@@ -316,16 +321,19 @@ function StatusChip({
}
if (send) {
return (
<span className={clsx("match-chip", send.state)} title={send.error}>
<span
className={clsx(styles.chip, styles[send.state])}
title={send.error}
>
{send.state === "queued" || send.state === "sending" ? (
<span className="dot" />
<span className={styles.dot} />
) : null}
{SEND_CHIP_LABELS[send.state]}
</span>
);
}
if (live) return null;
return <span className="match-chip">not sent</span>;
return <span className={styles.chip}>not sent</span>;
}
function ingestedMatchUrl(link: IngestedMatchLink): string {

View File

@@ -0,0 +1,5 @@
.matchList {
display: flex;
flex-direction: column;
gap: var(--s-3);
}

View File

@@ -18,6 +18,7 @@ import type { BuiltMatch } from "../core/match-builder";
import { assignMatchSets } from "../core/match-sets";
import type { ScannerLobby } from "../scanner-types";
import { SetDivider } from "./MatchCard";
import styles from "./MatchLobbyTabs.module.css";
type LobbyGroup = "private" | "x" | "other";
@@ -62,7 +63,7 @@ export function MatchLobbyTabs<E extends DetectedEvent>({
))}
</SendouTabList>
{groups.map(({ group, matches: groupMatches }) => (
<SendouTabPanel key={group} id={group} className="match-list">
<SendouTabPanel key={group} id={group} className={styles.matchList}>
<MatchList
matches={groupMatches}
sets={group === "private"}

View File

@@ -0,0 +1,27 @@
/* time + confidence + event type read as one unit, the detail text after it
sits further away */
.pills {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--s-1);
}
/* neutral chip, the icon carries the accent and documents the value — full
label on hover via title */
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: var(--s-0-5) var(--s-2-5);
border-radius: var(--radius-full);
border: var(--border-style);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
& svg {
color: var(--color-text-accent);
}
}

View File

@@ -7,6 +7,7 @@
import { Clock, Gauge } from "lucide-react";
import { EventTypeIcon } from "./EventTypeIcon";
import { formatTime } from "./format";
import styles from "./MetaChips.module.css";
export function MetaPills({
t,
@@ -20,16 +21,16 @@ export function MetaPills({
label: string;
}) {
return (
<div className="meta-pills">
<span className="meta-chip" title="Video timestamp">
<Clock size={12} aria-hidden className="meta-chip-icon" />
<div className={styles.pills}>
<span className={styles.chip} title="Video timestamp">
<Clock size={12} aria-hidden />
{formatTime(t)}
</span>
<span className="meta-chip" title="Detection confidence">
<Gauge size={12} aria-hidden className="meta-chip-icon" />
<span className={styles.chip} title="Detection confidence">
<Gauge size={12} aria-hidden />
{(confidence * 100).toFixed(0)}%
</span>
<span className="status detected">
<span className={styles.chip}>
<EventTypeIcon type={type} />
{label}
</span>

View File

@@ -0,0 +1,96 @@
.player {
display: flex;
align-items: center;
gap: var(--s-2);
padding-block: 3px;
font-size: var(--font-xs);
}
.weaponMissing {
flex-shrink: 0;
width: 24px;
text-align: center;
color: var(--color-text-high);
}
/* the minimap cards are tighter than the scanner's default weapon puck */
.weapon {
flex-shrink: 0;
width: 24px;
height: 24px;
vertical-align: middle;
background: var(--color-bg-badge);
border-radius: var(--radius-full);
padding: var(--s-0-5);
}
.name {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.abilities {
display: flex;
align-items: center;
gap: var(--s-0-5);
flex-shrink: 0;
}
.statusChip {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: var(--radius-full);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
line-height: 1;
&.dead {
background: var(--color-error-low);
color: var(--color-error-high);
}
&.special {
background: var(--color-warning-low);
color: var(--color-warning-high);
}
}
/* d-pad chevron / face-button pucks, same round-solid language as weapon icons */
.slotMarker {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: var(--radius-full);
background: var(--color-bg-badge);
color: #fff;
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
line-height: 1;
& svg {
width: 13px;
height: 13px;
}
&.right svg {
transform: rotate(90deg);
}
&.down svg {
transform: rotate(180deg);
}
&.left svg {
transform: rotate(-90deg);
}
}

View File

@@ -1,3 +1,4 @@
import clsx from "clsx";
import { ChevronUp } from "lucide-react";
import type { ReactNode } from "react";
import { Ability } from "~/components/Ability";
@@ -10,10 +11,12 @@ import {
type MinimapTeammate,
} from "../core/detectors/minimap/index";
import type { CardSlot } from "../core/detectors/minimap/rois";
import eventCardStyles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { stageLabel } from "./labels";
import { MetaPills } from "./MetaChips";
import styles from "./MinimapCard.module.css";
const ENEMY_SLOT_LETTERS = ["A", "B", "X", "Y"] as const;
@@ -39,10 +42,10 @@ function AbilityRow({
function TeammateMarker({ slot }: { slot: CardSlot }) {
if (slot === "self") {
return <span className="slot-marker"></span>;
return <span className={styles.slotMarker}></span>;
}
return (
<span className={`slot-marker ${slot}`}>
<span className={clsx(styles.slotMarker, styles[slot])}>
<ChevronUp strokeWidth={3.5} aria-label={slot} role="img" />
</span>
);
@@ -56,20 +59,36 @@ function PlayerRow({
player: MinimapTeammate | MinimapEnemy;
}) {
return (
<div className="minimap-player">
<div className={styles.player}>
{marker}
{player.weaponId !== null ? (
<WeaponImage
weaponSplId={player.weaponId}
variant="build"
size={24}
className="weapon-icon"
className={styles.weapon}
/>
) : (
<span className="weapon-missing">?</span>
<span className={styles.weaponMissing}>?</span>
)}
<span className="name">{player.name ?? ""}</span>
<span className="abilities">
<span className={styles.name}>{player.name ?? ""}</span>
{player.dead ? (
<span
className={clsx(styles.statusChip, styles.dead)}
title="respawning (struck out)"
>
</span>
) : null}
{player.specialReady ? (
<span
className={clsx(styles.statusChip, styles.special)}
title="special ready (camo)"
>
</span>
) : null}
<span className={styles.abilities}>
<AbilityRow abilities={player.abilities} />
</span>
</div>
@@ -90,8 +109,8 @@ export function MinimapCard(props: {
props;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<div className={eventCardStyles.card}>
<div className={eventCardStyles.meta}>
<MetaPills
t={t}
confidence={confidence}
@@ -107,8 +126,8 @@ export function MinimapCard(props: {
fixture={{ data, type: "Minimap" }}
/>
</div>
<div className="teams">
<div className="team">
<div className={eventCardStyles.teams}>
<div className={eventCardStyles.team}>
<h3>Team</h3>
{data.teammates.map((p) => (
<PlayerRow
@@ -119,13 +138,13 @@ export function MinimapCard(props: {
))}
</div>
{data.enemies.length > 0 ? (
<div className="team">
<div className={eventCardStyles.team}>
<h3>Enemies</h3>
{data.enemies.map((p, i) => (
<PlayerRow
key={i}
marker={
<span className="slot-marker">
<span className={styles.slotMarker}>
{ENEMY_SLOT_LETTERS[i] ?? i + 1}
</span>
}

View File

@@ -2,6 +2,7 @@ import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { formatClock, useEventTimeFormatter } from "./format";
import { MetaPills } from "./MetaChips";
@@ -27,8 +28,8 @@ export function ObjectiveCard(props: {
const holder = data.control.findIndex(Boolean);
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}

View File

@@ -0,0 +1,52 @@
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
} from "../core/detectors/objective/player-status";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { formatClock, useEventTimeFormatter } from "./format";
import { MetaPills } from "./MetaChips";
export function PlayerStatusCard(props: {
t: number;
confidence: number;
data: PlayerStatusData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const side = (index: 0 | 1) =>
data.dead[index]
.map((dead, slot) => (dead ? "✕" : data.special[index][slot] ? "★" : "·"))
.join("");
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}
type={PLAYER_STATUS_EVENT_TYPE}
label={`players (${data.layout})`}
/>
<span>
{data.time !== null ? `${formatClock(data.time)} · ` : null}
<b>
{side(0)} {side(1)}
</b>
</span>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: PLAYER_STATUS_EVENT_TYPE }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
.topbar {
display: flex;
align-items: center;
gap: var(--s-6);
padding-bottom: var(--s-3);
border-bottom: var(--border-width) solid var(--color-bg-high);
margin-bottom: var(--s-4);
& nav {
display: flex;
gap: var(--s-2);
& a {
color: var(--color-text-high);
font-size: var(--font-xs);
font-weight: var(--weight-bold);
text-decoration: none;
padding: var(--s-1) var(--s-2-5);
border-radius: var(--radius-field);
&:hover {
color: var(--color-text);
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
&.active {
color: var(--color-text-accent);
background: var(--color-bg-high);
}
}
}
}

View File

@@ -1,17 +1,19 @@
import clsx from "clsx";
import { Link } from "react-router";
import { useUser } from "~/features/auth/core/user";
import { useSearchParam } from "~/modules/search-params/hooks";
import { SCANNER_PAGE } from "~/utils/urls";
import scannerStyles from "../scanner.module.css";
import {
SCANNER_TABS,
type ScannerTab,
scannerSearchParams,
} from "../scanner-search-params";
import { LivePage } from "./LivePage";
import styles from "./ScannerApp.module.css";
import { ScreenshotPage } from "./ScreenshotPage";
import type { SendouUser } from "./sendou-ingest";
import { VodPage } from "./VodPage";
import "./styles.css";
const TAB_LABELS: Record<ScannerTab, string> = {
live: "Live",
@@ -19,7 +21,7 @@ const TAB_LABELS: Record<ScannerTab, string> = {
vod: "VoD",
};
export function App() {
export function ScannerApp() {
const [tab] = useSearchParam(scannerSearchParams, "tab");
const rootUser = useUser();
const sendouUser: SendouUser | null = rootUser
@@ -36,14 +38,14 @@ export function App() {
);
return (
<div className="scanner-app">
<header className="topbar">
<div className={scannerStyles.app}>
<header className={styles.topbar}>
<nav>
{SCANNER_TABS.map((tabOption) => (
<Link
key={tabOption}
to={scannerSearchParams.href(SCANNER_PAGE, { tab: tabOption })}
className={tab === tabOption ? "active" : ""}
className={clsx({ [styles.active]: tab === tabOption })}
>
{TAB_LABELS[tabOption]}
</Link>

View File

@@ -1,3 +1,4 @@
import clsx from "clsx";
import { WeaponImage } from "~/components/Image";
import type { PlayerAbilityMap } from "../core/ability-harvest";
import type {
@@ -6,7 +7,9 @@ import type {
} from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log-replay/index";
import scannerStyles from "../scanner.module.css";
import { AbilityPopover } from "./AbilityGrid";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import type { CardData } from "./fixture-export";
import { useEventTimeFormatter } from "./format";
@@ -24,18 +27,18 @@ function PlayerRows({
abilities?: PlayerAbilityMap;
}) {
return (
<table className="players">
<table className={styles.players}>
<tbody>
{players.map((p, i) => (
<tr key={i}>
<td>
<span className="weapon-cell">
<span className={styles.weaponCell}>
{p.weaponId !== null ? (
<WeaponImage
weaponSplId={p.weaponId}
variant="build"
size={28}
className="weapon-icon"
className={styles.weaponIcon}
/>
) : null}
{abilities?.has(offset + i) ? (
@@ -44,8 +47,8 @@ function PlayerRows({
</span>
</td>
<td>{p.name || "?"}</td>
<td className="num">{p.paint ?? "?"}p</td>
<td className="num">
<td className={styles.num}>{p.paint ?? "?"}p</td>
<td className={styles.num}>
{p.ka ?? "?"}/{p.d ?? "?"}/{p.s ?? "?"}
</td>
</tr>
@@ -82,8 +85,8 @@ export function ScoreboardCard(props: {
const isScoreboardBattleLog = eventType === SCOREBOARD_BATTLE_LOG_EVENT_TYPE;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}
@@ -109,7 +112,7 @@ export function ScoreboardCard(props: {
) : null}
{data.timestamp ? <span>{data.timestamp}</span> : null}
{data.replayCode ? (
<span className="score">{data.replayCode}</span>
<span className={scannerStyles.score}>{data.replayCode}</span>
) : null}
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
@@ -119,8 +122,8 @@ export function ScoreboardCard(props: {
fixture={{ data, type: eventType }}
/>
</div>
<div className="teams">
<div className="team win">
<div className={styles.teams}>
<div className={clsx(styles.team, styles.win)}>
<h3>{teamHeading("Victory", data, 0)}</h3>
<PlayerRows
players={data.players.slice(0, 4)}
@@ -128,7 +131,7 @@ export function ScoreboardCard(props: {
abilities={props.abilities}
/>
</div>
<div className="team lose">
<div className={clsx(styles.team, styles.lose)}>
<h3>{teamHeading("Defeat", data, 1)}</h3>
<PlayerRows
players={data.players.slice(4, 8)}

View File

@@ -1,9 +1,11 @@
import clsx from "clsx";
import { WeaponImage } from "~/components/Image";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} from "../core/detectors/scoreboard-own/index";
import { AbilityGrid } from "./AbilityGrid";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { lobbyLabel, mainWeaponLabel, modeLabel, stageLabel } from "./labels";
@@ -23,8 +25,8 @@ export function ScoreboardOwnCard(props: {
props;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}
@@ -51,14 +53,14 @@ export function ScoreboardOwnCard(props: {
fixture={{ data, type: "ScoreboardOwn" }}
/>
</div>
<div className="teams solo">
<div className="team">
<div className={clsx(styles.teams, styles.solo)}>
<div className={styles.team}>
{data.weaponId !== null ? (
<WeaponImage
weaponSplId={data.weaponId}
variant="build"
size={28}
className="weapon-icon"
className={styles.weaponIcon}
/>
) : null}
<AbilityGrid abilities={data.abilities} />

View File

@@ -0,0 +1,233 @@
.frame {
position: relative;
margin-bottom: var(--s-4);
& canvas {
width: 100%;
border-radius: var(--radius-box);
border: var(--border-width) solid var(--color-bg-high);
}
}
/* ---- per-detector gate results ---- */
.gateList {
display: grid;
grid-template-columns: repeat(3, max-content) 1fr;
column-gap: var(--s-4);
row-gap: var(--s-1-5);
align-items: baseline;
font-size: var(--font-xs);
margin-block: var(--s-4);
}
.gateRow {
display: contents;
}
.gateBadge {
justify-self: start;
align-self: center;
padding: 0 var(--s-1-5);
border-radius: var(--radius-selector);
background: var(--color-bg-high);
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
text-transform: uppercase;
white-space: nowrap;
}
.gateRow:not(.fired) .gateName {
color: var(--color-text-high);
}
.gateRow.fired {
& .gateBadge {
background: var(--color-success-low);
color: var(--color-success-high);
}
& .gateName {
font-weight: var(--weight-bold);
}
}
.gateScore {
font-variant-numeric: tabular-nums;
color: var(--color-text-high);
font-size: var(--font-2xs);
}
.gateNote {
color: var(--color-text-high);
font-size: var(--font-2xs);
}
/* ---- fired detector's parse detail ---- */
.detail {
display: flex;
flex-direction: column;
gap: var(--s-3);
margin-block: var(--s-4);
}
.detailStats {
display: flex;
flex-wrap: wrap;
gap: var(--s-1-5);
}
.stat {
display: inline-flex;
align-items: baseline;
gap: var(--s-1-5);
padding: var(--s-1) var(--s-2);
border-radius: var(--radius-field);
background: var(--color-bg-high);
font-size: var(--font-xs);
}
.statLabel {
color: var(--color-text-high);
font-size: var(--font-2xs);
white-space: nowrap;
}
.statValue {
font-weight: var(--weight-bold);
}
.statRaw {
color: var(--color-text-high);
font-size: var(--font-2xs);
}
.detailCrops {
display: flex;
flex-wrap: wrap;
gap: var(--s-3);
align-items: end;
& figure {
margin: 0;
display: flex;
flex-direction: column;
gap: var(--s-1);
min-width: 0;
}
& canvas {
display: block;
max-width: 100%;
background: #000;
border-radius: var(--radius-selector);
border: var(--border-width) solid var(--color-bg-high);
}
& figcaption {
color: var(--color-text-high);
font-size: var(--font-2xs);
}
}
/* one pill per player slot */
.statusSlots {
display: inline-flex;
gap: var(--s-2);
align-self: center;
}
.statusSide {
display: inline-flex;
gap: var(--s-0-5);
}
.statusSlot {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 1.25rem;
block-size: 1.25rem;
border-radius: var(--radius-selector);
background: var(--color-bg-higher);
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
&.dead {
background: var(--color-error-low);
color: var(--color-error-high);
}
&.special {
background: var(--color-warning-low);
color: var(--color-warning-high);
}
}
/* ---- per-row parse inspector ---- */
.inspector {
width: 100%;
border-collapse: collapse;
font-size: var(--font-xs);
& th {
text-align: left;
color: var(--color-text-high);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
padding: var(--s-1-5);
border-bottom: var(--border-style);
}
& td {
padding: var(--s-1-5);
border-bottom: 1px solid var(--color-bg-high);
vertical-align: middle;
}
& canvas {
display: block;
background: #000;
border-radius: var(--radius-selector);
}
}
.candidates {
display: flex;
gap: var(--s-2);
align-items: center;
}
.candidate {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-0-5);
font-size: var(--font-2xs);
color: var(--color-text-high);
}
.weaponCandidates .candidate:first-child {
color: var(--color-success);
}
.weaponIcon {
width: 28px;
height: 28px;
vertical-align: middle;
background: var(--color-bg-badge);
border-radius: var(--radius-full);
padding: var(--s-0-5);
}
/* the ROI crops set the width; scroll them instead of the whole page */
@container scanner (width < 700px) {
.inspector {
display: block;
overflow-x: auto;
}
}

View File

@@ -1,5 +1,11 @@
import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "react";
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";
@@ -10,6 +16,13 @@ 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";
@@ -17,6 +30,7 @@ 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 scannerStyles from "../scanner.module.css";
import { scannerSearchParams } from "../scanner-search-params";
import { claimInspectFrame } from "../store/inspect";
import { AnalyzerClient } from "../worker/client";
@@ -30,6 +44,7 @@ import {
stageLabel,
weaponLabel,
} from "./labels";
import styles from "./ScreenshotPage.module.css";
type Result = Extract<WorkerResponse, { kind: "result" }>;
@@ -62,6 +77,58 @@ function RoiCrop(props: {
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;
@@ -117,6 +184,65 @@ function replayRows(winnerSide: string): RowRois[] {
);
}
/** Same marker language as the objective status pills: ✗ dead, ★ special. */
function playerFlags(p: { dead: boolean; specialReady: boolean }) {
return `${p.dead ? " ✗" : ""}${p.specialReady ? " ★" : ""}`;
}
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] ?? "?"}`;
}
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 === "pov"
? objective.STATUS_SLOT_CENTERS_POV[side]
: layout === "cast"
? objective.STATUS_SLOT_CENTERS_CAST[side]
: objective.STATUS_SLOT_CENTERS_CAST_MIRROR[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;
@@ -140,6 +266,31 @@ function drawOverlay(ctx: CanvasRenderingContext2D, detector: string) {
}
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_POV,
objective.STATUS_BODY_BOX_POV,
"#60a5fa",
],
[
objective.STATUS_SLOT_CENTERS_CAST,
objective.STATUS_BODY_BOX_CAST,
"#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 === "map-start") {
rect(mapStart.MODE_LABEL_ROI, "#34d399");
rect(mapStart.MODE_BLOCK_ROI, "#f87171");
@@ -355,6 +506,7 @@ export function ScreenshotPage() {
const isMapStart = activeDetector === "map-start";
const isOwn = activeDetector === "scoreboard-own";
const isMinimap = activeDetector === "minimap";
const isObjective = activeDetector === "objective";
const winnerSide = String(event?.debug?.winnerSide ?? "left");
const rowRois = isReplay
? replayRows(winnerSide)
@@ -366,7 +518,9 @@ export function ScreenshotPage() {
<div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */}
<div
className={clsx("dropzone", { over })}
className={clsx(scannerStyles.dropzone, {
[scannerStyles.over]: over,
})}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
@@ -395,10 +549,10 @@ export function ScreenshotPage() {
</label>
{busy ? " — analyzing…" : null}
</div>
{error ? <p className="error">{error}</p> : null}
{error ? <p className={scannerStyles.error}>{error}</p> : null}
<div
className="screenshot-frame"
className={styles.frame}
style={{ display: frame ? "block" : "none" }}
>
<canvas ref={displayRef} />
@@ -429,237 +583,310 @@ export function ScreenshotPage() {
</p>
) : null}
{Object.values(results).map((result) => (
<p key={result.detector}>
{result.detector} gate:{" "}
<b>{result.gate.pass ? "fired" : "no fire"}</b> (score{" "}
{result.gate.score.toFixed(3)})
{result.events[0] && result.detector === "death" ? (
<>
{" · "}confidence{" "}
{((result.events[0].confidence ?? 0) * 100).toFixed(1)}%{" · "}
{(() => {
const data = result.events[0].data as DeathData;
return `splatted by ${weaponLabel(data.weaponType, data.weaponId) ?? "?"} (${data.name ?? "?"})`;
})()}
</>
) : null}
{result.events[0] && result.detector === "map-start" ? (
<>
{" · "}confidence{" "}
{((result.events[0].confidence ?? 0) * 100).toFixed(1)}%{" · "}
{(() => {
const data = result.events[0].data as MapStartData;
return `${modeLabel(data.mode) ?? "?"} · ${stageLabel(data.stage) ?? "?"}`;
})()}
</>
) : null}
{result.events[0] && result.detector === "scoreboard-own" ? (
<>
{" · "}confidence{" "}
{((result.events[0].confidence ?? 0) * 100).toFixed(1)}%{" · "}
{(() => {
const data = result.events[0].data as ScoreboardOwnData;
return `${[lobbyLabel(data.lobby), modeLabel(data.mode), stageLabel(data.stage)].map((v) => v ?? "?").join(" · ")} · ${mainWeaponLabel(data.weaponId) ?? "?"}`;
})()}
</>
) : null}
{result.events[0] && result.detector === "minimap" ? (
<>
{" · "}confidence{" "}
{((result.events[0].confidence ?? 0) * 100).toFixed(1)}%{" · "}
{(() => {
const data = result.events[0].data as MinimapData;
const players = data.teammates
.map((p) => p.name ?? "?")
.join(", ");
return `${data.stage ?? "?"} · ${players}`;
})()}
</>
) : null}
{result.events[0] &&
result.detector !== "death" &&
result.detector !== "map-start" &&
result.detector !== "scoreboard-own" &&
result.detector !== "minimap" ? (
<>
{" · "}confidence{" "}
{((result.events[0].confidence ?? 0) * 100).toFixed(1)}% · scores{" "}
{JSON.stringify((result.events[0].data as CardData).matchScores)}
{" · "}
{(() => {
const data = result.events[0].data as CardData;
return [
lobbyLabel(data.lobby),
modeLabel(data.mode),
stageLabel(data.stage),
]
.map((v) => v ?? "?")
.join(" · ");
})()}
</>
) : null}
</p>
))}
{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 ? (
<p>
timestamp <b>{event.data.timestamp ?? "?"}</b>
{" · "}code <b>{event.data.replayCode ?? "?"}</b>{" "}
<span className="score">
(raw: {String(event.debug?.codeRaw ?? "")})
</span>
{" · "}match scores {JSON.stringify(event.data.matchScores)}
{" · "}winner panel <b>{winnerSide}</b>
<br />
<RoiCrop frame={frame} roi={replay.HEADER_TOP_BAND} />{" "}
<RoiCrop frame={frame} roi={replay.REPLAY_CODE_ROI} />
</p>
<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 ? (
<p>
timestamp <b>{event.data.timestamp ?? "?"}</b>
{" · "}match scores {JSON.stringify(event.data.matchScores)}
{" · "}winner panel <b>{winnerSide}</b>
<br />
<RoiCrop frame={frame} roi={bl.HEADER_TOP_BAND} />{" "}
<RoiCrop frame={frame} roi={bl.HEADER_BOTTOM_BAND} />
</p>
<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 ? (
<p>
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as DeathData;
return (
<>
weapon{" "}
<b>{weaponLabel(data.weaponType, data.weaponId) ?? "?"}</b>{" "}
<span className="score">
(raw: {String(event.debug?.weaponRaw ?? "")})
</span>
{" · "}name <b>{data.name ?? "?"}</b>{" "}
<span className="score">
(raw: {String(event.debug?.nameRaw ?? "")})
</span>
{" · "}abilities{" "}
{data.abilities.map((row) => row.join(" ")).join(" | ")}
<br />
<RoiCrop frame={frame} roi={death.WEAPON_LINE_ROI} />{" "}
<RoiCrop frame={frame} roi={death.TAG_NAME_OUTER} />
<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>
</>
);
})()}
</p>
</div>
) : null}
{frame && event && isMapStart ? (
<p>
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as MapStartData;
return (
<>
mode <b>{modeLabel(data.mode) ?? "?"}</b>{" "}
<span className="score">
(raw: {String(event.debug?.modeReading ?? "")})
</span>
{" · "}stage <b>{stageLabel(data.stage) ?? "?"}</b>{" "}
<span className="score">
(raw: {String(event.debug?.stageReading ?? "")})
</span>
<br />
<RoiCrop
frame={frame}
roi={mapStart.MODE_BLOCK_ROI}
scale={0.75}
/>{" "}
<RoiCrop frame={frame} roi={mapStart.STAGE_ROI} />
<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>
</>
);
})()}
</p>
</div>
) : null}
{frame && event && isOwn ? (
<p>
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as ScoreboardOwnData;
return (
<>
weapon <b>{mainWeaponLabel(data.weaponId) ?? "?"}</b>{" "}
<span className="score">
(raw: {String(event.debug?.weaponReading ?? "")})
</span>
{" · "}abilities{" "}
{data.abilities.map((row) => row.join(" ")).join(" | ")}
<br />
<RoiCrop frame={frame} roi={own.WEAPON_TITLE_BAND} />{" "}
{[0, 1, 2].map((row) => (
<RoiCrop
key={row}
<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={{
x: own.GEAR_MAIN_CXS[row]! - 36,
y: own.GEAR_BADGE_CY - 32,
w: 200,
h: 64,
}}
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>
</>
);
})()}
</p>
</div>
) : null}
{frame && event && isMinimap ? (
<p>
<div className={styles.detail}>
{(() => {
const data = event.data as unknown as MinimapData;
return (
<>
stage <b>{stageLabel(data.stage) ?? "?"}</b>
{" · "}
{data.spectator ? "spectator map" : "POV overlay"}
{" · "}team{" "}
<b>
{data.teammates
.map(
(p) =>
`${p.slot}: ${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})`,
)
.join(", ") || "—"}
</b>
{data.enemies.length > 0 ? (
<>
{" · "}enemies{" "}
<b>
<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.slot}: ${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) ?? "?"})`,
`${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})${playerFlags(p)}`,
)
.join(", ")}
</b>
</>
) : null}
</Stat>
) : null}
</div>
{!data.spectator ? (
<>
<br />
<div className={styles.detailCrops}>
{minimap.CARD_LAYOUTS.map((card) => (
<RoiCrop key={card.slot} frame={frame} roi={card.name} />
<LabeledCrop
key={card.slot}
label={`slot ${card.slot}`}
frame={frame}
roi={card.name}
/>
))}
</>
</div>
) : null}
</>
);
})()}
</p>
</div>
) : null}
{frame && event && !isDeath && !isMapStart && !isOwn && !isMinimap ? (
<table className="inspector">
{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 &&
!isDeath &&
!isMapStart &&
!isOwn &&
!isMinimap &&
!isObjective ? (
<table className={styles.inspector}>
<thead>
<tr>
<th>row</th>
@@ -681,16 +908,23 @@ export function ScreenshotPage() {
<RoiCrop frame={frame} roi={roi.weapon} />
</td>
<td>
<div className="candidates weapon-candidates">
<div
className={clsx(
styles.candidates,
styles.weaponCandidates,
)}
>
{dbg?.weapon?.top.map((c) => (
<span className="cand" key={c.id}>
<span className={styles.candidate} key={c.id}>
<img
className="weapon-icon"
className={styles.weaponIcon}
src={`${mainWeaponImageUrl(Number(c.id) as MainWeaponId)}.avif`}
alt={c.id}
/>
{c.id}
<span className="score">{c.score.toFixed(3)}</span>
<span className={scannerStyles.score}>
{c.score.toFixed(3)}
</span>
</span>
))}
</div>
@@ -698,20 +932,24 @@ export function ScreenshotPage() {
<td>
<RoiCrop frame={frame} roi={roi.name} />
<b>{player?.name || "—"}</b>{" "}
<span className="score">{dbg?.nameScore.toFixed(3)}</span>
<span className={scannerStyles.score}>
{dbg?.nameScore.toFixed(3)}
</span>
</td>
<td>
<RoiCrop frame={frame} roi={roi.paint} />
<b>{player?.paint ?? "—"}</b>{" "}
<span className="score">{dbg?.paintScore.toFixed(3)}</span>
<span className={scannerStyles.score}>
{dbg?.paintScore.toFixed(3)}
</span>
</td>
<td>
<div className="candidates">
<div className={styles.candidates}>
{([0, 1, 2] as const).map((s) => (
<span className="cand" key={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="score">
<span className={scannerStyles.score}>
{dbg?.statScores[s].toFixed(2)}
</span>
</span>

View File

@@ -0,0 +1,55 @@
import {
STRIP_WEAPONS_EVENT_TYPE,
type StripWeaponsData,
} from "../core/detectors/objective/strip-weapons";
import styles from "./EventCard.module.css";
import { FrameThumb } from "./FrameThumb";
import { formatClock, useEventTimeFormatter } from "./format";
import { mainWeaponLabel } from "./labels";
import { MetaPills } from "./MetaChips";
export function StripWeaponsCard(props: {
t: number;
confidence: number;
data: StripWeaponsData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const side = (index: 0 | 1) =>
data.slots[index]
.map((candidates) =>
candidates === null
? "✕"
: (mainWeaponLabel(candidates[0]?.weaponId ?? null) ?? "?"),
)
.join(" | ");
const formatDetectedAt = useEventTimeFormatter();
return (
<div className={styles.card}>
<div className={styles.meta}>
<MetaPills
t={t}
confidence={confidence}
type={STRIP_WEAPONS_EVENT_TYPE}
label={`strip weapons (${data.layout})`}
/>
<span>
{data.time !== null ? `${formatClock(data.time)} · ` : null}
<b>{side(0)}</b> vs <b>{side(1)}</b>
</span>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: STRIP_WEAPONS_EVENT_TYPE }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
/* internal navigation styled like the sibling action buttons */
.linkButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s-1-5);
padding: 0 var(--field-padding);
height: var(--field-size-sm);
border: var(--border-style-accent);
border-radius: var(--radius-field);
background: var(--color-text-accent);
color: var(--color-text-inverse);
font-size: var(--font-xs);
font-weight: var(--weight-bold);
white-space: nowrap;
text-decoration: none;
& > svg {
width: 16px;
height: 16px;
}
&:active {
transform: translateY(1px);
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
}
.vodList {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.vodItem {
display: flex;
align-items: center;
gap: var(--s-3);
background: var(--color-bg-high);
border-radius: var(--radius-box);
padding: var(--s-3) var(--s-4);
}
.vodName {
font-size: var(--font-sm);
font-weight: var(--weight-bold);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vodMeta {
flex: 1;
}
/* the shared destructive variant loses to the scanner's own button look */
button.vodDelete {
width: var(--field-size-sm);
padding: 0;
border-color: var(--color-error);
background: var(--color-error);
outline-color: var(--color-error);
}
.telemetry {
margin: var(--s-2) 0;
font-size: var(--font-2xs);
color: var(--color-text-high);
& summary {
cursor: pointer;
}
& table {
margin-top: var(--s-1-5);
border-collapse: collapse;
& th,
& td {
padding: var(--s-0-5) var(--s-2-5) var(--s-0-5) 0;
text-align: right;
font-variant-numeric: tabular-nums;
&:first-child {
text-align: left;
}
}
}
}
@container scanner (width < 700px) {
.vodItem {
flex-wrap: wrap;
}
}

View File

@@ -21,13 +21,13 @@ import * as R from "remeda";
import { SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { ObjectiveTimeline } from "~/components/ObjectiveTimeline";
import { GameTimeline } from "~/components/GameTimeline";
import { useSearchParam } from "~/modules/search-params/hooks";
import { openSeekScan, probeWebCodecs } from "../capture/vod-frames";
import { connectAbilities } from "../core/ability-harvest";
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
import { PLAYER_STATUS_EVENT_TYPE } from "../core/detectors/objective/player-status";
import { STRIP_WEAPONS_EVENT_TYPE } from "../core/detectors/objective/strip-weapons";
import {
mergeScanTelemetry,
type ScanTelemetry,
@@ -39,6 +39,8 @@ import {
invalidObjectiveEvents,
} from "../core/match-builder";
import { TimelineBuilder } from "../core/timeline/index";
import scannerStyles from "../scanner.module.css";
import { scannerSearchParams } from "../scanner-search-params";
import type { SendStatus } from "../store/events";
import {
deleteVod,
@@ -63,6 +65,7 @@ import type { FixtureData } from "./fixture-export";
import { formatTime, useEventDateTimeFormatter } from "./format";
import { MatchCard } from "./MatchCard";
import { MatchLobbyTabs } from "./MatchLobbyTabs";
import { playerStatusTeams } from "./player-status-view";
import {
countIngestableMatches,
type SendouUser,
@@ -70,6 +73,7 @@ import {
} from "./sendou-ingest";
import { sendouUpload } from "./sendou-upload";
import { thumbnailFromBlob } from "./thumbnail";
import styles from "./VodPage.module.css";
/** The scan knows the on-screen sides only, not who is playing. */
const SCANNER_TEAM_LABELS = ["Alpha", "Bravo"] as const;
@@ -136,6 +140,9 @@ export function VodPage({
// in-flight thumbnail work; awaited before persisting a finished scan
const sideWorkRef = useRef<Promise<void>[]>([]);
const nextMatchKeyRef = useRef(0);
// telemetry collection is baked into the workers at init, so a change of
// the search param needs a fresh pool
const clientsCollectTelemetryRef = useRef(false);
const [fileName, setFileName] = useState<string | null>(null);
/** live scan vs. reopened saved VoD (no video element for the latter) */
@@ -152,6 +159,9 @@ export function VodPage({
const [eventsOpen, setEventsOpen] = useState(false);
const [resultsSend, setResultsSend] = useState<ResultsSend | null>(null);
// opt-in via ?telemetry=true only; nothing in the UI links to it
const [collectTelemetry] = useSearchParam(scannerSearchParams, "telemetry");
const formatSavedAt = useEventDateTimeFormatter();
const abilityMap = connectAbilities(matches.map((m) => m.event));
@@ -168,7 +178,11 @@ export function VodPage({
);
const vodMatchByEvent = new Map(matches.map((m) => [m.event, m] as const));
const groupedEvents = new Set(builtMatches.flatMap((b) => b.sources));
const ungroupedMatches = matches.filter((m) => !groupedEvents.has(m.event));
// strip weapon evidence is assignment input, not a detection worth a card
const ungroupedMatches = matches.filter(
(m) =>
!groupedEvents.has(m.event) && m.event.type !== STRIP_WEAPONS_EVENT_TYPE,
);
// "Send results" sends the whole scan in one go, so its outcome maps
// onto every ingestable card; a partial failure (some chunks sent, some
@@ -262,6 +276,11 @@ export function VodPage({
urlRef.current = URL.createObjectURL(file);
video.src = urlRef.current;
if (clientsCollectTelemetryRef.current !== collectTelemetry) {
for (const client of clientsRef.current) client.dispose();
clientsRef.current = [];
clientsCollectTelemetryRef.current = collectTelemetry;
}
if (clientsRef.current.length === 0) {
clientsRef.current = Array.from(
{ length: defaultScanWorkerCount() },
@@ -313,6 +332,7 @@ export function VodPage({
frameDoneRef.current?.();
frameDoneRef.current = null;
},
{ collectTelemetry },
),
);
}
@@ -368,10 +388,12 @@ export function VodPage({
abortScanRef.current = () => {
for (const client of clients) client.abortChunk();
};
const mergedTelemetry = () =>
mergeScanTelemetry(
chunks.flatMap((c) => (c.telemetry ? [c.telemetry] : [])),
const mergedTelemetry = () => {
const parts = chunks.flatMap((c) =>
c.telemetry ? [c.telemetry] : [],
);
return parts.length > 0 ? mergeScanTelemetry(parts) : null;
};
let lastUiUpdate = Number.NEGATIVE_INFINITY;
const pushUiUpdate = () => {
const now = performance.now();
@@ -535,7 +557,9 @@ export function VodPage({
<div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */}
<div
className={clsx("dropzone", { over })}
className={clsx(scannerStyles.dropzone, {
[scannerStyles.over]: over,
})}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
@@ -563,17 +587,18 @@ export function VodPage({
/>
</label>
</div>
<div className="controls">
<div className={scannerStyles.controls}>
{showVodView ? (
<>
<button type="button" onClick={backToList}>
All VoDs
</button>
<span
className={clsx("status", {
watching: status === "scanning",
detected: status === "done",
idle: status !== "scanning" && status !== "done",
className={clsx(scannerStyles.status, {
[scannerStyles.watching]: status === "scanning",
[scannerStyles.detected]: status === "done",
[scannerStyles.idle]:
status !== "scanning" && status !== "done",
})}
>
{source === "stored" ? "saved" : status}
@@ -584,7 +609,7 @@ export function VodPage({
: null}
</span>
{progress ? (
<span className="score">
<span className={scannerStyles.score}>
{formatTime(progress.t)} / {formatTime(progress.duration)}
{progress.duration > 0
? ` (${Math.round((progress.t / progress.duration) * 100)}%)`
@@ -595,13 +620,13 @@ export function VodPage({
</span>
) : null}
{upload?.url ? (
<Link to={upload.url} className="link-button">
<Link to={upload.url} className={styles.linkButton}>
<Video aria-hidden />
Add VoD
</Link>
) : null}
{upload?.problem ? (
<span className="score">
<span className={scannerStyles.score}>
upload unavailable: {upload.problem}
</span>
) : null}
@@ -618,8 +643,8 @@ export function VodPage({
) : null}
{resultsSend ? (
<span
className={clsx("score", {
error:
className={clsx(scannerStyles.score, {
[scannerStyles.error]:
resultsSend.state === "done" && Boolean(resultsSend.error),
})}
>
@@ -639,21 +664,21 @@ export function VodPage({
</>
) : null}
</div>
{error ? <p className="error">{error}</p> : null}
{error ? <p className={scannerStyles.error}>{error}</p> : null}
{showVodView && telemetry ? (
<TelemetryPanel telemetry={telemetry} />
) : null}
{!showVodView ? (
<div className="vod-list">
<div className={styles.vodList}>
{vods.length === 0 ? (
<p className="score">
<p className={scannerStyles.score}>
No saved VoDs yet scan one and it will show up here.
</p>
) : null}
{vods.map((vod) => (
<div key={vod.name} className="vod-item">
<span className="name">{vod.name}</span>
<span className="score">
<div key={vod.name} className={styles.vodItem}>
<span className={styles.vodName}>{vod.name}</span>
<span className={clsx(scannerStyles.score, styles.vodMeta)}>
{vod.eventCount} event{vod.eventCount === 1 ? "" : "s"} ·{" "}
{formatTime(vod.duration)} · {formatSavedAt(vod.savedAt)}
</span>
@@ -668,7 +693,7 @@ export function VodPage({
variant="destructive"
size="small"
shape="square"
className="vod-delete"
className={styles.vodDelete}
icon={<Trash2 />}
aria-label="Delete"
/>
@@ -678,7 +703,7 @@ export function VodPage({
</div>
) : null}
<div
className="live-layout"
className={scannerStyles.liveLayout}
style={{
display: showVodView ? undefined : "none",
// a reopened saved VoD has no video to review — give the feed the full width
@@ -688,12 +713,12 @@ export function VodPage({
<div style={{ display: source === "scan" ? undefined : "none" }}>
<canvas
ref={previewRef}
className="preview"
className={scannerStyles.preview}
style={{ display: status === "scanning" ? "block" : "none" }}
/>
<video
ref={videoRef}
className="preview"
className={scannerStyles.preview}
muted
playsInline
controls
@@ -702,9 +727,9 @@ export function VodPage({
}}
/>
</div>
<div className="feed">
<div className={scannerStyles.feed}>
{matches.length === 0 ? (
<p className="score">
<p className={scannerStyles.score}>
{status === "scanning"
? "Scanning — matches appear here as scoreboards are detected."
: "No matches found in this VoD."}
@@ -719,15 +744,19 @@ export function VodPage({
ingestableBuilt.indexOf(built),
);
const send = skipReason ? undefined : bulkSend;
// counter reads render as one timeline chart, not a card each;
// a non-SZ match's reads (objective null) are never shown
const objectiveEvents = built.match.objective
? built.sources
.filter((e) => e.type === OBJECTIVE_EVENT_TYPE)
.map((e) => ({ t: e.t, data: e.data as ObjectiveData }))
: [];
// counter reads render as one timeline chart, not a card each --
// from the builder's samples, whose sides are team-stable (raw
// reads follow the specced player on casts); a non-SZ match's
// reads (objective null) are never shown
const objectiveEvents = (
built.match.objective?.samples ?? []
).map((sample) => ({ t: sample.t, data: sample }));
const statusSamples = built.match.playerStatus?.samples ?? [];
const cardEvents = withoutRepeatEvents(built.sources).filter(
(e) => e.type !== OBJECTIVE_EVENT_TYPE,
(e) =>
e.type !== OBJECTIVE_EVENT_TYPE &&
e.type !== PLAYER_STATUS_EVENT_TYPE &&
e.type !== STRIP_WEAPONS_EVENT_TYPE,
);
return (
<MatchCard
@@ -743,12 +772,11 @@ export function VodPage({
send?.state === "sent" && link ? { ...send, link } : send
}
>
{objectiveEvents.length > 0 ? (
<ObjectiveTimeline
events={objectiveEvents}
teamLabels={SCANNER_TEAM_LABELS}
/>
) : null}
<GameTimeline
objectiveEvents={objectiveEvents}
playerStatusSamples={statusSamples}
teams={playerStatusTeams(built.match, SCANNER_TEAM_LABELS)}
/>
{cardEvents.map((e) => {
const vodMatch = vodMatchByEvent.get(e);
return (
@@ -811,7 +839,7 @@ function ExportMenu({
trigger={
<SendouButton
icon={<Download />}
className="icon-menu"
className={scannerStyles.iconMenu}
aria-label="Export"
/>
}
@@ -855,7 +883,7 @@ function TelemetryPanel({ telemetry }: { telemetry: ScanTelemetry }) {
);
const coveredS = telemetry.activeVideoS + telemetry.skimVideoS;
return (
<details className="telemetry">
<details className={styles.telemetry}>
<summary>
telemetry · analyzed {telemetry.analyzedFrames}/
{telemetry.decodedFrames} decoded frames

View File

@@ -1,9 +1,9 @@
/**
* Flatten detected events into a single CSV for download. One row per event;
* the six event types share columns where they overlap (lobby/mode/stage,
* the event types share columns where they overlap (lobby/mode/stage,
* weapon/name/abilities) and a scoreboard's eight player rows — or the
* minimap's teammates+enemies — are packed into one cell, matching the
* compact per-event view of the live feed.
* minimap's teammates+enemies, or the objective HUD's icon strip — are packed
* into one cell, matching the compact per-event view of the live feed.
*/
import type { MainWeaponId } from "~/modules/in-game-lists/types";
@@ -23,8 +23,23 @@ import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
import type { ScoreboardBattleLogReplayData } from "../core/detectors/scoreboard-battle-log-replay/index";
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
} from "../core/detectors/objective/player-status";
import {
STRIP_WEAPONS_EVENT_TYPE,
type StripWeaponsData,
} from "../core/detectors/objective/strip-weapons";
import {
SCOREBOARD_EVENT_TYPE,
type ScoreboardData,
} from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import {
SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE,
type ScoreboardBattleLogReplayData,
} from "../core/detectors/scoreboard-battle-log-replay/index";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
@@ -93,15 +108,36 @@ function formatMinimapPlayers(data: MinimapData): string {
name: string | null;
weaponId: number | null;
abilities: (string | null)[];
dead: boolean;
specialReady: boolean;
},
) =>
`${label} ${p.name ?? "?"} · ${mainWeaponLabel(p.weaponId as MainWeaponId | null) ?? "?"} · ${formatMinimapAbilities(p.abilities)}`;
`${label} ${p.name ?? "?"} · ${mainWeaponLabel(p.weaponId as MainWeaponId | null) ?? "?"} · ${formatMinimapAbilities(p.abilities)}` +
`${p.dead ? " · splatted" : ""}${p.specialReady ? " · special" : ""}`;
return [
...data.teammates.map((p) => fmt(p.slot, p)),
...data.enemies.map((p, i) => fmt(`enemy${i + 1}`, p)),
].join("; ");
}
/** one team's four slots as top-candidate weapon names, ✕ = splatted */
function formatStripWeaponsSide(data: StripWeaponsData, side: 0 | 1): string {
return data.slots[side]
.map((candidates) =>
candidates === null
? "✕"
: (mainWeaponLabel(candidates[0]?.weaponId ?? null) ?? "?"),
)
.join(" | ");
}
/** one team's four icons as ✕ splatted / ★ special ready / · alive */
function formatPlayerStatusSide(data: PlayerStatusData, side: 0 | 1): string {
return data.dead[side]
.map((dead, slot) => (dead ? "✕" : data.special[side][slot] ? "★" : "·"))
.join("");
}
function formatPlayers(data: ScoreboardData): string {
return data.players
.map(
@@ -217,9 +253,48 @@ function eventCells(event: CsvEvent): Cell[] {
"",
];
}
default: {
// Scoreboard, ScoreboardBattleLogReplay and ScoreboardBattleLog share
// the base shape
case PLAYER_STATUS_EVENT_TYPE: {
const d = event.data as PlayerStatusData;
const clock = d.time === null ? "" : `${formatClock(d.time)} · `;
return [
...base,
"",
"",
"",
"",
"",
"",
"",
"",
"",
`${clock}${formatPlayerStatusSide(d, 0)} vs ${formatPlayerStatusSide(d, 1)} (${d.layout})`,
"",
"",
];
}
case STRIP_WEAPONS_EVENT_TYPE: {
const d = event.data as StripWeaponsData;
const clock = d.time === null ? "" : `${formatClock(d.time)} · `;
return [
...base,
"",
"",
"",
"",
"",
"",
"",
"",
"",
`${clock}${formatStripWeaponsSide(d, 0)} vs ${formatStripWeaponsSide(d, 1)} (${d.layout})`,
"",
"",
];
}
case SCOREBOARD_EVENT_TYPE:
case SCOREBOARD_BATTLE_LOG_EVENT_TYPE:
case SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE: {
// the three scoreboard reads share the base shape
const d = event.data as ScoreboardData &
Partial<ScoreboardBattleLogReplayData>;
return [
@@ -238,6 +313,8 @@ function eventCells(event: CsvEvent): Cell[] {
d.timestamp ?? "",
];
}
default:
return [...base, ...Array(HEADER.length - base.length).fill("")];
}
}

View File

@@ -20,6 +20,14 @@ import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
} from "../core/detectors/objective/player-status";
import {
STRIP_WEAPONS_EVENT_TYPE,
type StripWeaponsData,
} from "../core/detectors/objective/strip-weapons";
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
import type { ScoreboardBattleLogReplayData } from "../core/detectors/scoreboard-battle-log-replay/index";
import {
@@ -39,7 +47,9 @@ export type FixtureData =
| MapStartData
| ScoreboardOwnData
| MinimapData
| ObjectiveData;
| ObjectiveData
| PlayerStatusData
| StripWeaponsData;
function isDeath(_data: FixtureData, eventType: string): _data is DeathData {
return eventType === DEATH_EVENT_TYPE;
@@ -118,12 +128,16 @@ function buildExpectedJson(
weaponLabel: mainWeaponLabel(p.weaponId),
weaponId: p.weaponId,
abilities: p.abilities,
dead: p.dead,
specialReady: p.specialReady,
})),
enemies: minimap.enemies.map((p) => ({
...(minimap.spectator && { name: p.name }),
weaponLabel: mainWeaponLabel(p.weaponId),
weaponId: p.weaponId,
abilities: p.abilities,
dead: p.dead,
specialReady: p.specialReady,
})),
},
},
@@ -148,6 +162,45 @@ function buildExpectedJson(
2,
)}\n`;
}
if (eventType === PLAYER_STATUS_EVENT_TYPE) {
const status = data as PlayerStatusData;
return `${JSON.stringify(
{
event: eventType,
data: {
layout: status.layout,
time: status.time,
special: status.special,
dead: status.dead,
},
},
null,
2,
)}\n`;
}
if (eventType === STRIP_WEAPONS_EVENT_TYPE) {
const strip = data as StripWeaponsData;
return `${JSON.stringify(
{
event: eventType,
data: {
layout: strip.layout,
time: strip.time,
// the top candidate per slot; hand-correct to the true weapons
weapons: strip.slots.map((side) =>
side.map((candidates) => candidates?.[0]?.weaponId ?? null),
),
weaponLabels: strip.slots.map((side) =>
side.map((candidates) =>
mainWeaponLabel(candidates?.[0]?.weaponId ?? null),
),
),
},
},
null,
2,
)}\n`;
}
// NB: not a type-predicate helper — CardData is structurally assignable to
// MapStartData, so a predicate would narrow the fall-through case to never
if (eventType === MAP_START_EVENT_TYPE) {

View File

@@ -0,0 +1,19 @@
/**
* Prop derivation for rendering a ScannerMatch's status samples with the
* shared <PlayerStatusTimeline />, used by the Live and VoD tabs.
*/
import type { PlayerStatusTimelineTeam } from "~/components/PlayerStatusTimeline";
import type { ScannerMatch } from "../core/scanner-match";
/** Row weapons per team from the match's known players, slots by index. */
export function playerStatusTeams(
match: ScannerMatch,
labels: readonly [string, string],
): [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam] {
return [0, 1].map((side) => ({
label: labels[side]!,
weapons: [0, 1, 2, 3].map(
(slot) => match.teams[side as 0 | 1].players[slot]?.weaponId ?? null,
),
})) as [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam];
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,11 +3,12 @@
* own-team callout cards (name, main weapon, the three main-ability
* badges), the enemy panel rows (weapon, abilities; the game shows no
* enemy names) — plus the stage, matched from the drawn map (stage.ts).
* The goal is the most complete read of every card/row; per-match state
* (respawn cross-outs, special charge, map control) is deliberately not
* reported.
* The goal is the most complete read of every card/row; map control is
* deliberately not reported.
*
* Two screen states still steer the reads without being emitted:
* Two per-player screen states are reported (`dead`, `specialReady` — the
* match builder merges them into the death/special timeline alongside the
* icon-strip PlayerStatus reads) and steer the reads themselves:
* - a respawning player's card is struck through with a large team-color
* X that covers the name and badges (own cards also lose the weapon;
* enemy rows keep theirs — the X spares the row's weapon icon). Reading
@@ -37,6 +38,7 @@ import {
meanBrightness,
type Roi,
} from "../../image";
import { type InkRgb, meanInkColor } from "../../ink-color";
import type { ScoreboardResources } from "../scoreboard/index";
import { type ParsedName, parseName } from "../scoreboard/names";
import {
@@ -95,6 +97,10 @@ export interface MinimapTeammate {
* unreadable badge); empty when a respawn cross-out sits over the badges
*/
abilities: (AbilityWithUnknown | null)[];
/** struck through with the respawn cross-out at the read */
dead: boolean;
/** on the light camo surface of a charged special at the read */
specialReady: boolean;
}
export interface MinimapEnemy {
@@ -106,6 +112,10 @@ export interface MinimapEnemy {
/** readable even on struck rows: the cross-out spares the weapon icon */
weaponId: MainWeaponId | null;
abilities: (AbilityWithUnknown | null)[];
/** struck through with the respawn cross-out at the read */
dead: boolean;
/** on the light camo surface of a charged special at the read */
specialReady: boolean;
}
export interface MinimapData {
@@ -127,10 +137,41 @@ export interface MinimapData {
teammates: MinimapTeammate[];
/** enemy panel rows, top to bottom */
enemies: MinimapEnemy[];
/**
* mean team-ink RGB per side sampled from the sub-weapon tiles
* ([teammates/alpha, enemies/bravo]); null when too little saturated
* ink. Anchors the objective counter's color-tracked sides to `teams`
* order on casted footage, which never shows a results screen.
*/
teamColors: [InkRgb | null, InkRgb | null];
}
export const MINIMAP_EVENT_TYPE = "Minimap";
/**
* Timeline content guard: minimap frames inside the merge window collapse
* only while every card/row keeps its dead/special state, so each flip a
* map-open catches (a respawn, a special charged or spent) stays its own
* event. Names, weapons and badges are not compared — OCR wobble on an
* unchanged screen is still the same state.
*/
export function sameMinimapStatusData(a: unknown, b: unknown): boolean {
const da = a as MinimapData;
const db = b as MinimapData;
const sameSide = (
xs: readonly { dead: boolean; specialReady: boolean }[],
ys: readonly { dead: boolean; specialReady: boolean }[],
): boolean =>
xs.length === ys.length &&
xs.every(
(x, i) =>
x.dead === ys[i]!.dead && x.specialReady === ys[i]!.specialReady,
);
return (
sameSide(da.teammates, db.teammates) && sameSide(da.enemies, db.enemies)
);
}
/** Badge match below this is reported as null (kept in debug). */
const ABILITY_MIN_SCORE = 0.45;
@@ -324,6 +365,7 @@ export function createMinimapDetector(
const teammates: MinimapTeammate[] = [];
const enemies: MinimapEnemy[] = [];
const sideSubTiles: [Roi[], Roi[]] = [[], []];
const cardDebug: Record<string, unknown>[] = [];
for (const dx of [0, SPECTATOR_ENEMY_DX]) {
for (let row = 0; row < 4; row++) {
@@ -333,6 +375,7 @@ export function createMinimapDetector(
cardDebug.push({ dx, row, presence, skipped: true });
continue;
}
sideSubTiles[dx === 0 ? 0 : 1].push(layout.subTile);
const crossFraction = saturatedFraction(hsv, layout.cross);
const occluded = crossFraction >= CROSS_MIN_FRACTION;
const cornerMin = minTopCornerMean(gray, layout.weapon);
@@ -397,6 +440,8 @@ export function createMinimapDetector(
name,
weaponId: matched ? toMainWeaponId(matched.id) : null,
abilities,
dead: occluded,
specialReady: lightSurface,
};
if (dx === 0) {
teammates.push({ slot: SPECTATOR_SLOTS[row]!, ...fields });
@@ -407,6 +452,11 @@ export function createMinimapDetector(
}
debug.cards = cardDebug;
const teamColors: [InkRgb | null, InkRgb | null] = [
meanInkColor(rgb, sideSubTiles[0]),
meanInkColor(rgb, sideSubTiles[1]),
];
const stageMatch = detectStage(frame, confidences);
debug.stage = stageMatch;
@@ -429,6 +479,7 @@ export function createMinimapDetector(
spectator: true,
teammates,
enemies,
teamColors,
},
debug,
},
@@ -463,6 +514,7 @@ export function createMinimapDetector(
// 1. own-team callout cards
const teammates: MinimapTeammate[] = [];
const sideSubTiles: [Roi[], Roi[]] = [[], []];
const cardDebug: Record<string, unknown>[] = [];
for (const layout of CARD_LAYOUTS) {
// presence: the card is crisp UI, absent slots show blurred scene
@@ -540,11 +592,14 @@ export function createMinimapDetector(
matched !== null ||
abilities.some((a) => a !== null);
if (!hasEvidence) continue;
sideSubTiles[0].push(layout.subTile);
teammates.push({
slot: layout.slot,
name,
weaponId: matched ? toMainWeaponId(matched.id) : null,
abilities,
dead: occluded,
specialReady: lightSurface,
});
}
debug.cards = cardDebug;
@@ -604,14 +659,22 @@ export function createMinimapDetector(
? SPECIAL_READY_WEAPON_MIN_SCORE
: WEAPON_MIN_SCORE;
const matched = weapon !== null && weapon.score >= floor ? weapon : null;
sideSubTiles[1].push(enemySubTileRoi(cy));
enemies.push({
name: null,
weaponId: matched ? toMainWeaponId(matched.id) : null,
abilities,
dead: occluded,
specialReady: lightSurface,
});
}
debug.enemies = enemyDebug;
const teamColors: [InkRgb | null, InkRgb | null] = [
meanInkColor(rgb, sideSubTiles[0]),
meanInkColor(rgb, sideSubTiles[1]),
];
const stageMatch = detectStage(frame, confidences);
debug.stage = stageMatch;
@@ -635,6 +698,7 @@ export function createMinimapDetector(
spectator: false,
teammates,
enemies,
teamColors,
},
debug,
},

View File

@@ -15,6 +15,11 @@
* `ObjectiveData` is a discriminated union on `mode`; only SZ exists so
* far. Identifying mode from the badge between the plates awaits TC/RM/CB
* fixtures.
*
* Every successful counter read additionally emits a PlayerStatus event
* (player-status.ts) parsed off the per-player icon strip of the same
* frame — the counter parse carries the lookalike rejection for both, and
* the shared timer value pairs the two events downstream.
*/
import { getCV, type Mat, minMaxLoc } from "../../cv";
import {
@@ -31,6 +36,7 @@ import {
minChannel,
type Roi,
} from "../../image";
import { type InkRgb, meanInkColor } from "../../ink-color";
import {
type BannerScoreRead,
isBetterRead,
@@ -38,6 +44,11 @@ import {
} from "../scoreboard/banner";
import type { ScoreboardResources } from "../scoreboard/index";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
type PlayerStatusData,
type PlayerStatusLayout,
parsePlayerStatus,
} from "./player-status";
import {
CONTROL_PLATE_MIN_SATURATION,
GATE_PLATE_MAX_STD,
@@ -52,15 +63,19 @@ import {
PENALTY_TEXT_HEIGHT,
PLATE_PROBE_ROIS,
SCORE_BIN_THRESHOLDS,
SCORE_EXTEND_MIN_CONF,
SCORE_ROIS,
SCORE_TEXT_HEIGHTS,
STATUS_LAYOUT_STICKY_MAX_GAP_S,
STRIP_WEAPON_SAMPLE_INTERVAL,
TIMER_BIN_THRESHOLD,
TIMER_DARK_PROBES,
TIMER_DIGIT_MIN_CONF,
TIMER_DIGIT_MIN_HEIGHT_RATIO,
TIMER_DIGIT_ROI,
TIMER_TEXT_HEIGHT,
TIMER_TEXT_HEIGHTS,
} from "./rois";
import { parseStripWeapons, type StripWeaponsData } from "./strip-weapons";
export type ObjectiveData = SplatZonesObjectiveData;
@@ -78,6 +93,14 @@ export interface SplatZonesObjectiveData {
penalty: [number | null, number | null];
/** which team currently holds the zone (team-color plate fill) */
control: [boolean, boolean];
/**
* mean team-ink RGB per side, sampled off the plate (the team-color
* fill while in control, the digit ink otherwise — one of the two is
* always drawn in the team's color); null when too little saturated
* ink was found. The stable team identity for casted footage, where
* the plates follow the specced player's side instead of alpha/bravo.
*/
teamColor: [InkRgb | null, InkRgb | null];
}
export const OBJECTIVE_EVENT_TYPE = "Objective";
@@ -89,7 +112,8 @@ const CHECK_INTERVAL_SECONDS = 1;
* Timeline content guard: consecutive counter reads merge only when they
* show the same state, so every actual tick/penalty/control change becomes
* its own event. `time` is deliberately not compared — the timer ticks
* every second, so comparing it would keep any two reads from ever merging.
* every second, so comparing it would keep any two reads from ever merging
* — and neither is `teamColor`, whose raw pixel means jitter per frame.
*/
export function sameObjectiveData(a: unknown, b: unknown): boolean {
const da = a as ObjectiveData;
@@ -110,12 +134,17 @@ interface SideRead {
penalty: BannerScoreRead | null;
control: boolean;
fill: { mean: number; saturation: number };
teamColor: InkRgb | null;
}
export function createObjectiveDetector(
resources: ScoreboardResources,
): Detector<ObjectiveData> {
): Detector<ObjectiveData | PlayerStatusData | StripWeaponsData> {
const cv = getCV();
let lastStatus: { layout: PlayerStatusLayout; t: number } | undefined;
// primed so the very first counter read samples — short matches and
// single-frame runs (fixtures) get evidence too
let readsSinceWeaponSample = STRIP_WEAPON_SAMPLE_INTERVAL;
const scoreSets: GlyphSet[] = resources.paintDigits
? SCORE_TEXT_HEIGHTS.map((h) =>
@@ -131,12 +160,14 @@ export function createObjectiveDetector(
PENALTY_TEXT_HEIGHT / resources.paintDigits.height,
)
: null;
const timerSet: GlyphSet | null = resources.paintDigits
? scaleGlyphSet(
resources.paintDigits,
TIMER_TEXT_HEIGHT / resources.paintDigits.height,
const timerSets: GlyphSet[] = resources.paintDigits
? TIMER_TEXT_HEIGHTS.map((h) =>
scaleGlyphSet(
resources.paintDigits!,
h / resources.paintDigits!.height,
),
)
: null;
: [];
/** Mean and standard deviation of a grayscale ROI. */
function meanStd(gray: Mat, roi: Roi): { mean: number; std: number } {
@@ -205,7 +236,13 @@ export function createObjectiveDetector(
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
const read = trailingDigitRun(raw, set);
// the band holds nothing but the count, so a leading digit
// blurred below the extension floor voids the read instead of
// truncating it (see SCORE_EXTEND_MIN_CONF)
const read = trailingDigitRun(raw, set, {
extendMinScore: SCORE_EXTEND_MIN_CONF,
rejectTruncated: true,
});
if (isBetterRead(read, best)) best = read;
}
}
@@ -218,34 +255,47 @@ export function createObjectiveDetector(
* The match timer's M:SS over TIMER_DIGIT_ROI: white digits on the
* near-black box the gate already anchored on. The colon's two dots stack
* to well under the digit height floor, so a valid read is exactly three
* full-height digits — the minute, then the two second digits.
* full-height digits — the minute, then the two second digits. Each glyph
* size is tried (the digits render bigger on upscaled 720p footage) and
* the valid read with the most confident digits wins.
*/
function readTimer(gray: Mat): { value: number | null; reading: string } {
if (!timerSet) return { value: null, reading: "" };
const band = copyRoi(gray, TIMER_DIGIT_ROI);
const raw = recognizeText(band, timerSet, {
binThreshold: TIMER_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
band.delete();
const isTimerDigit = (c: RecognizedChar) =>
c.score >= TIMER_DIGIT_MIN_CONF &&
c.y1 - c.y0 >= timerSet.height * TIMER_DIGIT_MIN_HEIGHT_RATIO;
const digits = raw.chars.filter(isTimerDigit).map((c) => Number(c.char));
if (digits.length !== 3 || digits.some(Number.isNaN)) {
return { value: null, reading: raw.text };
}
const [minutes, secondsTens, secondsOnes] = digits as [
number,
number,
number,
];
if (secondsTens >= 6) return { value: null, reading: raw.text };
return {
value: minutes * 60 + secondsTens * 10 + secondsOnes,
reading: raw.text,
let best: { value: number | null; reading: string; score: number } = {
value: null,
reading: "",
score: 0,
};
for (const timerSet of timerSets) {
const raw = recognizeText(band, timerSet, {
binThreshold: TIMER_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
if (!best.reading) best = { ...best, reading: raw.text };
const isTimerDigit = (c: RecognizedChar) =>
c.score >= TIMER_DIGIT_MIN_CONF &&
c.y1 - c.y0 >= timerSet.height * TIMER_DIGIT_MIN_HEIGHT_RATIO;
const chars = raw.chars.filter(isTimerDigit);
const digits = chars.map((c) => Number(c.char));
if (digits.length !== 3 || digits.some(Number.isNaN)) continue;
const [minutes, secondsTens, secondsOnes] = digits as [
number,
number,
number,
];
if (secondsTens >= 6) continue;
const score = chars.reduce((sum, c) => sum + c.score, 0) / chars.length;
if (score > best.score) {
best = {
value: minutes * 60 + secondsTens * 10 + secondsOnes,
reading: raw.text,
score,
};
}
}
band.delete();
return { value: best.value, reading: best.reading };
}
/** Penalty pill: presence probes first, then the white "+N" digits. */
@@ -297,7 +347,10 @@ export function createObjectiveDetector(
return { mean: sum / count, saturation: satSum / count };
}
function parse(frame: Mat, t: number): DetectedEvent<ObjectiveData>[] {
function parse(
frame: Mat,
t: number,
): DetectedEvent<ObjectiveData | PlayerStatusData | StripWeaponsData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
@@ -312,6 +365,12 @@ export function createObjectiveDetector(
score.value !== null &&
fill.saturation >= CONTROL_PLATE_MIN_SATURATION,
fill,
// the fill while in control, the digit ink otherwise — both
// ROIs together always cover whichever carries the team color
teamColor: meanInkColor(frame, [
SCORE_ROIS[side],
PLATE_PROBE_ROIS[side],
]),
};
}) as [SideRead, SideRead];
const timer = readTimer(gray);
@@ -320,11 +379,42 @@ export function createObjectiveDetector(
// no readable count on either side = the gate hit a lookalike
if (sides.every((side) => side.score.value === null)) return [];
const playerStatus = parsePlayerStatus(
frame,
t,
timer.value,
lastStatus && t - lastStatus.t <= STATUS_LAYOUT_STICKY_MAX_GAP_S
? lastStatus.layout
: undefined,
);
lastStatus = { layout: playerStatus.data.layout, t };
// sampled slot-identity evidence for the strip → scoreboard-row
// assignment; every read would re-measure fixed identities at full
// template-sweep cost
let stripWeapons: DetectedEvent<StripWeaponsData> | null = null;
readsSinceWeaponSample++;
if (
resources.stripWeapons &&
readsSinceWeaponSample >= STRIP_WEAPON_SAMPLE_INTERVAL
) {
readsSinceWeaponSample = 0;
stripWeapons = parseStripWeapons(
frame,
t,
playerStatus.data,
resources.stripWeapons,
);
}
const confidences = sides.flatMap((side) => [
...(side.score.value !== null ? [side.score.confidence] : []),
...(side.penalty?.value != null ? [side.penalty.confidence] : []),
]);
return [
// the icon-strip statuses ride along with every counter read: the
// count confirmation above is the shared lookalike rejection, and
// the shared timer value is what pairs the two events downstream
{
type: OBJECTIVE_EVENT_TYPE,
t,
@@ -338,6 +428,7 @@ export function createObjectiveDetector(
sides[1].penalty?.value ?? null,
],
control: [sides[0].control, sides[1].control],
teamColor: [sides[0].teamColor, sides[1].teamColor],
},
debug: {
timerReading: timer.reading,
@@ -347,6 +438,8 @@ export function createObjectiveDetector(
plateFills: sides.map((side) => side.fill),
},
},
playerStatus,
...(stripWeapons ? [stripWeapons] : []),
];
}

View File

@@ -0,0 +1,354 @@
/**
* PlayerStatus: the per-player state read off the eight squid/octo icons
* flanking the match timer, emitted by the ObjectiveDetector alongside each
* Objective read (same frame, same `time`, so callers can pair the two).
*
* Per slot, three pixel-class fractions decide the state (rois.ts documents
* the calibration): an alive icon's body is saturated team ink; holding
* special washes the body into a bright pale glow that PULSES — bright
* frames light the shoulder probe past the glow floor, trough frames only
* read as pale — and a splatted icon is an unsaturated grey/dark X with
* none of the three. The casted spectator HUD draws the same strip at its
* own geometry — two of them, in fact: the pitches mirror when the specced
* POV sits on the other team ("cast-mirror", whose right column nearly
* coincides with POV's). White camera badges under the right team prove
* either cast arrangement, but broadcasts can hide them while keeping cast
* geometry, so a badge-less frame picks whichever geometry reads more
* decisively (bodies far from the dead threshold on either side) instead
* of assuming POV.
*/
import type { Mat } from "../../cv";
import { copyRoi, type Roi } from "../../image";
import type { DetectedEvent } from "../types";
import {
STATUS_BODY_BOX_CAST,
STATUS_BODY_BOX_POV,
STATUS_CAST_MIN_DPAD_WHITE,
STATUS_DEAD_MAX_BODY_INK,
STATUS_DEAD_MAX_BODY_PALE,
STATUS_DEAD_MAX_SHOULDER_GLOW,
STATUS_DPAD_PROBES,
STATUS_DPAD_PROBES_MIRROR,
STATUS_GLOW_MIN_VALUE,
STATUS_INK_MIN_SPREAD,
STATUS_INK_MIN_VALUE,
STATUS_LAYOUT_SCORE_CAP,
STATUS_LAYOUT_STICKY_MARGIN,
STATUS_PALE_MAX_SPREAD,
STATUS_PALE_MIN_VALUE,
STATUS_READY_MIN_BODY_PALE,
STATUS_READY_MIN_SHOULDER_GLOW,
STATUS_READY_WASH_MAX_BODY_INK,
STATUS_SHOULDER_BOX_CAST,
STATUS_SHOULDER_BOX_POV,
STATUS_SLOT_CENTERS_CAST,
STATUS_SLOT_CENTERS_CAST_MIRROR,
STATUS_SLOT_CENTERS_POV,
STATUS_WHITE_MAX_SPREAD,
STATUS_WHITE_MIN_VALUE,
} from "./rois";
export const PLAYER_STATUS_EVENT_TYPE = "PlayerStatus";
export type PlayerStatusFlags = [boolean, boolean, boolean, boolean];
export type PlayerStatusLayout = "pov" | "cast" | "cast-mirror";
export interface PlayerStatusData {
/**
* seconds shown on the match timer at the read, same value as the
* Objective event from the same frame — the key for pairing the two
*/
time: number | null;
/** special held per slot, [left team, right team], slots left-to-right */
special: [PlayerStatusFlags, PlayerStatusFlags];
/** splatted per slot, same arrangement */
dead: [PlayerStatusFlags, PlayerStatusFlags];
/** which icon-strip geometry the frame showed */
layout: PlayerStatusLayout;
}
/**
* Timeline content guard: reads merge only while every slot state matches,
* so each death/respawn/special flip becomes its own event. `time` is not
* compared (it ticks every second) and neither is `layout` (a camera-style
* change with identical states is the same state).
*/
export function samePlayerStatusData(a: unknown, b: unknown): boolean {
const da = a as PlayerStatusData;
const db = b as PlayerStatusData;
for (const side of [0, 1] as const) {
for (let slot = 0; slot < 4; slot++) {
if (da.special[side][slot] !== db.special[side][slot]) return false;
if (da.dead[side][slot] !== db.dead[side][slot]) return false;
}
}
return true;
}
interface SlotRead {
dead: boolean;
special: boolean;
confidence: number;
bodyInk: number;
bodyPale: number;
shoulderGlow: number;
}
/**
* Parse the icon strip of a frame the objective gate already anchored as
* the in-match counter HUD. Callers emit the event only alongside a
* successful Objective read — the counter parse carries the lookalike
* rejection for both. `prevLayout` is the layout of the caller's previous
* read (sticky: a badge-less frame only switches geometry when the other
* layout wins the decisiveness score by a clear margin — the footage type
* does not flip frame to frame, but a busy scene can nudge the score).
*/
export function parsePlayerStatus(
frame: Mat,
t: number,
time: number | null,
prevLayout?: PlayerStatusLayout,
): DetectedEvent<PlayerStatusData> {
const { layout, scores } = pickLayout(frame, prevLayout);
const sides = readSlots(frame, layout);
const reads = sides.flat();
return {
type: PLAYER_STATUS_EVENT_TYPE,
t,
confidence:
reads.reduce((sum, read) => sum + read.confidence, 0) / reads.length,
data: {
time,
special: sides.map((side) =>
side.map((read) => read.special),
) as PlayerStatusData["special"],
dead: sides.map((side) =>
side.map((read) => read.dead),
) as PlayerStatusData["dead"],
layout,
},
debug: {
layout,
layoutScores: scores
? Object.fromEntries(
Object.entries(scores).map(([name, score]) => [
name,
Number(score.toFixed(3)),
]),
)
: "badges",
bodyInk: reads.map((read) => Number(read.bodyInk.toFixed(2))),
bodyPale: reads.map((read) => Number(read.bodyPale.toFixed(2))),
shoulderGlow: reads.map((read) => Number(read.shoulderGlow.toFixed(2))),
},
};
}
function readSlots(
frame: Mat,
layout: PlayerStatusLayout,
): [SlotRead[], SlotRead[]] {
const centers =
layout === "pov"
? STATUS_SLOT_CENTERS_POV
: layout === "cast"
? STATUS_SLOT_CENTERS_CAST
: STATUS_SLOT_CENTERS_CAST_MIRROR;
const shoulderBox =
layout === "pov" ? STATUS_SHOULDER_BOX_POV : STATUS_SHOULDER_BOX_CAST;
const bodyBox = layout === "pov" ? STATUS_BODY_BOX_POV : STATUS_BODY_BOX_CAST;
return centers.map((sideCenters) =>
sideCenters.map((cx): SlotRead => {
const shoulder = classFractions(frame, {
x: cx + shoulderBox.dx,
y: shoulderBox.y,
w: shoulderBox.w,
h: shoulderBox.h,
});
const body = classFractions(frame, {
x: cx + bodyBox.dx,
y: bodyBox.y,
w: bodyBox.w,
h: bodyBox.h,
});
return classifySlot(body.ink, body.pale, shoulder.glow, layout);
}),
) as [SlotRead[], SlotRead[]];
}
const ALL_LAYOUTS: readonly PlayerStatusLayout[] = [
"pov",
"cast",
"cast-mirror",
];
/**
* Which layouts a badge-less frame may flip to from an established one on
* score alone. The cast-mirror right column nearly coincides with POV's, so
* decisiveness cannot tell those two apart — and a wrong POV pick on cast
* footage self-heals (the next badge frame proves the arrangement) while a
* wrong mirror pick on POV footage never would (POV shows no badges). So
* the mirror is only reachable via badges or from an established cast
* layout (the specced POV switching teams mid-game, attested in the AREA
* CUP VoD's badge-less overhead stretches).
*/
const SCORED_FLIPS: Record<PlayerStatusLayout, readonly PlayerStatusLayout[]> =
{
pov: ["cast"],
cast: ["pov", "cast-mirror"],
"cast-mirror": ["cast"],
};
/**
* Camera badges prove a cast arrangement outright (each arrangement has its
* own badge columns). Badge-less frames are NOT proven POV — broadcasts can
* hide the badges while keeping cast icon geometry — so the candidate
* geometries are scored and the one whose body reads land decisively on
* either side of the dead threshold wins. A mispicked geometry puts
* outer-slot boxes between icons or on backdrop, which reads mid-range ink —
* exactly what the score punishes; boxes on featureless dark backdrop still
* read "decisively dead" though, so a busy scene can nudge a frame's score
* across — the sticky margin keeps a single noisy frame from flipping an
* established layout, and SCORED_FLIPS keeps the POV/mirror false friends
* from ever trading places without badge proof.
*/
function pickLayout(
frame: Mat,
prevLayout: PlayerStatusLayout | undefined,
): {
layout: PlayerStatusLayout;
scores: Record<PlayerStatusLayout, number> | null;
} {
if (badgesVisible(frame, STATUS_DPAD_PROBES))
return { layout: "cast", scores: null };
if (badgesVisible(frame, STATUS_DPAD_PROBES_MIRROR))
return { layout: "cast-mirror", scores: null };
const scores = Object.fromEntries(
ALL_LAYOUTS.map((layout) => [layout, layoutDecisiveness(frame, layout)]),
) as Record<PlayerStatusLayout, number>;
if (prevLayout) {
const challenger = SCORED_FLIPS[prevLayout].reduce((a, b) =>
scores[b] > scores[a] ? b : a,
);
return {
layout:
scores[challenger] > scores[prevLayout] + STATUS_LAYOUT_STICKY_MARGIN
? challenger
: prevLayout,
scores,
};
}
return { layout: scores.pov >= scores.cast ? "pov" : "cast", scores };
}
function layoutDecisiveness(frame: Mat, layout: PlayerStatusLayout): number {
const reads = readSlots(frame, layout).flat();
return (
reads.reduce(
(sum, read) =>
sum +
Math.min(
Math.abs(read.bodyInk - STATUS_DEAD_MAX_BODY_INK),
STATUS_LAYOUT_SCORE_CAP,
),
0,
) / reads.length
);
}
/**
* State from the three fractions, with a confidence scaled by the distance
* to the nearest decision boundary (1 at twice the threshold / at zero).
* On the cast layout a ready icon is always the wash, which replaces the
* body's team ink — an ink-heavy body there means the bright read is
* backdrop leaking past the icon edge, not a held special (see
* STATUS_READY_WASH_MAX_BODY_INK).
*/
function classifySlot(
bodyInk: number,
bodyPale: number,
shoulderGlow: number,
layout: PlayerStatusLayout,
): SlotRead {
const dead =
bodyInk <= STATUS_DEAD_MAX_BODY_INK &&
shoulderGlow <= STATUS_DEAD_MAX_SHOULDER_GLOW &&
bodyPale <= STATUS_DEAD_MAX_BODY_PALE;
const special =
!dead &&
(shoulderGlow >= STATUS_READY_MIN_SHOULDER_GLOW ||
bodyPale >= STATUS_READY_MIN_BODY_PALE) &&
(layout === "pov" || bodyInk <= STATUS_READY_WASH_MAX_BODY_INK);
const confidence = dead
? Math.min(
1,
(STATUS_DEAD_MAX_BODY_INK - bodyInk) / STATUS_DEAD_MAX_BODY_INK,
)
: special
? Math.min(
1,
Math.max(
shoulderGlow / (STATUS_READY_MIN_SHOULDER_GLOW * 2),
bodyPale / (STATUS_READY_MIN_BODY_PALE * 2),
),
)
: Math.min(1, bodyInk / (STATUS_DEAD_MAX_BODY_INK * 2));
return { dead, special, confidence, bodyInk, bodyPale, shoulderGlow };
}
/** Ink, glow, and pale pixel fractions of a ROI (see rois.ts for the classes). */
function classFractions(
frame: Mat,
roi: Roi,
): { ink: number; glow: number; pale: number } {
const crop = copyRoi(frame, roi);
const { data } = crop;
const channels = crop.channels();
let ink = 0;
let glow = 0;
let pale = 0;
let count = 0;
for (let i = 0; i < data.length; i += channels) {
const r = data[i]!;
const g = data[i + 1]!;
const b = data[i + 2]!;
const value = Math.max(r, g, b);
const spread = value - Math.min(r, g, b);
if (spread >= STATUS_INK_MIN_SPREAD && value >= STATUS_INK_MIN_VALUE) ink++;
if (value >= STATUS_GLOW_MIN_VALUE) glow++;
if (value >= STATUS_PALE_MIN_VALUE && spread <= STATUS_PALE_MAX_SPREAD)
pale++;
count++;
}
crop.delete();
return { ink: ink / count, glow: glow / count, pale: pale / count };
}
/** All four badge probes reading white = that casted spectator arrangement. */
function badgesVisible(frame: Mat, probes: readonly Roi[]): boolean {
return probes.every((roi) => {
const crop = copyRoi(frame, roi);
const { data } = crop;
const channels = crop.channels();
let white = 0;
let count = 0;
for (let i = 0; i < data.length; i += channels) {
const r = data[i]!;
const g = data[i + 1]!;
const b = data[i + 2]!;
const value = Math.max(r, g, b);
if (
value >= STATUS_WHITE_MIN_VALUE &&
value - Math.min(r, g, b) <= STATUS_WHITE_MAX_SPREAD
) {
white++;
}
count++;
}
crop.delete();
return white / count >= STATUS_CAST_MIN_DPAD_WHITE;
});
}

View File

@@ -83,10 +83,13 @@ export const GATE_TIMER_MAX_MEAN = 70;
export const GATE_TIMER_MIN_MAX_BRIGHTNESS = 240;
/**
* Timer's white M:SS digits measure 34px; the colon's dots stack under
* the digit height floor, so a plain height filter drops the colon.
* Timer's white M:SS digits measure 34px on native 1080p footage; upscaled
* 720p captures draw them at ~40px. Every height is tried and the best
* valid read wins — the wrong-scale set scores well under a clean read's
* confidence. The colon's dots stack under the digit height floor at
* either size, so a plain height filter drops the colon.
*/
export const TIMER_TEXT_HEIGHT = 34;
export const TIMER_TEXT_HEIGHTS = [34, 40] as const;
export const TIMER_BIN_THRESHOLD = 160;
export const TIMER_DIGIT_MIN_CONF = 0.75;
export const TIMER_DIGIT_MIN_HEIGHT_RATIO = 0.82;
@@ -104,6 +107,16 @@ export const PENALTY_TEXT_HEIGHT = 29;
*/
export const SCORE_BIN_THRESHOLDS = [160, 190] as const;
/**
* Extension floor for count digits joining a run anchored by a confident
* one: white digits on a bright control fill blur into the plate on
* compressed cast footage, eroding a leading digit to 0.64-0.76 while its
* neighbor still clears the main floor ("50" read as trailing "0" — the
* Splat World Series lime plates). Noise chars on the same plates matched
* digit templates at up to 0.66 but never alongside an anchor digit.
*/
export const SCORE_EXTEND_MIN_CONF = 0.6;
/** Penalty pill: white digits on the translucent dark fill (~100 gray). */
export const PENALTY_BIN_THRESHOLD = 170;
export const PENALTY_PROBE_MAX_MEAN = 165;
@@ -116,3 +129,226 @@ export const PENALTY_PROBE_MAX_STD = 30;
* (attested fills >=112 vs <=19).
*/
export const CONTROL_PLATE_MIN_SATURATION = 60;
// ---- player-status icon strips (the PlayerStatus event) ----
//
// Eight per-player squid/octo icons flank the timer. An alive icon's body
// is drawn in team ink; holding special washes the upper body out into a
// bright pale glow; a splatted player's icon turns an unsaturated grey/dark
// X'd shape. Two layouts share the band: POV (small icons) and the casted
// spectator HUD (bigger icons, gauge digits hanging over each icon's
// top-RIGHT and white camera-button badges below) — slot centers are
// measured per side off the fixtures; neither layout is mirror-symmetric
// (POV inner icons sit 108px left / 130px right of screen center) and the
// cast sides don't even share a pitch (~98 left vs ~76 right). Cast icons
// ride their badge columns ~20px left of each badge center; the outer-right
// center sits a few px past the measured icon (~1313) because splat X's
// there lean into inked backdrop on their left while alive bodies extend
// right (attested dead <=0.16 vs alive >=0.33 at 1320).
//
// The cast strip additionally MIRRORS its pitches when the broadcast specs
// a player on the other team (attested mid-game in the AREA CUP VoD): the
// narrow ~76 pitch swaps to the left column and the wide ~97 pitch to the
// right, badges and all — the "cast-mirror" layout. Its right column lands
// within a few px of the POV right column, so a mirrored frame scores
// deceptively well as POV; the mirror's own geometry must be a scored
// candidate (and badge-probed) or the left column misreads ready/dead.
//
// Broadcasts can hide the camera badges while keeping the cast icon
// geometry (attested in the AREA CUP VoD), so badge absence alone cannot
// pick the POV layout — player-status.ts scores both geometries and keeps
// the one whose body reads sit decisively on either side of the dead
// threshold; a mispicked layout puts outer-slot boxes between icons and
// flickers phantom deaths on the outermost players.
/** Per-side slot center x's, slots left-to-right. */
export const STATUS_SLOT_CENTERS_POV: readonly [
readonly number[],
readonly number[],
] = [
[554, 653, 752, 852],
[1090, 1190, 1288, 1388],
];
export const STATUS_SLOT_CENTERS_CAST: readonly [
readonly number[],
readonly number[],
] = [
[543, 642, 741, 837],
[1085, 1161, 1237, 1320],
];
export const STATUS_SLOT_CENTERS_CAST_MIRROR: readonly [
readonly number[],
readonly number[],
] = [
[605, 681, 757, 833],
[1090, 1187, 1283, 1381],
];
/**
* Shoulder probe: the icon's upper-left body, clear of the weapon
* silhouette (drawn center/lower), the cast gauge digits (hanging top-right)
* and the POV sub/special trinkets (bottom). The special-ready glow is
* detected here. Boxes are relative to a slot center.
*/
export const STATUS_SHOULDER_BOX_POV = { dx: -30, y: 38, w: 24, h: 20 };
export const STATUS_SHOULDER_BOX_CAST = { dx: -30, y: 35, w: 24, h: 30 };
/**
* Body probe: the widest band of the icon that dodges the cast camera
* badges below (y>=100) and the POV coin trinkets (y>=95). Team ink
* presence here separates alive icons from the grey/dark splatted ones.
* Sized to cover most of the icon: a slimmer box left alive icons whose
* body is largely weapon silhouette/badges reading ink 0.24 while stage
* ink bleeding around a translucent dead icon read 0.23 (AREA CUP VoD) —
* this footprint separates them at 0.26 vs 0.20.
*/
export const STATUS_BODY_BOX_POV = { dx: -40, y: 40, w: 80, h: 50 };
export const STATUS_BODY_BOX_CAST = { dx: -40, y: 45, w: 80, h: 50 };
/**
* An ink pixel: saturated and bright enough to be team color. The value
* floor keeps dark saturated stage backdrops (deep blue arena walls behind
* the translucent dead icons measure v<=90) from counting as ink.
*/
export const STATUS_INK_MIN_SPREAD = 70;
export const STATUS_INK_MIN_VALUE = 105;
/**
* A glow pixel of the special-ready wash. 225 splits the attested ready
* shoulders (fractions >=0.40) from the brightest alive team color — POV
* lime peaks between 215 and 225 (glow fraction 0.97 at 215, 0.00 at 225).
*/
export const STATUS_GLOW_MIN_VALUE = 225;
/**
* A pale pixel: bright but unsaturated, the ready wash across its whole
* pulse cycle. The wash PULSES — its trough dims below the glow floor
* (~190-220) while staying pale, so a trough frame reads no ink and no
* glow; without the pale class it is indistinguishable from a splat.
*/
export const STATUS_PALE_MIN_VALUE = 185;
export const STATUS_PALE_MAX_SPREAD = 70;
/**
* Splatted: body ink under the floor (attested dead <=0.20 vs alive
* >=0.26 with the current body box) with two guards: shoulder glow keeps
* the bright ready wash out (glow >=0.40 vs dead <=0.03), body pale keeps
* the wash's dim pulse trough out (pale >=0.27 vs dead <=0.07).
*/
export const STATUS_DEAD_MAX_BODY_INK = 0.23;
export const STATUS_DEAD_MAX_SHOULDER_GLOW = 0.2;
export const STATUS_DEAD_MAX_BODY_PALE = 0.15;
/** Special ready: shoulder glow past this (attested >=0.40 vs <=0.06). */
export const STATUS_READY_MIN_SHOULDER_GLOW = 0.25;
/**
* Special ready off the body when the shoulder misses the wash (pulse
* trough, or wash dimmed on compressed footage): pale-dominant body
* (attested ready >=0.40 vs alive <=0.25 — plain alive bodies show some
* pale from weapon-silhouette whites).
*/
export const STATUS_READY_MIN_BODY_PALE = 0.32;
/**
* Cast-layout ready guard: the cast wash REPLACES the body's team ink
* (attested washed bodies <=0.36 — the top end is stage ink bleeding
* around the washed icon), so an ink-heavy body proves a bright
* shoulder/pale read is backdrop leaking past the icon edge — the
* spectator overhead view draws a badge-less strip whose left column sits
* ~12px off the cast centers, sliding the probes onto pale buildings and
* the team-color lead banner (attested leaks read body ink >=0.44). POV
* ready icons instead light up IN team color (attested ink up to 0.68),
* so the guard is cast-only.
*/
export const STATUS_READY_WASH_MAX_BODY_INK = 0.4;
/**
* Layout scoring (see player-status.ts): per-slot decisiveness is the
* body-ink distance from the dead threshold, capped so one saturated slot
* cannot carry a misaligned geometry. The sticky margin is what the
* non-established layout must win the score by before a badge-less frame
* switches an established geometry.
*/
export const STATUS_LAYOUT_SCORE_CAP = 0.3;
export const STATUS_LAYOUT_STICKY_MARGIN = 0.04;
/**
* An established layout only carries forward across reads this close in
* time — in-match reads land ~1s apart, while a longer silence means a new
* match (possibly new footage type) and the next frame picks fresh.
*/
export const STATUS_LAYOUT_STICKY_MAX_GAP_S = 30;
// ---- strip weapon-icon evidence (the StripWeapons event) ----
//
// Each slot draws the player's weapon render over its squid plate; the
// match builder aggregates sampled per-slot candidate rankings across a
// match to solve the strip-slot → scoreboard-row assignment
// (strip-weapons.ts). Calibrated on the sendou-triton VoD (cast geometry
// on 720p footage upscaled to canonical space).
/**
* Weapon search window relative to a slot center: generous enough to hold
* the render at either strip geometry (the render measures ~65px on the
* cast strip, smaller on POV) without swallowing a neighbor slot's art.
*/
export const STRIP_WEAPON_BOX = { dx: -50, y: 20, w: 100, h: 100 };
/**
* Template render heights to try inside the window; the attested cast
* strip draws renders at ~55-70px depending on the weapon's aspect.
*/
export const STRIP_WEAPON_TEMPLATE_SIZES = [44, 52, 60, 68, 76] as const;
/**
* The flat grey the knocked-out plate pixels become and templates are
* composited over — mid-grey, so both dark barrels and white bodies keep
* contrast against it.
*/
export const STRIP_WEAPON_TEMPLATE_BACKGROUND = 90;
/** Ink floor for the NCC coverage penalty over that background. */
export const STRIP_WEAPON_INK_THRESHOLD = 140;
/**
* A plate pixel: saturated and bright (the plate is drawn in team ink),
* within the hue band of the region's modal saturated hue. The spread and
* value floors sit under the modal-vote floors (+15 in strip-weapons.ts)
* so the knockout reaches the plate's dimmer edge pixels the vote skips.
*/
export const STRIP_WEAPON_KNOCKOUT_MIN_SPREAD = 55;
export const STRIP_WEAPON_KNOCKOUT_MIN_VALUE = 90;
export const STRIP_WEAPON_MAX_PLATE_HUE_DIST = 30;
/**
* Candidates kept per slot: single reads only rank the true weapon top-1
* about half the time on attested footage, but it lands in the top 8 in
* enough reads for the cross-match aggregate to decide.
*/
export const STRIP_WEAPON_TOP_K = 8;
/**
* Every Nth successful counter read samples the strip weapons: identities
* are fixed per match, ~1 read/s makes ~20 samples over a short match —
* attested to assign correctly — and the full-atlas NCC sweep is too
* heavy to run on every read.
*/
export const STRIP_WEAPON_SAMPLE_INTERVAL = 5;
/**
* Cast-layout discriminator: the spectator HUD always draws white camera
* badges under the right team's icons; nothing fixed sits there on POV
* footage. All four probes must read white (bright AND unsaturated —
* bright sky is saturated cyan) to call the frame cast. The mirror set
* covers the cast-mirror arrangement's wide right badge pitch.
*/
export const STATUS_DPAD_PROBES: readonly Roi[] = [1105, 1180, 1256, 1332].map(
(cx) => ({ x: cx - 8, y: 102, w: 16, h: 16 }),
);
export const STATUS_DPAD_PROBES_MIRROR: readonly Roi[] = [
1110, 1207, 1303, 1401,
].map((cx) => ({ x: cx - 8, y: 102, w: 16, h: 16 }));
export const STATUS_WHITE_MIN_VALUE = 215;
export const STATUS_WHITE_MAX_SPREAD = 40;
export const STATUS_CAST_MIN_DPAD_WHITE = 0.35;

View File

@@ -0,0 +1,178 @@
/**
* StripWeapons: per-slot weapon-icon evidence off the same icon strip the
* PlayerStatus read classifies, emitted by the ObjectiveDetector on a
* sampled cadence (identities are fixed for a match, so every read would be
* waste). The results scoreboard re-sorts each team per game while the
* strip keeps the lobby seating (attested in the sendou-triton VoD: strip
* [Planetz, .52, Neo Splash, Snipewriter] vs scoreboard rows
* [.52, Neo Splash, Snipewriter, Planetz]), so status samples cannot be
* paired with scoreboard rows by position alone — the match builder
* aggregates these candidate lists across the match and solves the
* slot→row assignment against the scoreboard's weapons
* (slot-row-assignment.ts).
*
* A slot's icon is the weapon render over a team-ink squid plate; the
* plate (and scene bleeding through it — the plates are translucent) is
* what drowns template matching, so saturated pixels near the plate's
* modal hue are flattened to the template background before the NCC
* ranking. One read's top-1 is only right about half the time on attested
* footage — the value is in the aggregate, so the event carries a ranked
* candidate list per slot. Splatted slots grey the render out and are
* skipped rather than guessed.
*/
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { getCV, type Mat } from "../../cv";
import { copyRoi } from "../../image";
import { hueDistance, hueOf } from "../../ink-color";
import { matchWeapon, type WeaponTemplate } from "../scoreboard/weapons";
import type { DetectedEvent } from "../types";
import type { PlayerStatusData, PlayerStatusLayout } from "./player-status";
import {
STATUS_SLOT_CENTERS_CAST,
STATUS_SLOT_CENTERS_CAST_MIRROR,
STATUS_SLOT_CENTERS_POV,
STRIP_WEAPON_BOX,
STRIP_WEAPON_INK_THRESHOLD,
STRIP_WEAPON_KNOCKOUT_MIN_SPREAD,
STRIP_WEAPON_KNOCKOUT_MIN_VALUE,
STRIP_WEAPON_MAX_PLATE_HUE_DIST,
STRIP_WEAPON_TEMPLATE_BACKGROUND,
STRIP_WEAPON_TOP_K,
} from "./rois";
export const STRIP_WEAPONS_EVENT_TYPE = "StripWeapons";
export interface StripWeaponCandidate {
weaponId: MainWeaponId;
score: number;
}
export interface StripWeaponsData {
/** match timer at the read, pairing it with the Objective/PlayerStatus events */
time: number | null;
/** the icon-strip geometry the paired PlayerStatus read picked */
layout: PlayerStatusLayout;
/**
* ranked weapon candidates per slot, [left team, right team], slots
* left-to-right as drawn; null = slot skipped (splatted icons grey the
* weapon render out)
*/
slots: [(StripWeaponCandidate[] | null)[], (StripWeaponCandidate[] | null)[]];
}
/**
* Match every alive slot's icon against the strip weapon templates.
* `status` is the PlayerStatus read off the same frame — its layout picks
* the slot centers and its dead flags pick which slots are worth reading.
*/
export function parseStripWeapons(
frame: Mat,
t: number,
status: PlayerStatusData,
templates: WeaponTemplate[],
): DetectedEvent<StripWeaponsData> {
const centers = slotCenters(status.layout);
const scores: number[] = [];
const slots = centers.map((sideCenters, side) =>
sideCenters.map((cx, slot): StripWeaponCandidate[] | null => {
if (status.dead[side as 0 | 1][slot]) return null;
const candidates = matchSlot(frame, cx, templates);
if (candidates.length > 0) scores.push(candidates[0]!.score);
return candidates;
}),
) as StripWeaponsData["slots"];
return {
type: STRIP_WEAPONS_EVENT_TYPE,
t,
// raw NCC peaks on attested footage sit ~0.4-0.6 even for correct
// reads; the aggregate assignment carries the reliability, so the
// event's own confidence only reflects that something matched at all
confidence: scores.length > 0 ? Math.max(...scores) : 0,
data: {
time: status.time,
layout: status.layout,
slots,
},
};
}
function slotCenters(
layout: PlayerStatusLayout,
): readonly [readonly number[], readonly number[]] {
return layout === "pov"
? STATUS_SLOT_CENTERS_POV
: layout === "cast"
? STATUS_SLOT_CENTERS_CAST
: STATUS_SLOT_CENTERS_CAST_MIRROR;
}
function matchSlot(
frame: Mat,
cx: number,
templates: WeaponTemplate[],
): StripWeaponCandidate[] {
const cv = getCV();
const crop = copyRoi(frame, {
x: cx + STRIP_WEAPON_BOX.dx,
y: STRIP_WEAPON_BOX.y,
w: STRIP_WEAPON_BOX.w,
h: STRIP_WEAPON_BOX.h,
});
const search = new cv.Mat();
cv.cvtColor(crop, search, cv.COLOR_RGBA2RGB);
crop.delete();
knockoutPlate(search);
const match = matchWeapon(search, templates, {
inkThreshold: STRIP_WEAPON_INK_THRESHOLD,
topN: STRIP_WEAPON_TOP_K,
});
search.delete();
return match.top.map((candidate) => ({
weaponId: Number(candidate.id) as MainWeaponId,
score: candidate.score,
}));
}
/**
* Flatten the squid plate out of the search region: the modal hue of the
* region's saturated pixels is the plate's team ink, and every pixel near
* that hue is replaced with the flat template background, leaving the
* weapon render (grey/white bodies and off-hue accents) to carry the NCC.
*/
function knockoutPlate(search: Mat): void {
const { data } = search;
const n = search.rows * search.cols;
const bins = new Array<number>(36).fill(0);
for (let i = 0; i < n; i++) {
const r = data[i * 3]!;
const g = data[i * 3 + 1]!;
const b = data[i * 3 + 2]!;
const value = Math.max(r, g, b);
const spread = value - Math.min(r, g, b);
if (
spread >= STRIP_WEAPON_KNOCKOUT_MIN_SPREAD + 15 &&
value >= STRIP_WEAPON_KNOCKOUT_MIN_VALUE + 15
) {
bins[Math.floor(hueOf({ r, g, b }) / 10)]!++;
}
}
const plateHue = bins.indexOf(Math.max(...bins)) * 10 + 5;
for (let i = 0; i < n; i++) {
const r = data[i * 3]!;
const g = data[i * 3 + 1]!;
const b = data[i * 3 + 2]!;
const value = Math.max(r, g, b);
const spread = value - Math.min(r, g, b);
if (
spread >= STRIP_WEAPON_KNOCKOUT_MIN_SPREAD &&
value >= STRIP_WEAPON_KNOCKOUT_MIN_VALUE &&
hueDistance(hueOf({ r, g, b }), plateHue) <=
STRIP_WEAPON_MAX_PLATE_HUE_DIST
) {
data[i * 3] = STRIP_WEAPON_TEMPLATE_BACKGROUND;
data[i * 3 + 1] = STRIP_WEAPON_TEMPLATE_BACKGROUND;
data[i * 3 + 2] = STRIP_WEAPON_TEMPLATE_BACKGROUND;
}
}
}

View File

@@ -231,10 +231,26 @@ export function isBetterRead(
export interface TrailingDigitOptions {
/** char floor a glyph must clear to count as a digit of the number */
minCharScore?: number;
/**
* lower floor for digits joining a run that another digit anchors at
* `minCharScore` — motion blur / compression can erode one digit of a
* genuine number below the main floor while its neighbor stays crisp.
* Defaults to `minCharScore` (no two-tier extension).
*/
extendMinScore?: number;
/** min ink height as a fraction of the set height (drops labels, '+') */
minHeightRatio?: number;
/** values above this are rejected as misreads */
maxValue?: number;
/**
* reject the read (null) when the char immediately left of the run sat
* within digit-gap distance but failed the floors — on a band that holds
* nothing but the number (objective counter plates), that char is a
* blur-mangled leading digit and the run is a truncated misread ("50"
* returning 0). Off for banner bands, where an adjacent label/burst
* letter legitimately borders the digits.
*/
rejectTruncated?: boolean;
}
/**
@@ -250,30 +266,41 @@ export function trailingDigitRun(
): BannerScoreRead {
const {
minCharScore = DIGIT_MIN_CONF,
extendMinScore = minCharScore,
minHeightRatio = DIGIT_MIN_HEIGHT_RATIO,
maxValue = KO_MATCH_SCORE,
rejectTruncated = false,
} = options;
const maxGap = Math.max(4, Math.round(set.medianWidth * DIGIT_GAP_MAX_RATIO));
const isScoreDigit = (c: RecognizedChar) =>
c.score >= minCharScore && c.y1 - c.y0 >= set.height * minHeightRatio;
const fullHeight = (c: RecognizedChar) =>
c.y1 - c.y0 >= set.height * minHeightRatio;
const isRunDigit = (c: RecognizedChar) =>
c.score >= extendMinScore && fullHeight(c);
const run: RecognizedChar[] = [];
let i = raw.chars.length - 1;
for (; i >= 0; i--) {
const c = raw.chars[i]!;
if (!isScoreDigit(c)) break;
if (!isRunDigit(c)) break;
if (run.length > 0 && run[0]!.x0 - c.x1 > maxGap) break;
run.unshift(c);
}
if (run.length === 0) return { ...EMPTY_READ, reading: raw.text };
if (!run.some((c) => c.score >= minCharScore)) {
return { ...EMPTY_READ, reading: raw.text };
}
// A further digit left of the run means an unreadable glyph split the
// number (turf war percentages read "48", ".", "7") — the tail is not
// the score.
for (let k = i; k >= 0; k--) {
if (isScoreDigit(raw.chars[k]!)) {
const c = raw.chars[k]!;
if (c.score >= minCharScore && fullHeight(c)) {
return { ...EMPTY_READ, reading: raw.text };
}
}
if (rejectTruncated && i >= 0 && run[0]!.x0 - raw.chars[i]!.x1 <= maxGap) {
return { ...EMPTY_READ, reading: raw.text };
}
const value = Number.parseInt(run.map((c) => c.char).join(""), 10);
if (value > maxValue) return { ...EMPTY_READ, reading: raw.text };

View File

@@ -87,6 +87,12 @@ export interface ScoreboardRowDebug {
export interface ScoreboardResources {
weapons: WeaponTemplate[];
/**
* Weapon renders prepared for the in-match icon strip (objective's
* StripWeapons evidence). Optional: without them the strip slot →
* scoreboard row assignment falls back to as-drawn order.
*/
stripWeapons?: WeaponTemplate[] | null;
/**
* Special-weapon silhouettes (assets/cv/specials). Optional: without
* them, near-tied weapon icons (Splash- vs Sploosh-o-matic) stay decided

View File

@@ -64,6 +64,7 @@ export interface WeaponTemplate {
export interface WeaponMatch {
id: string;
score: number;
/** best candidates, most likely first (3 unless options.topN says more) */
top: { id: string; score: number }[];
/** true when a scoped/unscoped twin tie was resolved by the unscoped prior */
twinAmbiguous?: boolean;
@@ -308,10 +309,11 @@ function coarseShortlist(
export function matchWeapon(
searchRgb: Mat,
templates: WeaponTemplate[],
options: { inkThreshold?: number } = {},
options: { inkThreshold?: number; topN?: number } = {},
): WeaponMatch {
const cv = getCV();
const inkThreshold = options.inkThreshold ?? INK_THRESHOLD;
const topN = options.topN ?? 3;
// icon ink present in the search region (pixel access needs a copy)
const cont = new cv.Mat();
@@ -348,7 +350,7 @@ export function matchWeapon(
const ranked = [...best.entries()]
.map(([id, score]) => ({ id, score }))
.sort((a, b) => b.score - a.score);
const top = ranked.slice(0, 3);
const top = ranked.slice(0, topN);
let first = top[0] ?? { id: "unknown", score: -1 };
let twinAmbiguous = false;
const unscopedId = SCOPED_TWINS.get(first.id);
@@ -360,7 +362,7 @@ export function matchWeapon(
const i = top.findIndex((t) => t.id === twin.id);
if (i >= 0) top.splice(i, 1);
top.unshift(twin);
top.length = Math.min(top.length, 3);
top.length = Math.min(top.length, topN);
}
}
return { id: first.id, score: first.score, top, twinAmbiguous };

View File

@@ -0,0 +1,85 @@
/**
* Team ink color sampling and comparison. Splatoon renders each team's UI
* accents — counter plate fills and digits, minimap sub tiles — in the
* team's ink color, fixed for the whole game and picked to contrast with
* the opponent's. That makes ink hue a per-frame team identity signal
* where screen position is not one: casted footage reorders HUD sides to
* follow the currently specced player.
*/
import type { Mat } from "./cv";
import { copyRoi, type Roi } from "./image";
export interface InkRgb {
r: number;
g: number;
b: number;
}
/** Channel spread (max-min) a pixel needs to count as ink, not chrome. */
const INK_MIN_SATURATION = 60;
/** Fewer qualifying pixels than this = no reliable ink in the ROIs. */
const MIN_INK_PIXELS = 30;
/**
* Mean RGB of the ink-saturated pixels across the ROIs (channel spread at
* least `minSaturation`); null when fewer than MIN_INK_PIXELS qualify.
* Averaging only saturated pixels keeps dark/white surroundings (plate
* fills, digit cores, tile backgrounds) from washing the hue out.
*/
export function meanInkColor(
frame: Mat,
rois: readonly Roi[],
minSaturation: number = INK_MIN_SATURATION,
): InkRgb | null {
let r = 0;
let g = 0;
let b = 0;
let count = 0;
for (const roi of rois) {
const crop = copyRoi(frame, roi);
const { data } = crop;
const channels = crop.channels();
for (let i = 0; i < data.length; i += channels) {
const pr = data[i]!;
const pg = data[i + 1]!;
const pb = data[i + 2]!;
const spread = Math.max(pr, pg, pb) - Math.min(pr, pg, pb);
if (spread < minSaturation) continue;
r += pr;
g += pg;
b += pb;
count++;
}
crop.delete();
}
if (count < MIN_INK_PIXELS) return null;
return {
r: Math.round(r / count),
g: Math.round(g / count),
b: Math.round(b / count),
};
}
/** Hue angle of an ink color, degrees on the 0-360 color wheel. */
export function hueOf(color: InkRgb): number {
const max = Math.max(color.r, color.g, color.b);
const min = Math.min(color.r, color.g, color.b);
if (max === min) return 0;
const d = max - min;
let h: number;
if (max === color.r) {
h = ((color.g - color.b) / d) % 6;
} else if (max === color.g) {
h = (color.b - color.r) / d + 2;
} else {
h = (color.r - color.g) / d + 4;
}
return (h * 60 + 360) % 360;
}
/** Shortest angular distance between two hues, 0-180 degrees. */
export function hueDistance(a: number, b: number): number {
const d = Math.abs(a - b) % 360;
return d > 180 ? 360 - d : d;
}

View File

@@ -25,6 +25,15 @@ import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "./detectors/objective/index";
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
type PlayerStatusFlags,
} from "./detectors/objective/player-status";
import {
STRIP_WEAPONS_EVENT_TYPE,
type StripWeaponsData,
} from "./detectors/objective/strip-weapons";
import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry";
import type { ScoreboardData } from "./detectors/scoreboard/index";
import {
@@ -36,14 +45,24 @@ import {
type ScoreboardBattleLogReplayData,
} from "./detectors/scoreboard-battle-log-replay/index";
import type { DetectedEvent } from "./detectors/types";
import { hueDistance, hueOf, type InkRgb } from "./ink-color";
import { parseReplayTimestamp } from "./replay-time";
import type {
ScannerMatch,
ScannerMatchObjective,
ScannerMatchObjectiveSample,
ScannerMatchPlayer,
ScannerMatchPlayerStatus,
ScannerMatchPlayerStatusSample,
ScannerMatchTeam,
} from "./scanner-match";
import {
applyPermutation,
IDENTITY_PERMUTATION,
nameSlotRowPermutation,
type SlotRowPermutation,
weaponSlotRowPermutation,
} from "./slot-row-assignment";
/** The lobby header value private battles (tournament games) carry. */
const TOURNAMENT_LOBBY = "PRIVATE";
@@ -70,6 +89,47 @@ const PLAYERS_PER_TEAM = 4;
*/
const EARLY_END_MARGIN_SECONDS = 10;
/**
* The two team-ink hues must be at least this far apart before color is
* trusted to orient counter reads: a game's color pair is picked to
* contrast (attested pairs measure >130° apart), so a closer seed pair is
* a misread, and orientation falls back to the as-read arrangement.
*/
const MIN_TEAM_HUE_SEPARATION = 30;
/**
* A counter read whose projected clock zero (`t + time`) sits further than
* this from the match's dominant projection was taken off a broadcast
* replay of another moment, not the live game. Live projections jitter by
* a couple of seconds (wall clock and match timer both round to whole
* seconds); attested replay wipes land a minute or more away.
*/
const REPLAY_ANCHOR_TOLERANCE_SECONDS = 10;
/**
* Dead-flag runs that fit between their flanking opposite-state reads in
* less than these spans are physically impossible and get flipped to their
* surroundings: the fastest respawn in Splatoon is 3.5s, so no true dead
* stretch is shorter — while a respawned player CAN be re-splatted quickly
* (spawncamps are real), so the alive floor stays a conservative 2s and
* only clears blips like background ink bleeding through a crossed-out
* icon. Judging by the flank-to-flank span (the longest the state could
* truly have held) keeps sparse sampling honest: a lone dead read between
* far-apart reads spans wide and is left alone.
*/
const DEAD_RUN_MIN_SECONDS = 3.5;
const ALIVE_RUN_MIN_SECONDS = 2;
/**
* A held special only goes away by being used (or by dying), and regaining
* one takes at least this long — no special charges off ~10s of painting
* even with max Special Charge Up. So an interior not-ready run flanked by
* ready reads closer together than this, with no death inside the run, is
* a misread gap (the ready wash pulses through a dim trough; overlays
* clip the icons) and is bridged to ready.
*/
const SPECIAL_REGAIN_MIN_SECONDS = 10;
export interface BuiltMatch<E extends DetectedEvent> {
match: ScannerMatch;
/**
@@ -93,9 +153,11 @@ export function buildScannerMatches<E extends DetectedEvent>(
const nextStage = buildNextStageMap(sorted);
let open: OpenMatch<E> | null = null;
// deaths/objective reads seen with no match open to anchor them yet
// deaths/objective/status reads seen with no match open to anchor them yet
let orphanDeaths: E[] = [];
let orphanObjectives: E[] = [];
let orphanPlayerStatuses: E[] = [];
let orphanStripWeapons: E[] = [];
const finalize = (): void => {
if (!open) return;
if (open.scoreboard || open.minimaps.length > 0) {
@@ -113,6 +175,8 @@ export function buildScannerMatches<E extends DetectedEvent>(
vote(open.stageVotes, (event.data as MapStartData).stage);
orphanDeaths = [];
orphanObjectives = [];
orphanPlayerStatuses = [];
orphanStripWeapons = [];
} else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) {
if (!open) {
open = startMatch();
@@ -122,12 +186,20 @@ export function buildScannerMatches<E extends DetectedEvent>(
open.objectives = orphanObjectives.filter(
(objective) => event.t - objective.t <= FALLBACK_WINDOW_SECONDS,
);
open.playerStatuses = orphanPlayerStatuses.filter(
(status) => event.t - status.t <= FALLBACK_WINDOW_SECONDS,
);
open.stripWeapons = orphanStripWeapons.filter(
(read) => event.t - read.t <= FALLBACK_WINDOW_SECONDS,
);
}
open.scoreboard = event;
vote(open.stageVotes, (event.data as ScoreboardData).stage);
finalize();
orphanDeaths = [];
orphanObjectives = [];
orphanPlayerStatuses = [];
orphanStripWeapons = [];
} else if (event.type === MINIMAP_EVENT_TYPE) {
const stage = (event.data as MinimapData).stage;
if (open) {
@@ -153,6 +225,10 @@ export function buildScannerMatches<E extends DetectedEvent>(
(open?.deaths ?? orphanDeaths).push(event);
} else if (event.type === OBJECTIVE_EVENT_TYPE) {
(open?.objectives ?? orphanObjectives).push(event);
} else if (event.type === PLAYER_STATUS_EVENT_TYPE) {
(open?.playerStatuses ?? orphanPlayerStatuses).push(event);
} else if (event.type === STRIP_WEAPONS_EVENT_TYPE) {
(open?.stripWeapons ?? orphanStripWeapons).push(event);
}
}
finalize();
@@ -193,10 +269,12 @@ export function ingestSkipReasons<E extends DetectedEvent>(
}
/**
* Objective-counter reads that landed on a match whose detected mode is not
* Splat Zones — the SZ parser (the only one so far) misreading another
* mode's counter overlay. The builder already leaves such a match's
* `objective` null; callers should delete these events from their stores.
* Objective-counter and player-status reads that landed on a match whose
* detected mode is not Splat Zones — the SZ parser (the only one so far)
* misreading another mode's counter overlay, and the statuses that rode
* along with those misreads. The builder already leaves such a match's
* `objective`/`playerStatus` null; callers should delete these events from
* their stores.
*/
export function invalidObjectiveEvents<E extends DetectedEvent>(
built: readonly BuiltMatch<E>[],
@@ -204,7 +282,12 @@ export function invalidObjectiveEvents<E extends DetectedEvent>(
return built
.filter((b) => b.match.mode !== null && b.match.mode !== "SZ")
.flatMap((b) =>
b.sources.filter((event) => event.type === OBJECTIVE_EVENT_TYPE),
b.sources.filter(
(event) =>
event.type === OBJECTIVE_EVENT_TYPE ||
event.type === PLAYER_STATUS_EVENT_TYPE ||
event.type === STRIP_WEAPONS_EVENT_TYPE,
),
);
}
@@ -273,6 +356,10 @@ interface OpenMatch<E extends DetectedEvent> {
deaths: E[];
/** objective-counter reads; become the match's `objective` samples */
objectives: E[];
/** icon-strip reads; become the match's `playerStatus` samples */
playerStatuses: E[];
/** sampled per-slot weapon evidence for the slot→row assignment */
stripWeapons: E[];
scoreboard: E | null;
/**
* per-stage read counts (a MapStart's stage seeds it); the plurality
@@ -290,6 +377,8 @@ function startMatch<E extends DetectedEvent>(): OpenMatch<E> {
minimaps: [],
deaths: [],
objectives: [],
playerStatuses: [],
stripWeapons: [],
scoreboard: null,
stageVotes: new Map(),
lastMinimapT: null,
@@ -339,6 +428,8 @@ function toBuiltMatch<E extends DetectedEvent>(
...open.minimaps,
...open.deaths,
...open.objectives,
...open.playerStatuses,
...open.stripWeapons,
...(open.scoreboard ? [open.scoreboard] : []),
].sort((a, b) => a.t - b.t);
@@ -357,8 +448,34 @@ function toBuiltMatch<E extends DetectedEvent>(
t: event.t,
data: event.data as ObjectiveData,
}));
const playerStatuses = open.playerStatuses.map((event) => ({
t: event.t,
data: event.data as PlayerStatusData,
}));
const stripWeapons = open.stripWeapons.map((event) => ({
t: event.t,
data: event.data as StripWeaponsData,
}));
const minimapReads = open.minimaps.map((event) => ({
t: event.t,
data: event.data as MinimapData,
}));
const minimaps = minimapReads.map((read) => read.data);
const mode = board?.mode ?? start?.mode ?? null;
// only the SZ counter is parsed — reads on a known other-mode match are
// misreads of a lookalike overlay, not progress data (statuses included:
// they only ever ride along with counter reads). Minimap card states are
// mode-agnostic, so they feed the status samples regardless
const counterModeValid = mode === null || mode === "SZ";
const progress = buildProgress(
counterModeValid ? objectives : [],
counterModeValid ? playerStatuses : [],
counterModeValid ? stripWeapons : [],
minimapReads,
board,
minimapTeamColors(minimaps),
);
const match: ScannerMatch = {
startsAt:
@@ -372,17 +489,14 @@ function toBuiltMatch<E extends DetectedEvent>(
? board.matchScores
: null,
replayCode: timestamped?.replayCode ?? null,
cast: open.minimaps.some((event) => (event.data as MinimapData).spectator),
// only the SZ counter is parsed — reads on a known other-mode match
// are misreads of a lookalike overlay, not progress data
objective:
mode === null || mode === "SZ" ? buildObjective(objectives, board) : null,
cast:
open.minimaps.some((event) => (event.data as MinimapData).spectator) ||
playerStatuses.some((read) => read.data.layout !== "pov"),
objective: progress.objective,
playerStatus: progress.playerStatus,
teams: board
? teamsFromScoreboard(board, deaths)
: teamsFromMinimaps(
open.minimaps.map((event) => event.data as MinimapData),
deaths,
),
: teamsFromMinimaps(minimaps, deaths),
winner: board ? 0 : null,
pov:
board && board.povIndex !== null
@@ -401,46 +515,702 @@ function floorOrNull(t: number | undefined): number | null {
}
/**
* The counter reads as `objective` samples in `teams` order. The on-screen
* plates put the POV/alpha side left, which already is teams[0] for a
* minimap-grouped match; a scoreboard-closed match's teams are winner-first,
* so the sides swap when the POV seat sat on the losing team — or, with no
* POV arrow read, when the right plate's count got lower (in SZ the winner
* is the team whose remaining count went furthest down; ties keep the order
* as read).
* The counter reads as `objective` samples and the icon-strip reads as
* `playerStatus` samples, both in `teams` order. On POV footage the left
* plate is the POV/alpha side for the whole game, but casted footage
* reorders the plates to follow the specced player — so each counter read
* is first oriented by its sides' team ink hues (clustered against the
* first read that saw both), making the series side-stable; a status read
* carries no ink of its own and inherits the orientation of the counter
* read nearest in time (they are emitted off the same frames). Broadcast
* replay wipes re-run an earlier moment with the whole HUD intact, so both
* series are anchored by their projected clock zero (`t + time`) against
* one shared dominant projection — the live game — and reads off it are
* dropped; timerless reads follow their preceding anchored neighbor. A
* displayed count never increases (it shows the team's best remaining), so
* each side's counter series then keeps only its longest non-increasing
* run of scores — surviving OCR blips are voided rather than charted. Both
* series then go into `teams` order: a scoreboard-closed match is
* winner-first (POV seat when read; else in SZ the winner is the side
* whose remaining count went furthest down), a minimap-grouped match
* anchors on the minimap's own/alpha-vs-enemy/bravo ink colors, and with
* no signal the first read's arrangement stands. A side's four slots keep
* their on-screen left-to-right order through a side swap — whether the
* game mirrors slot order across sides is unattested so far.
*
* Minimap reads contribute status samples too (their card cross-out and
* special-camo states), interleaved with the icon-strip reads on the same
* replay-wipe anchor (timerless, so each follows its anchored neighbor).
* Their sides are own/alpha-vs-enemy/bravo — camera-stable, unlike the
* plates — so they skip the per-read cluster orientation and map to
* `teams` through the same minimap ink anchor the whole match uses
* (identity on a minimap-grouped match by construction).
*
* Within a side, the strip's slot order is the lobby seating while a
* results scoreboard re-sorts its rows per game (attested in the
* sendou-triton VoD) — so on a scoreboard-closed match each side's slots
* are reordered into row order via the slot→row assignment
* (slot-row-assignment.ts): weapon votes from the sampled StripWeapons
* evidence plus the minimap's card columns, which mirror the strip's
* seating (attested for the enemy column; the spectator screen's own
* column is assumed symmetric). The POV overlay's teammate diamond
* follows neither order, so diamond-sourced flags map by card name
* instead, and keep their as-drawn order when too few names resolve. A
* minimap-grouped match's `teams` come from the cards themselves, so its
* samples stay as drawn by construction.
*/
function buildObjective(
function buildProgress(
objectives: readonly { t: number; data: ObjectiveData }[],
playerStatuses: readonly { t: number; data: PlayerStatusData }[],
stripWeapons: readonly { t: number; data: StripWeaponsData }[],
minimapReads: readonly { t: number; data: MinimapData }[],
board: ScoreboardData | undefined,
): ScannerMatchObjective | null {
if (objectives.length === 0) return null;
minimapColors: [InkRgb | null, InkRgb | null] | null,
): {
objective: ScannerMatchObjective | null;
playerStatus: ScannerMatchPlayerStatus | null;
} {
const dominant = dominantAnchorOf([...objectives, ...playerStatuses]);
const live = withoutReplayReads(objectives, dominant);
const statusReads = [
...playerStatuses.map(
(read): StatusRead => ({
t: read.t,
fromMinimap: false,
data: read.data,
}),
),
...minimapStatusReads(minimapReads),
].sort((a, b) => a.t - b.t);
const liveStatuses = withoutReplayReads(statusReads, dominant);
const clusterHues = seedClusterHues(live);
const swapFlags = readSwapFlags(live, clusterHues);
const oriented = withMonotonicScores(orientObjectives(live, swapFlags));
const swap = board
? board.povIndex !== null
? board.povIndex >= PLAYERS_PER_TEAM
: bestCount(objectives, 1) < bestCount(objectives, 0)
: false;
const samples = objectives.map(({ t, data }): ScannerMatchObjectiveSample => {
const [a, b] = swap ? ([1, 0] as const) : ([0, 1] as const);
: bestCount(oriented, 1) < bestCount(oriented, 0)
: minimapAnchorSwap(clusterHues, minimapColors);
const minimapSwapped = swap !== minimapAnchorSwap(clusterHues, minimapColors);
const perms = board
? slotRowPermutations(
board,
stripWeapons,
minimapReads,
live,
swapFlags,
swap,
minimapSwapped,
)
: null;
const objective =
oriented.length === 0
? null
: {
mode: "SZ" as const,
samples: oriented.map((read): ScannerMatchObjectiveSample => {
const [a, b] = swap ? ([1, 0] as const) : ([0, 1] as const);
return {
t: Math.max(0, Math.floor(read.t)),
time: read.time,
score: [read.score[a], read.score[b]],
penalty: [read.penalty[a], read.penalty[b]],
control: [read.control[a], read.control[b]],
};
}),
};
const playerStatus =
liveStatuses.length === 0
? null
: {
samples: withShortSpecialGapsBridged(
withImpossibleDeadRunsFlipped(
liveStatuses.map((read): ScannerMatchPlayerStatusSample => {
const swapped = read.fromMinimap
? minimapSwapped
: nearestSwapFlag(live, swapFlags, read.t) !== swap;
const [a, b] = swapped ? ([1, 0] as const) : ([0, 1] as const);
const arrange = (
flags: readonly [PlayerStatusFlags, PlayerStatusFlags],
): [PlayerStatusFlags, PlayerStatusFlags] =>
[a, b].map((source, side) =>
applyPermutation(
flags[source]!,
readPermutation(perms, read, source, side as 0 | 1),
),
) as [PlayerStatusFlags, PlayerStatusFlags];
return {
t: Math.max(0, Math.floor(read.t)),
time: read.data.time,
special: arrange(read.data.special),
dead: arrange(read.data.dead),
};
}),
),
),
};
return { objective, playerStatus };
}
/** The slot→row permutations of a scoreboard-closed match, per source. */
interface SlotRowPerms {
/** per teams side, for strip-seated slots (the strip and card columns) */
strip: [SlotRowPermutation, SlotRowPermutation];
/** for the POV diamond's teammate flags; null = keep as drawn */
diamond: SlotRowPermutation | null;
}
/**
* How much one minimap card's parsed weapon counts next to the strip
* evidence's raw NCC scores (~0.3-0.6 per candidate per read): the card
* parser is gated on a clean read, so one card outweighs a single strip
* sample without being able to drown a match's worth of them.
*/
const MINIMAP_CARD_VOTE = 1;
/**
* Accumulate the match's weapon votes (sampled strip evidence oriented
* read-by-read like the status samples; minimap cards through the match's
* minimap anchor) and solve each side's slot→row assignment against the
* scoreboard's weapons, plus the diamond's name-based assignment for POV
* teammate cards.
*/
function slotRowPermutations(
board: ScoreboardData,
stripWeapons: readonly { t: number; data: StripWeaponsData }[],
minimapReads: readonly { t: number; data: MinimapData }[],
live: readonly { t: number; data: ObjectiveData }[],
swapFlags: readonly boolean[],
swap: boolean,
minimapSwapped: boolean,
): SlotRowPerms {
const votes: Map<MainWeaponId, number>[][] = [0, 1].map(() =>
[0, 1, 2, 3].map(() => new Map<MainWeaponId, number>()),
);
const addVote = (
side: 0 | 1,
slot: number,
weaponId: MainWeaponId,
score: number,
): void => {
const slotVotes = votes[side]![slot]!;
slotVotes.set(weaponId, (slotVotes.get(weaponId) ?? 0) + score);
};
for (const read of stripWeapons) {
const swapped = nearestSwapFlag(live, swapFlags, read.t) !== swap;
for (const side of [0, 1] as const) {
const source = swapped ? ((1 - side) as 0 | 1) : side;
for (const [slot, candidates] of read.data.slots[source].entries()) {
for (const candidate of candidates ?? []) {
addVote(side, slot, candidate.weaponId, candidate.score);
}
}
}
}
// enemy cards mirror the strip seating (attested); the spectator
// screen's own column is assumed symmetric. The POV diamond is not
// strip-seated and votes for nothing.
const enemySide = minimapSwapped ? 0 : 1;
for (const read of minimapReads) {
for (const [slot, enemy] of read.data.enemies.entries()) {
if (enemy.weaponId !== null) {
addVote(enemySide, slot, enemy.weaponId, MINIMAP_CARD_VOTE);
}
}
if (!read.data.spectator) continue;
for (const [slot, mate] of read.data.teammates.entries()) {
if (mate.weaponId !== null) {
addVote(
(1 - enemySide) as 0 | 1,
slot,
mate.weaponId,
MINIMAP_CARD_VOTE,
);
}
}
}
const rowWeapons = (side: 0 | 1) =>
board.players
.slice(side * PLAYERS_PER_TEAM, (side + 1) * PLAYERS_PER_TEAM)
.map((player) => player.weaponId);
const strip = [0, 1].map((side) =>
weaponSlotRowPermutation(votes[side]!, rowWeapons(side as 0 | 1)),
) as [SlotRowPermutation, SlotRowPermutation];
const friendlySide = minimapSwapped ? 1 : 0;
const cardNames: (string | null)[] = [null, null, null, null];
for (const read of minimapReads) {
if (read.data.spectator) continue;
for (const [slot, mate] of read.data.teammates.entries()) {
cardNames[slot] ??= mate.name?.trim() || null;
}
}
const diamond = cardNames.some((name) => name !== null)
? nameSlotRowPermutation(
cardNames,
board.players
.slice(
friendlySide * PLAYERS_PER_TEAM,
(friendlySide + 1) * PLAYERS_PER_TEAM,
)
.map((player) => player.name.trim() || null),
)
: null;
return { strip, diamond };
}
/**
* Which permutation a status read's `sourceSide` flags go through on their
* way to teams side `side`: strip-seated sources (the strip itself, card
* columns) take the weapon-vote assignment, the POV diamond its name
* assignment; a minimap-grouped match (no perms) keeps everything as drawn.
*/
function readPermutation(
perms: SlotRowPerms | null,
read: StatusRead,
sourceSide: 0 | 1,
side: 0 | 1,
): SlotRowPermutation {
if (!perms) return IDENTITY_PERMUTATION;
if (read.fromMinimap && sourceSide === 0 && !read.spectator) {
return perms.diamond ?? IDENTITY_PERMUTATION;
}
return perms.strip[side];
}
/**
* Debounce per-slot dead flags across a match's samples: an interior run
* whose flanking opposite-state reads sit closer together than the state
* could truly have held (DEAD/ALIVE_RUN_MIN_SECONDS) is a misread blip
* (background ink bleeding through a translucent splatted icon, or a box
* over a mid-animation icon) and takes the flanking state instead. Edge
* runs stay — nothing attests what came before or after the match window.
*/
function withImpossibleDeadRunsFlipped(
samples: ScannerMatchPlayerStatusSample[],
): ScannerMatchPlayerStatusSample[] {
let smoothed = samples;
for (const side of [0, 1] as const) {
for (let slot = 0; slot < PLAYERS_PER_TEAM; slot++) {
// flipping one blip can expose the next (alternating flicker), so
// each slot's series is re-swept until it settles
let changed = true;
while (changed) {
changed = false;
const series = smoothed.map((sample) => sample.dead[side][slot]);
let runStart = 0;
for (let i = 1; i <= series.length; i++) {
if (i < series.length && series[i] === series[runStart]) continue;
const interior = runStart > 0 && i < series.length;
const impossiblyShort =
interior &&
smoothed[i]!.t - smoothed[runStart - 1]!.t <=
(series[runStart] ? DEAD_RUN_MIN_SECONDS : ALIVE_RUN_MIN_SECONDS);
if (impossiblyShort) {
if (smoothed === samples) {
smoothed = samples.map((sample) => ({
...sample,
dead: [[...sample.dead[0]], [...sample.dead[1]]] as [
PlayerStatusFlags,
PlayerStatusFlags,
],
}));
}
for (let j = runStart; j < i; j++) {
smoothed[j]!.dead[side][slot] = !series[runStart];
}
changed = true;
break;
}
runStart = i;
}
}
}
}
return smoothed;
}
/**
* Bridge per-slot special-ready gaps: an interior not-ready run whose
* flanking ready reads sit closer together than a special could truly be
* regained (SPECIAL_REGAIN_MIN_SECONDS), with no death inside the run to
* explain the loss, is a misread gap (the ready wash pulses through a dim
* trough) and reads ready throughout. Short READY runs are left alone — a
* just-charged special really can be spent within a read or two. Edge
* runs stay too: nothing attests the state beyond the match window.
*/
function withShortSpecialGapsBridged(
samples: ScannerMatchPlayerStatusSample[],
): ScannerMatchPlayerStatusSample[] {
let bridged = samples;
for (const side of [0, 1] as const) {
for (let slot = 0; slot < PLAYERS_PER_TEAM; slot++) {
const series = samples.map((sample) => sample.special[side][slot]);
let runStart = 0;
for (let i = 1; i <= series.length; i++) {
if (i < series.length && series[i] === series[runStart]) continue;
const interiorGap =
!series[runStart] && runStart > 0 && i < series.length;
const impossiblyShort =
interiorGap &&
samples[i]!.t - samples[runStart - 1]!.t < SPECIAL_REGAIN_MIN_SECONDS;
const diedInside =
interiorGap &&
samples.slice(runStart, i).some((sample) => sample.dead[side][slot]);
if (impossiblyShort && !diedInside) {
if (bridged === samples) {
bridged = samples.map((sample) => ({
...sample,
special: [[...sample.special[0]], [...sample.special[1]]] as [
PlayerStatusFlags,
PlayerStatusFlags,
],
}));
}
for (let j = runStart; j < i; j++) {
bridged[j]!.special[side][slot] = true;
}
}
runStart = i;
}
}
}
return bridged;
}
/** A status read from either source, sides as read (pre-orientation). */
interface StatusRead {
t: number;
/** minimap sides are own/enemy — camera-stable, unlike the HUD plates */
fromMinimap: boolean;
/**
* minimap reads only: the 8-card spectator screen, whose own-side
* column is card-seated like the enemy one — the POV overlay's
* teammate diamond is not (see readPermutation)
*/
spectator?: boolean;
data: {
time: number | null;
special: [PlayerStatusFlags, PlayerStatusFlags];
dead: [PlayerStatusFlags, PlayerStatusFlags];
};
}
/**
* The minimap reads' card/row states as status reads: own/alpha side
* first, slots in card order — the same order `teamsFromMinimaps` seats
* players — with absent cards padded false (nothing to chart, never a
* fabricated state). A read that saw no cards at all identifies nobody
* and contributes nothing.
*/
function minimapStatusReads(
minimapReads: readonly { t: number; data: MinimapData }[],
): StatusRead[] {
return minimapReads.flatMap((read): StatusRead[] => {
const { teammates, enemies } = read.data;
if (teammates.length === 0 && enemies.length === 0) return [];
return [
{
t: read.t,
fromMinimap: true,
spectator: read.data.spectator,
data: {
time: null,
special: [
sideFlags(teammates, "specialReady"),
sideFlags(enemies, "specialReady"),
],
dead: [sideFlags(teammates, "dead"), sideFlags(enemies, "dead")],
},
},
];
});
}
function sideFlags(
players: readonly { dead: boolean; specialReady: boolean }[],
key: "dead" | "specialReady",
): PlayerStatusFlags {
return [0, 1, 2, 3].map(
(slot) => players[slot]?.[key] ?? false,
) as PlayerStatusFlags;
}
/**
* The cluster-orientation flag of the counter read nearest in time —
* status reads are emitted off the same frames as counter reads, so the
* nearest one saw the same camera arrangement. False when no counter read
* carried a usable flag (POV footage never swaps anyway).
*/
function nearestSwapFlag(
objectives: readonly { t: number }[],
swapFlags: readonly boolean[],
t: number,
): boolean {
let best = -1;
for (const [i, read] of objectives.entries()) {
if (
best === -1 ||
Math.abs(read.t - t) < Math.abs(objectives[best]!.t - t)
) {
best = i;
}
}
return best === -1 ? false : (swapFlags[best] ?? false);
}
/** A counter read with its sides in cluster (first-read) order. */
interface OrientedObjectiveRead {
t: number;
time: number | null;
score: [number | null, number | null];
penalty: [number | null, number | null];
control: [boolean, boolean];
}
/**
* The two team-ink cluster hues, seeded from the first read that saw both
* sides' colors far enough apart; null when no read qualifies (color
* orientation then stays at the as-read arrangement).
*/
function seedClusterHues(
objectives: readonly { data: ObjectiveData }[],
): [number, number] | null {
for (const { data } of objectives) {
const [left, right] = data.teamColor;
if (left === null || right === null) continue;
const hues: [number, number] = [hueOf(left), hueOf(right)];
if (hueDistance(hues[0], hues[1]) >= MIN_TEAM_HUE_SEPARATION) return hues;
}
return null;
}
/**
* Per-read cluster assignment of the sides: a read whose ink hues sit
* closer to the clusters crosswise is swapped (the cast switched the
* specced side). Reads with no readable color inherit the previous read's
* orientation — plate arrangement only changes with a camera change, which
* leaves the colors readable once the plates are back.
*/
function readSwapFlags(
objectives: readonly { t: number; data: ObjectiveData }[],
clusterHues: [number, number] | null,
): boolean[] {
let previousSwapped = false;
return objectives.map(({ data }) => {
const swapped = clusterHues
? readSwapped(data, clusterHues, previousSwapped)
: false;
previousSwapped = swapped;
return swapped;
});
}
/** The counter reads with their sides in cluster (first-read) order. */
function orientObjectives(
objectives: readonly { t: number; data: ObjectiveData }[],
swapFlags: readonly boolean[],
): OrientedObjectiveRead[] {
return objectives.map(({ t, data }, i): OrientedObjectiveRead => {
const [a, b] = swapFlags[i] ? ([1, 0] as const) : ([0, 1] as const);
return {
t: Math.max(0, Math.floor(t)),
t,
time: data.time,
score: [data.score[a], data.score[b]],
penalty: [data.penalty[a], data.penalty[b]],
control: [data.control[a], data.control[b]],
};
});
return { mode: "SZ", samples };
}
/** The lowest count a side's plate ever showed; Infinity when never read. */
function readSwapped(
data: ObjectiveData,
clusterHues: [number, number],
previousSwapped: boolean,
): boolean {
const [left, right] = data.teamColor;
if (left === null && right === null) return previousSwapped;
const identityCost =
(left ? hueDistance(hueOf(left), clusterHues[0]) : 0) +
(right ? hueDistance(hueOf(right), clusterHues[1]) : 0);
const swappedCost =
(left ? hueDistance(hueOf(left), clusterHues[1]) : 0) +
(right ? hueDistance(hueOf(right), clusterHues[0]) : 0);
if (identityCost === swappedCost) return previousSwapped;
return swappedCost < identityCost;
}
/**
* Whether the cluster order is bravo-first, judged against the minimap's
* ink colors (own/alpha column, enemy/bravo column) — the `teams` anchor
* for cast matches, which never see a results screen.
*/
function minimapAnchorSwap(
clusterHues: [number, number] | null,
minimapColors: [InkRgb | null, InkRgb | null] | null,
): boolean {
if (!clusterHues || !minimapColors) return false;
const [own, enemy] = minimapColors;
if (own === null && enemy === null) return false;
const identityCost =
(own ? hueDistance(hueOf(own), clusterHues[0]) : 0) +
(enemy ? hueDistance(hueOf(enemy), clusterHues[1]) : 0);
const swappedCost =
(own ? hueDistance(hueOf(own), clusterHues[1]) : 0) +
(enemy ? hueDistance(hueOf(enemy), clusterHues[0]) : 0);
return swappedCost < identityCost;
}
/**
* Componentwise mean of the minimap reads' per-side ink colors; null when
* no read got a side's color (or there were no minimaps at all).
*/
function minimapTeamColors(
minimaps: readonly MinimapData[],
): [InkRgb | null, InkRgb | null] | null {
if (minimaps.length === 0) return null;
const sides = [0, 1].map((side): InkRgb | null => {
const colors = minimaps
.map((minimap) => minimap.teamColors[side as 0 | 1])
.filter((color): color is InkRgb => color !== null);
if (colors.length === 0) return null;
return {
r: Math.round(colors.reduce((sum, c) => sum + c.r, 0) / colors.length),
g: Math.round(colors.reduce((sum, c) => sum + c.g, 0) / colors.length),
b: Math.round(colors.reduce((sum, c) => sum + c.b, 0) / colors.length),
};
}) as [InkRgb | null, InkRgb | null];
return sides[0] === null && sides[1] === null ? null : sides;
}
/**
* The dominant clock-zero projection across every read that carried a
* timer; null when none did. Counter and status reads project the same
* live clock, so one shared anchor voids replay wipes from both series.
*/
function dominantAnchorOf(
reads: readonly { t: number; data: { time: number | null } }[],
): number | null {
const anchors = reads.flatMap((read) =>
read.data.time !== null ? [read.t + read.data.time] : [],
);
return anchors.length === 0 ? null : dominantAnchor(anchors);
}
/**
* Drops reads taken off broadcast replay wipes: `t + time` projects the
* wall-clock moment the match timer reaches zero, which stays constant
* across a live game but lands far away when the broadcast re-runs an
* earlier moment, clock and all. Only reads near the dominant projection
* (the live series always outnumbers ~30s replay clips) are kept; a
* timerless read shares the fate of its preceding anchored neighbor (the
* following one for a timerless head), so an unreadable — or as yet
* unattested overtime — timer display never voids live reads.
*/
function withoutReplayReads<
T extends { t: number; data: { time: number | null } },
>(reads: readonly T[], dominant: number | null): T[] {
if (dominant === null) return [...reads];
const anchored = reads.flatMap((read, i) =>
read.data.time !== null ? [{ i, anchor: read.t + read.data.time }] : [],
);
if (anchored.length === 0) return [...reads];
const keptAnchored = new Map(
anchored.map(({ i, anchor }) => [
i,
Math.abs(anchor - dominant) <= REPLAY_ANCHOR_TOLERANCE_SECONDS,
]),
);
let previousKept = keptAnchored.get(anchored[0]!.i)!;
return reads.filter((_, i) => {
previousKept = keptAnchored.get(i) ?? previousKept;
return previousKept;
});
}
/** The clock-zero projection supported by the most reads within tolerance. */
function dominantAnchor(anchors: readonly number[]): number {
const sorted = anchors.toSorted((a, b) => a - b);
let best = sorted[0]!;
let bestCount = 0;
let lo = 0;
for (let hi = 0; hi < sorted.length; hi++) {
while (sorted[hi]! - sorted[lo]! > REPLAY_ANCHOR_TOLERANCE_SECONDS) lo++;
if (hi - lo + 1 > bestCount) {
bestCount = hi - lo + 1;
best = sorted[Math.floor((lo + hi) / 2)]!;
}
}
return best;
}
/**
* Voids score reads that contradict SZ's countdown: per side, only the
* longest non-increasing subsequence of the readable scores is kept and
* every read off it gets that side's score nulled (its penalty/control
* stand). A misread that slipped past the detector — a truncated "50"
* charted as a 0-dip, a stray 100 — is always the minority against the
* surrounding correct series, so it is what gets dropped.
*/
function withMonotonicScores(
oriented: readonly OrientedObjectiveRead[],
): OrientedObjectiveRead[] {
const smoothed = oriented.map((read) => ({
...read,
score: [...read.score] as [number | null, number | null],
}));
for (const side of [0, 1] as const) {
const readIndices = smoothed.flatMap((read, i) =>
read.score[side] !== null ? [i] : [],
);
const kept = longestNonIncreasingRun(
readIndices.map((i) => smoothed[i]!.score[side]!),
);
for (const [k, i] of readIndices.entries()) {
if (!kept.has(k)) smoothed[i]!.score[side] = null;
}
}
return smoothed;
}
/** Indices of one longest non-increasing subsequence of `values`. */
function longestNonIncreasingRun(values: readonly number[]): Set<number> {
const lengths = new Array<number>(values.length).fill(1);
const prev = new Array<number>(values.length).fill(-1);
let bestEnd = values.length > 0 ? 0 : -1;
for (let i = 0; i < values.length; i++) {
for (let j = 0; j < i; j++) {
if (values[j]! >= values[i]! && lengths[j]! + 1 > lengths[i]!) {
lengths[i] = lengths[j]! + 1;
prev[i] = j;
}
}
if (lengths[i]! > lengths[bestEnd]!) bestEnd = i;
}
const kept = new Set<number>();
for (let i = bestEnd; i !== -1; i = prev[i]!) kept.add(i);
return kept;
}
/** The lowest count a side ever showed; Infinity when never read. */
function bestCount(
objectives: readonly { data: ObjectiveData }[],
oriented: readonly OrientedObjectiveRead[],
side: 0 | 1,
): number {
return Math.min(
...objectives.map(
({ data }) => data.score[side] ?? Number.POSITIVE_INFINITY,
),
...oriented.map((read) => read.score[side] ?? Number.POSITIVE_INFINITY),
);
}

View File

@@ -27,6 +27,11 @@ import {
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";
@@ -132,6 +137,13 @@ export async function assembleScoreboardResources(
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),
@@ -155,6 +167,9 @@ export async function assembleScoreboardResources(
get minimapLightWeapons() {
return minimapLightWeapons();
},
get stripWeapons() {
return stripWeapons();
},
get specials() {
return specials();
},

View File

@@ -60,6 +60,33 @@ export interface ScannerMatchObjective {
samples: ScannerMatchObjectiveSample[];
}
export type ScannerMatchPlayerFlags = [boolean, boolean, boolean, boolean];
export interface ScannerMatchPlayerStatusSample {
/** whole seconds into the video/stream the icon strip was read at */
t: number;
/**
* seconds shown on the match timer at the read — the same key the
* objective samples carry, for charting both on one clock axis
*/
time: number | null;
/** special held per player, teams in `teams` order, slots in row order */
special: [ScannerMatchPlayerFlags, ScannerMatchPlayerFlags];
/** splatted per player, same arrangement */
dead: [ScannerMatchPlayerFlags, ScannerMatchPlayerFlags];
}
/**
* Per-player special/death states over the match, read off the icon strip
* next to the objective counter. Chronological and deduped to state
* changes like the objective samples, with an unchanged state re-confirmed
* every ~6s — render longer sample gaps as unknown, not as a continued
* state.
*/
export interface ScannerMatchPlayerStatus {
samples: ScannerMatchPlayerStatusSample[];
}
export interface ScannerMatch {
/** whole seconds into the video/stream the match starts at */
startsAt: number | null;
@@ -83,11 +110,18 @@ export interface ScannerMatch {
/** spectator/casted footage (the 8-player spectator map screen was seen) */
cast: boolean;
/**
* counter progress samples in `teams` order (the on-screen left plate is
* the POV/alpha side; the builder reorients when teams[0] is the other
* side); null when no counter was read
* counter progress samples in `teams` order the builder tracks each
* side's team ink color, so casted footage's plate swaps (the plates
* follow the specced player) can't scramble the series; null when no
* counter was read
*/
objective: ScannerMatchObjective | null;
/**
* per-player special/death samples in `teams` order, oriented alongside
* the objective samples (a status read pairs with the counter read of
* its frame); null when the icon strip was never read
*/
playerStatus: ScannerMatchPlayerStatus | null;
/**
* on-screen order: scoreboard rows 0-3 are teams[0] (the winners),
* minimap alpha/own side is teams[0]

View File

@@ -0,0 +1,130 @@
/**
* Strip-slot → scoreboard-row assignment. The in-match icon strip (and the
* minimap's card columns, which mirror it) keeps the lobby seating for the
* whole set, while the results scoreboard re-sorts each team per game — so
* per-slot status series can only be paired with scoreboard rows through
* identity evidence. Two kinds are combined:
*
* - weapon votes: per-slot candidate scores accumulated across a match's
* sampled StripWeapons reads (strip-weapons.ts) plus the minimap cards'
* parsed weapons. The best-scoring of the 24 possible slot→row
* assignments against the scoreboard's four weapons wins — the global
* constraint corrects slots whose own evidence is wrong or missing
* (attested: a slot with zero readable votes still lands right by
* elimination).
* - card names (the POV minimap's teammate diamond): matched directly
* against scoreboard row names.
*
* Ties resolve toward the fewest moved slots, so two rows sharing a weapon
* keep their as-drawn relative order, and thin evidence degrades to the
* as-drawn arrangement rather than a coin flip.
*/
import type { MainWeaponId } from "~/modules/in-game-lists/types";
/** A slot→row permutation: `perm[slot]` is the scoreboard row the slot feeds. */
export type SlotRowPermutation = readonly [number, number, number, number];
export const IDENTITY_PERMUTATION: SlotRowPermutation = [0, 1, 2, 3];
/**
* Total accumulated vote score the winning assignment needs before it may
* reorder anything, and the lead it needs over the best differing
* assignment. Calibrated on the sendou-triton VoD, where correct
* assignments scored 10-33 with margins 2.2-5.3 over ~20 sampled reads;
* junk evidence (a strip geometry mispick, non-Splatoon lookalikes)
* spreads flat and fails the margin.
*/
const MIN_ASSIGNMENT_SCORE = 1.5;
const MIN_ASSIGNMENT_MARGIN = 0.75;
/** All 24 permutations, fewest-moved-slots first (ties resolve to earlier). */
const PERMUTATIONS: SlotRowPermutation[] = (() => {
const all: SlotRowPermutation[] = [];
for (const a of [0, 1, 2, 3])
for (const b of [0, 1, 2, 3])
for (const c of [0, 1, 2, 3])
for (const d of [0, 1, 2, 3]) {
if (new Set([a, b, c, d]).size === 4) all.push([a, b, c, d]);
}
const displaced = (perm: SlotRowPermutation) =>
perm.filter((row, slot) => row !== slot).length;
return all.sort((x, y) => displaced(x) - displaced(y));
})();
/**
* The slot→row assignment best supported by one side's accumulated weapon
* votes, against that side's scoreboard row weapons. Falls back to the
* as-drawn order when the evidence is too thin or too close to call (see
* MIN_ASSIGNMENT_SCORE/MARGIN).
*/
export function weaponSlotRowPermutation(
votes: readonly ReadonlyMap<MainWeaponId, number>[],
rowWeapons: readonly (MainWeaponId | null)[],
): SlotRowPermutation {
const scored = PERMUTATIONS.map((perm) => ({
perm,
score: perm.reduce((sum, row, slot) => {
const weapon = rowWeapons[row];
return sum + (weapon === null ? 0 : (votes[slot]?.get(weapon!) ?? 0));
}, 0),
}));
let best = scored[0]!;
for (const candidate of scored) {
if (candidate.score > best.score) best = candidate;
}
if (best.score < MIN_ASSIGNMENT_SCORE) return IDENTITY_PERMUTATION;
const runnerUp = Math.max(
...scored
.filter((candidate) => candidate.score < best.score)
.map((candidate) => candidate.score),
0,
);
if (best.score - runnerUp < MIN_ASSIGNMENT_MARGIN) {
return IDENTITY_PERMUTATION;
}
return best.perm;
}
/**
* A card→row assignment from card names (the POV minimap's teammate
* diamond, whose order matches neither the strip nor the scoreboard):
* unique case-insensitive name matches place their cards, the leftovers
* keep their relative as-drawn order. Null — keep the as-drawn order —
* when fewer than two cards resolve, since a single hit cannot attest the
* arrangement is worth disturbing.
*/
export function nameSlotRowPermutation(
cardNames: readonly (string | null)[],
rowNames: readonly (string | null)[],
): SlotRowPermutation | null {
const normalized = (name: string | null) =>
name?.trim().toLowerCase() || null;
const rows = rowNames.map(normalized);
const assignment: (number | null)[] = [null, null, null, null];
const takenRows = new Set<number>();
let resolved = 0;
for (const [slot, cardName] of cardNames.map(normalized).entries()) {
if (cardName === null) continue;
const matches = rows.flatMap((row, i) => (row === cardName ? [i] : []));
if (matches.length !== 1 || takenRows.has(matches[0]!)) continue;
assignment[slot] = matches[0]!;
takenRows.add(matches[0]!);
resolved++;
}
if (resolved < 2) return null;
const freeRows = [0, 1, 2, 3].filter((row) => !takenRows.has(row));
for (const [slot, row] of assignment.entries()) {
if (row === null) assignment[slot] = freeRows.shift()!;
}
return assignment as unknown as SlotRowPermutation;
}
/** `flags` rearranged so slot `i`'s value lands at `perm[i]`. */
export function applyPermutation<T>(
flags: readonly T[],
perm: SlotRowPermutation,
): T[] {
const out = [...flags] as T[];
for (const [slot, row] of perm.entries()) out[row] = flags[slot]!;
return out;
}

View File

@@ -4,10 +4,19 @@
* highest-confidence version; events below a confidence floor are dropped.
*/
import {
MINIMAP_EVENT_TYPE,
sameMinimapStatusData,
} from "../detectors/minimap/index";
import {
OBJECTIVE_EVENT_TYPE,
sameObjectiveData,
} from "../detectors/objective/index";
import {
PLAYER_STATUS_EVENT_TYPE,
samePlayerStatusData,
} from "../detectors/objective/player-status";
import { STRIP_WEAPONS_EVENT_TYPE } from "../detectors/objective/strip-weapons";
import { SCOREBOARD_EVENT_TYPE } from "../detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../detectors/scoreboard-battle-log/index";
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../detectors/scoreboard-battle-log-replay/index";
@@ -31,6 +40,12 @@ export interface TimelineOptions {
sameEventDataByType: Record<string, (a: unknown, b: unknown) => boolean>;
/** events below this confidence are dropped */
minConfidence: number;
/**
* per-type confidence floor overrides: evidence-carrying events whose
* scores live on a different scale than parse confidences (raw NCC
* peaks) opt out of the shared floor
*/
minConfidenceByType: Record<string, number>;
}
const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
@@ -39,18 +54,34 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
// of one death land within the window while consecutive deaths are outside;
// players flick the map open for 1-3s and each open is a fresh sample
// (slots read differently across opens), so minimap frames merge only
// within one open
// within one open — and a dead/special flip caught mid-open stays its
// own event via the content guard
// objective counter reads repeat every check second; the content guard
// below keeps every actual change while the window collapses static
// stretches into one event per state
mergeWindowByType: { Death: 8, Minimap: 5, [OBJECTIVE_EVENT_TYPE]: 10 },
// stretches into one event per state. Player statuses can revisit an
// exact prior state no sooner than a respawn takes (~9s), so their
// window must stay under that
// strip weapon evidence is sampled every ~5s and consecutive samples are
// distinct evidence — only same-frame re-reads should collapse
mergeWindowByType: {
Death: 8,
[MINIMAP_EVENT_TYPE]: 5,
[OBJECTIVE_EVENT_TYPE]: 10,
[PLAYER_STATUS_EVENT_TYPE]: 5,
[STRIP_WEAPONS_EVENT_TYPE]: 2,
},
sameEventDataByType: {
[SCOREBOARD_EVENT_TYPE]: sameScoreboardMatch,
[SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE]: sameScoreboardMatch,
[SCOREBOARD_BATTLE_LOG_EVENT_TYPE]: sameScoreboardMatch,
[MINIMAP_EVENT_TYPE]: sameMinimapStatusData,
[OBJECTIVE_EVENT_TYPE]: sameObjectiveData,
[PLAYER_STATUS_EVENT_TYPE]: samePlayerStatusData,
},
minConfidence: 0.6,
minConfidenceByType: {
[STRIP_WEAPONS_EVENT_TYPE]: 0,
},
};
export type TimelineAction =
@@ -72,7 +103,10 @@ export class TimelineBuilder {
}
push(event: DetectedEvent): TimelineAction {
if (event.confidence < this.#options.minConfidence) {
const minConfidence =
this.#options.minConfidenceByType[event.type] ??
this.#options.minConfidence;
if (event.confidence < minConfidence) {
return { action: "dropped", reason: "low-confidence" };
}
const window =

View File

@@ -36,12 +36,16 @@ interface ExpectedPlayer {
}
interface ExpectedMinimapTeammate {
slot?: "up" | "left" | "right" | "self";
slot?: "up" | "left" | "right" | "self" | "down";
name?: string | null;
/** informational for the human corrector; tests compare weaponId */
weaponLabel?: string | null;
weaponId?: MainWeaponId | null;
abilities?: (AbilityWithUnknown | null)[];
/** struck through with the respawn cross-out */
dead?: boolean;
/** on the light camo surface of a charged special */
specialReady?: boolean;
}
interface ExpectedMinimapEnemy {
@@ -51,6 +55,10 @@ interface ExpectedMinimapEnemy {
weaponLabel?: string | null;
weaponId?: MainWeaponId | null;
abilities?: (AbilityWithUnknown | null)[];
/** struck through with the respawn cross-out */
dead?: boolean;
/** on the light camo surface of a charged special */
specialReady?: boolean;
}
interface ExpectedScoreboard {
@@ -63,6 +71,8 @@ interface ExpectedScoreboard {
| "MapStart"
| "Minimap"
| "Objective"
| "PlayerStatus"
| "StripWeapons"
| "none";
data?: {
lobby?: ScannerLobby;
@@ -99,6 +109,19 @@ interface ExpectedScoreboard {
penalty?: [number | null, number | null];
/** Objective only: which team currently holds the objective */
control?: [boolean, boolean];
/** PlayerStatus only: special held per slot, [left team, right team] */
special?: [boolean[], boolean[]];
/** PlayerStatus only: splatted per slot, [left team, right team] */
dead?: [boolean[], boolean[]];
/** PlayerStatus + StripWeapons: which icon-strip geometry the frame shows */
layout?: "pov" | "cast" | "cast-mirror";
/**
* StripWeapons only: the true weapon per slot, [left team, right
* team], null = slot skipped (splatted icon). weaponLabels is
* informational for the human corrector.
*/
weapons?: [(MainWeaponId | null)[], (MainWeaponId | null)[]];
weaponLabels?: [(string | null)[], (string | null)[]];
/** Minimap only: casted 8-player spectator map screen (not parsed yet) */
spectator?: boolean;
/** Minimap only: own-team callout cards in slot order */

View File

@@ -29,7 +29,9 @@ export const meta: MetaFunction = (args) => {
// may be imported at route-module top level — only from inside this lazily
// imported client component tree, after hydration.
const ScannerApp = lazy(() =>
import("~/features/scanner/components/App").then((m) => ({ default: m.App })),
import("~/features/scanner/components/ScannerApp").then((m) => ({
default: m.ScannerApp,
})),
);
export default function ScannerPage() {

View File

@@ -19,6 +19,7 @@ import type {
ScannerMatch,
ScannerMatchObjective,
ScannerMatchPlayer,
ScannerMatchPlayerStatus,
ScannerMatchTeam,
} from "./core/scanner-match";
import { SCANNER_LOBBIES } from "./scanner-types";
@@ -72,6 +73,26 @@ const scannerMatchObjectiveSchema = z.object({
.max(MAX_OBJECTIVE_SAMPLES),
});
const playerFlagsSchema = z.tuple([
z.boolean(),
z.boolean(),
z.boolean(),
z.boolean(),
]);
const scannerMatchPlayerStatusSampleSchema = z.object({
t: z.number().int().min(0),
time: z.number().int().min(0).nullable(),
special: z.tuple([playerFlagsSchema, playerFlagsSchema]),
dead: z.tuple([playerFlagsSchema, playerFlagsSchema]),
});
const scannerMatchPlayerStatusSchema = z.object({
samples: z
.array(scannerMatchPlayerStatusSampleSchema)
.max(MAX_OBJECTIVE_SAMPLES),
});
export const scannerMatchSchema = z.object({
startsAt: z.number().int().min(0).nullable(),
endsAt: z.number().int().min(0).nullable(),
@@ -86,6 +107,7 @@ export const scannerMatchSchema = z.object({
replayCode: detectionText.nullable(),
cast: z.boolean(),
objective: scannerMatchObjectiveSchema.nullable(),
playerStatus: scannerMatchPlayerStatusSchema.nullable(),
teams: z.tuple([scannerMatchTeamSchema, scannerMatchTeamSchema]),
winner: teamIndexSchema.nullable(),
pov: z
@@ -115,6 +137,10 @@ true satisfies MutuallyAssignable<
z.infer<typeof scannerMatchObjectiveSchema>,
ScannerMatchObjective
>;
true satisfies MutuallyAssignable<
z.infer<typeof scannerMatchPlayerStatusSchema>,
ScannerMatchPlayerStatus
>;
true satisfies MutuallyAssignable<
z.infer<typeof scannerMatchSchema>,
ScannerMatch

View File

@@ -10,6 +10,7 @@ describe("scannerSearchParams", () => {
assertRoundTrips(scannerSearchParams, {
tab: ["live", "screenshot", "vod"],
inspect: ["1723456789012-abc123", null],
telemetry: [true, false],
});
});

View File

@@ -10,4 +10,9 @@ export const scannerSearchParams = SearchParams.define({
tab: SP.param(z.enum(SCANNER_TABS), { default: "live", loader: false }),
/** Inspect handoff key: the screenshot tab claims this frame on load */
inspect: SP.param(z.string().max(100).nullable(), { loader: false }),
/**
* Opt-in scan telemetry: counters are accumulated and the panel is shown
* only when this is set by hand in the URL (no link points at it)
*/
telemetry: SP.param(z.boolean(), { default: false, loader: false }),
});

View File

@@ -0,0 +1,216 @@
/*
Chrome shared by the scanner's pages and cards. Everything is scoped under
`.app`, whose container name the pages' container queries key off; design
tokens come from sendou.ink's app/styles/vars.css.
*/
.app {
container: scanner / inline-size;
/* the app's own buttons, kept at :where() specificity so any component
class overrides it without having to win an ordering race */
& :where(button) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s-1-5);
border: var(--border-style-accent);
border-radius: var(--radius-field);
appearance: none;
background: var(--color-text-accent);
color: var(--color-text-inverse);
cursor: pointer;
font-family: inherit;
font-size: var(--font-xs);
font-weight: var(--weight-bold);
padding: 0 var(--field-padding);
height: var(--field-size-sm);
white-space: nowrap;
user-select: none;
& > svg {
width: 16px;
height: 16px;
}
&:focus-visible {
outline: var(--focus-ring);
outline-offset: 1px;
}
&:active {
transform: translateY(1px);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: initial;
}
}
}
button.outlined {
background: transparent;
color: var(--color-text-accent);
}
/* secondary actions live behind this icon-only trigger, pushed to the row's end.
Sized explicitly: it is a SendouButton, which brings a competing base look */
button.iconMenu {
margin-inline-start: auto;
width: var(--field-size-sm);
height: var(--field-size-sm);
border-radius: var(--radius-field);
padding: 0;
border: var(--border-style);
background: var(--color-bg-higher);
color: var(--color-text);
&:hover {
color: var(--color-text-accent);
}
& > svg {
width: var(--field-size-icon);
height: var(--field-size-icon);
margin: 0;
}
}
.controls {
display: flex;
gap: var(--s-2);
align-items: center;
margin-bottom: var(--s-3);
flex-wrap: wrap;
}
.status {
padding: var(--s-0-5) var(--s-3);
border-radius: var(--radius-full);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
border: var(--border-style);
}
.idle {
color: var(--color-text-high);
}
.watching {
color: var(--color-info-high);
border-color: var(--color-info-low);
background: var(--color-info-low);
&::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
border-radius: var(--radius-full);
background: currentColor;
margin-inline-end: var(--s-1-5);
animation: scanner-pulse 1.6s ease-in-out infinite;
}
}
.detected {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--color-success-high);
border-color: var(--color-success-low);
background: var(--color-success-low);
}
@keyframes scanner-pulse {
50% {
opacity: 0.25;
transform: scale(0.8);
}
}
.liveLayout {
display: grid;
grid-template-columns: minmax(320px, 640px) minmax(0, 1fr);
gap: var(--s-4);
align-items: start;
}
.preview {
width: 100%;
background: #000;
border-radius: var(--radius-box);
border: var(--border-width) solid var(--color-bg-high);
}
.feed {
display: flex;
flex-direction: column;
gap: var(--s-3);
}
.score {
color: var(--color-text-high);
font-size: var(--font-2xs);
}
.error {
color: var(--color-error);
}
.dropzone {
border: var(--border-width) dashed var(--color-border-high);
border-radius: var(--radius-box);
padding: var(--s-8);
text-align: center;
color: var(--color-text-high);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
margin-bottom: var(--s-4);
& label {
display: inline;
font-size: inherit;
font-weight: inherit;
margin-block-end: 0;
text-decoration: underline;
cursor: pointer;
}
}
.over {
border-color: var(--color-text-accent);
color: var(--color-text-accent);
}
/* a 640px wide preview would starve the feed next to it: split evenly instead */
@container scanner (width < 1000px) {
.liveLayout {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
/* the controls wrap here, where a trigger pushed to the end reads as stray */
button.iconMenu {
margin-inline-start: 0;
}
}
@container scanner (width < 700px) {
.liveLayout {
grid-template-columns: minmax(0, 1fr);
}
/* stacked, the preview would otherwise push the feed off the screen */
.preview {
max-height: 45vh;
object-fit: contain;
}
}
@media (prefers-reduced-motion: reduce) {
.watching::before {
animation: none;
}
}

View File

@@ -24,16 +24,24 @@ function teammate(
{
name = null as string | null,
abilities = [] as (AbilityWithUnknown | null)[],
dead = false,
} = {},
): MinimapTeammate {
return { slot: SPECTATOR_SLOTS[i]!, name, weaponId, abilities };
return {
slot: SPECTATOR_SLOTS[i]!,
name,
weaponId,
abilities,
dead,
specialReady: false,
};
}
function enemy(
weaponId: MainWeaponId | null,
{ name = null as string | null } = {},
): MinimapEnemy {
return { name, weaponId, abilities: [] };
return { name, weaponId, abilities: [], dead: false, specialReady: false };
}
function minimap(
@@ -44,7 +52,13 @@ function minimap(
enemies = BRAVO.map((id) => enemy(id)),
} = {},
): DetectedEvent {
const data: MinimapData = { stage, spectator: true, teammates, enemies };
const data: MinimapData = {
stage,
spectator: true,
teammates,
enemies,
teamColors: [null, null],
};
return { type: "Minimap", t, confidence: 0.8, data };
}
@@ -84,6 +98,16 @@ test("a changed ability read keeps both minimaps", () => {
assert.equal(kept.length, 2);
});
test("a changed dead state keeps both minimaps", () => {
const kept = withoutRepeatEvents([
minimap(70),
minimap(73, {
teammates: ALPHA.map((id, i) => teammate(id, i, { dead: i === 0 })),
}),
]);
assert.equal(kept.length, 2);
});
test("dedupes across interleaved events of other types, dropping none of them", () => {
const kept = withoutRepeatEvents([minimap(70), death(72), minimap(74)]);
assert.deepEqual(

View File

@@ -12,7 +12,9 @@
"SJ"
],
"weaponLabel": "Splattershot",
"weaponId": 40
"weaponId": 40,
"dead": false,
"specialReady": false
},
{
"slot": "left",
@@ -23,7 +25,9 @@
"SJ"
],
"weaponLabel": "Splatana Stamper",
"weaponId": 8000
"weaponId": 8000,
"dead": false,
"specialReady": true
},
{
"slot": "right",
@@ -34,7 +38,9 @@
"ISS"
],
"weaponLabel": "Splattershot Jr.",
"weaponId": 10
"weaponId": 10,
"dead": false,
"specialReady": false
},
{
"slot": "self",
@@ -45,7 +51,9 @@
"SJ"
],
"weaponLabel": "Custom Blaster",
"weaponId": 211
"weaponId": 211,
"dead": false,
"specialReady": false
}
],
"enemies": [
@@ -56,7 +64,9 @@
"SJ"
],
"weaponLabel": "Inkbrush Nouveau",
"weaponId": 1101
"weaponId": 1101,
"dead": false,
"specialReady": true
},
{
"abilities": [
@@ -65,7 +75,9 @@
"SJ"
],
"weaponLabel": ".52 Gal",
"weaponId": 50
"weaponId": 50,
"dead": false,
"specialReady": false
},
{
"abilities": [
@@ -74,7 +86,9 @@
"SCU"
],
"weaponLabel": "Big Swig Roller",
"weaponId": 1040
"weaponId": 1040,
"dead": false,
"specialReady": true
},
{
"abilities": [
@@ -83,7 +97,9 @@
"SSU"
],
"weaponLabel": "Luna Blaster Neo",
"weaponId": 201
"weaponId": 201,
"dead": false,
"specialReady": false
}
],
"stageLabel": "Undertow Spillway"

View File

@@ -13,7 +13,9 @@
"SJ"
],
"weaponLabel": "Splash-o-matic",
"weaponId": 20
"weaponId": 20,
"dead": false,
"specialReady": false
},
{
"slot": "right",
@@ -24,7 +26,9 @@
"SJ"
],
"weaponLabel": "Dread Wringer",
"weaponId": 3050
"weaponId": 3050,
"dead": false,
"specialReady": false
},
{
"slot": "down",
@@ -35,7 +39,9 @@
"SJ"
],
"weaponLabel": "Custom Blaster",
"weaponId": 211
"weaponId": 211,
"dead": false,
"specialReady": false
},
{
"slot": "left",
@@ -46,7 +52,9 @@
"ISM"
],
"weaponLabel": "Snipewriter 5H",
"weaponId": 2070
"weaponId": 2070,
"dead": false,
"specialReady": false
}
],
"enemies": [
@@ -58,7 +66,9 @@
"SJ"
],
"weaponLabel": "Snipewriter 5H",
"weaponId": 2070
"weaponId": 2070,
"dead": false,
"specialReady": false
},
{
"name": "ねこすき",
@@ -68,7 +78,9 @@
"SJ"
],
"weaponLabel": "S-BLAST '91",
"weaponId": 261
"weaponId": 261,
"dead": false,
"specialReady": false
},
{
"name": "そっぴ",
@@ -78,7 +90,9 @@
"SJ"
],
"weaponLabel": "Zink Mini Splatling",
"weaponId": 4001
"weaponId": 4001,
"dead": false,
"specialReady": false
},
{
"name": "しゅーた",
@@ -88,7 +102,9 @@
"SJ"
],
"weaponLabel": ".52 Gal",
"weaponId": 50
"weaponId": 50,
"dead": false,
"specialReady": false
}
],
"stageLabel": "Manta Maria"

View File

@@ -13,7 +13,9 @@
"ISM"
],
"weaponLabel": "Snipewriter 5H",
"weaponId": 2070
"weaponId": 2070,
"dead": false,
"specialReady": false
},
{
"slot": "right",
@@ -24,7 +26,9 @@
"SSU"
],
"weaponLabel": "Splash-o-matic",
"weaponId": 20
"weaponId": 20,
"dead": false,
"specialReady": false
},
{
"slot": "down",
@@ -35,7 +39,9 @@
"SJ"
],
"weaponLabel": "Custom Blaster",
"weaponId": 211
"weaponId": 211,
"dead": false,
"specialReady": false
},
{
"slot": "left",
@@ -46,7 +52,9 @@
"SJ"
],
"weaponLabel": "Splatana Stamper",
"weaponId": 8000
"weaponId": 8000,
"dead": false,
"specialReady": false
}
],
"enemies": [
@@ -58,7 +66,9 @@
"SJ"
],
"weaponLabel": "Mint Decavitator",
"weaponId": 8020
"weaponId": 8020,
"dead": false,
"specialReady": false
},
{
"name": "たなは",
@@ -68,7 +78,9 @@
"SCU"
],
"weaponLabel": "Splat Brella",
"weaponId": 6000
"weaponId": 6000,
"dead": false,
"specialReady": false
},
{
"name": "いなてんきんぐ",
@@ -78,7 +90,9 @@
"RSU"
],
"weaponLabel": "Zink Mini Splatling",
"weaponId": 4001
"weaponId": 4001,
"dead": false,
"specialReady": false
},
{
"name": "そふ",
@@ -88,7 +102,9 @@
"RES"
],
"weaponLabel": "Heavy Splatling",
"weaponId": 4010
"weaponId": 4010,
"dead": false,
"specialReady": false
}
],
"stageLabel": "Mincemeat Metalworks"

View File

@@ -13,7 +13,9 @@
"SJ"
],
"weaponLabel": ".96 Gal",
"weaponId": 80
"weaponId": 80,
"dead": false,
"specialReady": false
},
{
"slot": "right",
@@ -24,7 +26,9 @@
"ISM"
],
"weaponLabel": "Flingza Roller",
"weaponId": 1030
"weaponId": 1030,
"dead": false,
"specialReady": false
},
{
"slot": "down",
@@ -35,7 +39,9 @@
"SJ"
],
"weaponLabel": "Order Slosher Replica",
"weaponId": 3005
"weaponId": 3005,
"dead": false,
"specialReady": false
},
{
"slot": "left",
@@ -46,7 +52,9 @@
"ISM"
],
"weaponLabel": "Rapid Blaster Pro WNT-R",
"weaponId": 252
"weaponId": 252,
"dead": false,
"specialReady": false
}
],
"enemies": [
@@ -58,7 +66,9 @@
"SJ"
],
"weaponLabel": "S-BLAST '91",
"weaponId": 261
"weaponId": 261,
"dead": false,
"specialReady": false
},
{
"name": "バッラスト",
@@ -68,7 +78,9 @@
"ISM"
],
"weaponLabel": "Snipewriter 5H",
"weaponId": 2070
"weaponId": 2070,
"dead": false,
"specialReady": false
},
{
"name": "Jared",
@@ -78,7 +90,9 @@
"SJ"
],
"weaponLabel": "Twinklez Splat Dualies",
"weaponId": 5012
"weaponId": 5012,
"dead": false,
"specialReady": false
},
{
"name": "Reiyu C:3",
@@ -88,7 +102,9 @@
"SJ"
],
"weaponLabel": "Splash-o-matic",
"weaponId": 20
"weaponId": 20,
"dead": false,
"specialReady": false
}
],
"stageLabel": "Crableg Capital"

View File

@@ -0,0 +1,118 @@
{
"event": "Minimap",
"data": {
"stage": 1,
"spectator": true,
"teammates": [
{
"slot": "up",
"name": "King Inate",
"abilities": [
"LDE",
"RSU",
"RSU"
],
"weaponLabel": "Zink Mini Splatling",
"weaponId": 4001,
"dead": false,
"specialReady": false
},
{
"slot": "right",
"name": "todo",
"abilities": [
"CB",
"SSU",
"SJ"
],
"weaponLabel": "Mint Decavitator",
"weaponId": 8020,
"dead": false,
"specialReady": false
},
{
"slot": "down",
"name": "tanaha",
"abilities": [
"RSU",
"SSU",
"SCU"
],
"weaponLabel": "Splat Brella",
"weaponId": 6000,
"dead": false,
"specialReady": false
},
{
"slot": "left",
"name": "soph",
"abilities": [
"RSU",
"RSU",
"SJ"
],
"weaponLabel": "Heavy Splatling",
"weaponId": 4010,
"dead": false,
"specialReady": false
}
],
"enemies": [
{
"name": "Burstie",
"abilities": [
"SSU",
"SSU",
"SSU"
],
"weaponLabel": ".96 Gal",
"weaponId": 80,
"dead": false,
"specialReady": false
},
{
"name": "[K]yo!",
"abilities": [
"CB",
"QR",
"SJ"
],
"weaponLabel": "Slosher",
"weaponId": 3000,
"dead": false,
"specialReady": false
},
{
"name": "leafi !!",
"abilities": [
"OG",
"SSU",
"SJ"
],
"weaponLabel": "Custom Blaster",
"weaponId": 211,
"dead": false,
"specialReady": false
},
{
"name": "biscuit",
"abilities": [
"LDE",
"SCU",
"ISM"
],
"weaponLabel": "Snipewriter 5H",
"weaponId": 2070,
"dead": false,
"specialReady": false
}
],
"stageLabel": "Eeltail Alley"
},
"options": {
"skipFields": [
"teammates.0.name"
],
"notes": "8-player spectator map screen from the SWS26 cast, same game as the objective/ splat-zones-cast-* fixtures: green team left column, purple right — the cross-detector anchor pair for objective team-color tracking. teammates.0.name skipped: King Inate reads King lnate — capital I and lowercase l are pixel-identical in BlitzMain at this size (the POV nameplate in the sibling frame confirms Inate)."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -12,7 +12,9 @@
"SJ"
],
"weaponLabel": "Tri-Stringer",
"weaponId": 7010
"weaponId": 7010,
"dead": false,
"specialReady": false
},
{
"slot": "left",
@@ -23,14 +25,18 @@
"SS"
],
"weaponLabel": "Splattershot Jr.",
"weaponId": 10
"weaponId": 10,
"dead": false,
"specialReady": false
},
{
"slot": "right",
"name": null,
"abilities": [],
"weaponLabel": null,
"weaponId": null
"weaponId": null,
"dead": true,
"specialReady": false
},
{
"slot": "self",
@@ -41,7 +47,9 @@
"SS"
],
"weaponLabel": "Ballpoint Splatling",
"weaponId": 4030
"weaponId": 4030,
"dead": false,
"specialReady": false
}
],
"enemies": [
@@ -52,17 +60,23 @@
"IRU"
],
"weaponLabel": "Slosher",
"weaponId": 3000
"weaponId": 3000,
"dead": false,
"specialReady": false
},
{
"abilities": [],
"weaponLabel": "Splat Roller",
"weaponId": 1010
"weaponId": 1010,
"dead": true,
"specialReady": false
},
{
"abilities": [],
"weaponLabel": "Dualie Squelchers",
"weaponId": 5030
"weaponId": 5030,
"dead": true,
"specialReady": true
},
{
"abilities": [
@@ -71,7 +85,9 @@
"SS"
],
"weaponLabel": "Splattershot",
"weaponId": 40
"weaponId": 40,
"dead": false,
"specialReady": false
}
],
"stageLabel": "Museum d'Alfonsino"

View File

@@ -0,0 +1,13 @@
{
"event": "Objective",
"data": {
"mode": "SZ",
"time": 262,
"score": [50, 100],
"penalty": [null, null],
"control": [true, false]
},
"options": {
"notes": "Casted stream (Splat World Series 2026 playoffs) mid-fight: lime team in control, white '50' on the bright lime fill blurred by stream compression — the leading '5' scored under the main digit floor and the read truncated to 0 before the extension floor existed."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -0,0 +1,13 @@
{
"event": "Objective",
"data": {
"mode": "SZ",
"time": 249,
"score": [100, 29],
"penalty": [null, null],
"control": [false, true]
},
"options": {
"notes": "Same cast game seconds later after a camera swap put the lime team's plate on the right: white '29' on the bright lime fill, leading '2' blur-eroded under the main digit floor (was truncated to 9). Gray-band noise on this frame matches digit templates at up to 0.66 — the anchor-digit rule must keep rejecting it."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -0,0 +1,13 @@
{
"event": "Objective",
"data": {
"mode": "SZ",
"time": 243,
"score": [49, 98],
"penalty": [39, null],
"control": [false, true]
},
"options": {
"notes": "Casted stream (SWS26) overhead camera of the same game as the specced-purple-left fixture: default arrangement puts green left (black plate, green digits, +39 penalty pill) and purple right (in control, team-color fill)."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@@ -0,0 +1,13 @@
{
"event": "Objective",
"data": {
"mode": "SZ",
"time": 248,
"score": [100, 49],
"penalty": [null, null],
"control": [false, false]
},
"options": {
"notes": "Casted stream (SWS26) from a purple-team player's POV: the specced team's plate sits left, so purple (magenta digits) is side 0 and green side 1 — opposite arrangement to the overhead frame from the same game. Zone neutral: both plates near-black."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@@ -0,0 +1,18 @@
{
"event": "PlayerStatus",
"data": {
"layout": "cast",
"time": 258,
"special": [
[false, false, false, false],
[false, false, false, false]
],
"dead": [
[false, false, false, false],
[false, false, false, false]
]
},
"options": {
"notes": "JP broadcast (AREA CUP) spectator HUD with the camera-button badges hidden — the strip keeps the cast icon geometry, so the layout must resolve to cast via the geometry score, not the badge probe (reading it as POV drifts the outer-slot boxes off their icons and flickers phantom deaths). All eight players alive."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -0,0 +1,18 @@
{
"event": "PlayerStatus",
"data": {
"layout": "cast",
"time": 255,
"special": [
[true, false, false, false],
[false, false, false, false]
],
"dead": [
[false, false, false, false],
[true, false, true, false]
]
},
"options": {
"notes": "AREA CUP FINAL (grey-win VoD, Flounder Heights) spectator HUD at 4:15. Right team's outermost slot is a plain alive purple body tilted mid-bounce — the live scan read it as splatted (✕·✕✕ @ 0.749), manufacturing a third death for the .52 Gal whose real second death only starts three seconds later; one second after this frame the same body washes into special ready. Right slots 1 and 3 are genuinely splatted. Left team's outermost slot carries the bright special-ready wash."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@@ -0,0 +1,18 @@
{
"event": "PlayerStatus",
"data": {
"layout": "cast",
"time": 250,
"special": [
[false, false, false, false],
[false, false, false, false]
],
"dead": [
[false, false, false, false],
[true, false, false, true]
]
},
"options": {
"notes": "Same footage one second on (4:10). Right slot 3 has respawned (gauge back at 15) while the outermost slot's X is still up over the dark hull; the live scan read only slot 1 dead (✕··· @ 0.814), missing the outer X for a second consecutive read."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -0,0 +1,18 @@
{
"event": "PlayerStatus",
"data": {
"layout": "cast",
"time": 251,
"special": [
[false, false, false, false],
[false, false, false, false]
],
"dead": [
[false, false, false, false],
[true, false, true, true]
]
},
"options": {
"notes": "Same footage at 4:11, gauge-percentage spectator HUD. Right team slots 1, 3 and 4 are all splatted; the outermost X sits over a dark navy sky/structure backdrop and carries no gauge number, and the live scan missed it (✕·✕· @ 0.924), splitting the .52 Gal's single 7-second death into two runs. Left team all alive (window glare streaks the backdrop between the first icons)."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,18 @@
{
"event": "PlayerStatus",
"data": {
"layout": "cast",
"time": 253,
"special": [
[false, false, false, false],
[false, false, false, true]
],
"dead": [
[false, false, false, false],
[true, false, true, false]
]
},
"options": {
"notes": "Same footage at 4:13, spectator HUD with special-gauge percentages above the icons and camera-button/D-pad badges below (85/72/49 left, 11/17/26 right; a splatted icon keeps its number). Right team's outermost slot is fully washed with special ready and shows no gauge number — the live scan never captured this ★ (the .52 Gal's brief special between its two deaths, user-confirmed) and neighboring reads called the slot dead instead. Left outer slot's wash is gone by this frame (special spent), despite its missing gauge number."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@@ -0,0 +1,18 @@
{
"event": "PlayerStatus",
"data": {
"layout": "cast-mirror",
"time": 124,
"special": [
[false, false, false, true],
[false, false, false, false]
],
"dead": [
[false, false, false, false],
[false, false, true, false]
]
},
"options": {
"notes": "Same footage one second on (2:06:32, clock 2:04), player-cam view with gauge digits and camera badges — but in the MIRRORED cast arrangement (left '+' pads at [625,701,777,852], right A/B/Y/X at [1110,1207,1303,1401]; the standard badge probes miss them). The live scan read this frame as pov and emitted ·★·★: the ★ on left slot 1 is FALSE — the .52 Gal just respawned and shows gauge 29 — which is the user-reported dead/ready flicker. Left slot 3's wash IS real (pale cream body, no gauge digit; spent by the next second). Right slot 2 splatted (27 kept over the X). Labels authored by Claude from pixel evidence; the user-confirmed part is that left slot 1 held no special here."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Some files were not shown because too many files have changed in this diff Show More