Scanner improvements (#3331)
3
.gitignore
vendored
@@ -37,3 +37,6 @@ notepad.txt
|
||||
|
||||
# proprietary game fonts for the scanner glyph-atlas builders (scripts/scanner)
|
||||
/assets/fonts/
|
||||
|
||||
*.mp4
|
||||
*.mkv
|
||||
120
app/components/GameTimeline.module.css
Normal 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;
|
||||
}
|
||||
377
app/components/GameTimeline.tsx
Normal 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")],
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
94
app/components/PlayerStatusTimeline.module.css
Normal 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;
|
||||
}
|
||||
182
app/components/PlayerStatusTimeline.tsx
Normal 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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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)[] {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]!)) },
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]],
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }]);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
13
app/features/scanner/components/AbilityGrid.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
40
app/features/scanner/components/DeathCard.module.css
Normal 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);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
142
app/features/scanner/components/EventCard.module.css
Normal 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);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
37
app/features/scanner/components/EventsSummary.module.css
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
39
app/features/scanner/components/FrameThumb.module.css
Normal 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);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
34
app/features/scanner/components/LivePage.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
391
app/features/scanner/components/MatchCard.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.matchList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
27
app/features/scanner/components/MetaChips.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
96
app/features/scanner/components/MinimapCard.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
52
app/features/scanner/components/PlayerStatusCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
app/features/scanner/components/ScannerApp.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
233
app/features/scanner/components/ScreenshotPage.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
55
app/features/scanner/components/StripWeaponsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
app/features/scanner/components/VodPage.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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("")];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
19
app/features/scanner/components/player-status-view.ts
Normal 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];
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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] : []),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
354
app/features/scanner/core/detectors/objective/player-status.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
178
app/features/scanner/core/detectors/objective/strip-weapons.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
85
app/features/scanner/core/ink-color.ts
Normal 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;
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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]
|
||||
|
||||
130
app/features/scanner/core/slot-row-assignment.ts
Normal 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;
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@ describe("scannerSearchParams", () => {
|
||||
assertRoundTrips(scannerSearchParams, {
|
||||
tab: ["live", "screenshot", "vod"],
|
||||
inspect: ["1723456789012-abc123", null],
|
||||
telemetry: [true, false],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
216
app/features/scanner/scanner.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
118
app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/expected.json
vendored
Normal 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)."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/frame.png
vendored
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-left/frame.png
vendored
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
@@ -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."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-right/frame.png
vendored
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
@@ -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)."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/objective/splat-zones-cast-overhead-purple-right/frame.png
vendored
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
13
app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/expected.json
vendored
Normal 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."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/frame.png
vendored
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
18
app/features/scanner/tests/fixtures/player-status/cast-areacup-badgeless-all-alive/expected.json
vendored
Normal 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."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/player-status/cast-areacup-badgeless-all-alive/frame.png
vendored
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
@@ -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."
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.2 MiB |
@@ -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."
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
@@ -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)."
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.6 MiB |
@@ -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."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-ready-wash/frame.png
vendored
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
@@ -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."
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.0 MiB |