mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-28 06:10:01 -05:00
Add kills/splatted by to track
This commit is contained in:
@@ -107,6 +107,10 @@
|
||||
&.statusLabelSpecial {
|
||||
color: var(--color-chart-special);
|
||||
}
|
||||
|
||||
&.statusLabelKill {
|
||||
color: var(--color-chart-kill);
|
||||
}
|
||||
}
|
||||
|
||||
.readoutWeapons {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* A game's two scanned-timeline charts (player status bands above the objective-counter chart)
|
||||
* on one shared time axis. Hovering scrubs both: a cursor line spans the charts and a readout
|
||||
* shows the moment's state, replacing the chart's own tooltip.
|
||||
* shows the moment's state and any kills under the cursor, replacing the chart's own tooltip.
|
||||
*/
|
||||
import clsx from "clsx";
|
||||
import { memo, useRef, useState } from "react";
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
import {
|
||||
PLAYER_STATUS_TAIL_SECONDS,
|
||||
PlayerStatusTimeline,
|
||||
type PlayerStatusTimelineKill,
|
||||
type PlayerStatusTimelinePov,
|
||||
type PlayerStatusTimelineSample,
|
||||
type PlayerStatusTimelineTeam,
|
||||
statusSpans,
|
||||
@@ -31,11 +33,14 @@ import {
|
||||
/** 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;
|
||||
/** Kill ticks this close to the cursor show up in the readout. */
|
||||
const KILL_READOUT_RADIUS_PX = 4;
|
||||
|
||||
interface GameTimelineProps {
|
||||
objectiveEvents?: readonly ObjectiveTimelineEvent[];
|
||||
playerStatusSamples?: readonly PlayerStatusTimelineSample[];
|
||||
teams: readonly [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam];
|
||||
pov?: PlayerStatusTimelinePov;
|
||||
}
|
||||
|
||||
interface ScrubPosition {
|
||||
@@ -52,6 +57,7 @@ export function GameTimeline({
|
||||
objectiveEvents,
|
||||
playerStatusSamples,
|
||||
teams,
|
||||
pov,
|
||||
}: GameTimelineProps) {
|
||||
const [scrub, setScrub] = useState<ScrubPosition | null>(null);
|
||||
const plotRef = useRef<HTMLDivElement>(null);
|
||||
@@ -91,6 +97,7 @@ export function GameTimeline({
|
||||
objectiveEvents={objectiveEvents}
|
||||
playerStatusSamples={playerStatusSamples}
|
||||
teams={teams}
|
||||
pov={pov}
|
||||
/>
|
||||
<div className={styles.plotOverlay} ref={plotRef}>
|
||||
{scrub ? (
|
||||
@@ -100,6 +107,7 @@ export function GameTimeline({
|
||||
objective={objective}
|
||||
samples={samples}
|
||||
teams={teams}
|
||||
pov={samples.length > 0 ? pov : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -114,6 +122,7 @@ const TimelineCharts = memo(function TimelineCharts({
|
||||
objectiveEvents,
|
||||
playerStatusSamples,
|
||||
teams,
|
||||
pov,
|
||||
}: GameTimelineProps) {
|
||||
const objective = (objectiveEvents ?? []).toSorted((a, b) => a.t - b.t);
|
||||
const samples = (playerStatusSamples ?? []).toSorted((a, b) => a.t - b.t);
|
||||
@@ -123,7 +132,12 @@ const TimelineCharts = memo(function TimelineCharts({
|
||||
return (
|
||||
<>
|
||||
{samples.length > 0 ? (
|
||||
<PlayerStatusTimeline samples={samples} teams={teams} domain={domain} />
|
||||
<PlayerStatusTimeline
|
||||
samples={samples}
|
||||
teams={teams}
|
||||
domain={domain}
|
||||
pov={pov}
|
||||
/>
|
||||
) : null}
|
||||
{objective.length > 0 ? (
|
||||
<ObjectiveTimeline
|
||||
@@ -143,18 +157,24 @@ function ScrubReadout({
|
||||
objective,
|
||||
samples,
|
||||
teams,
|
||||
pov,
|
||||
}: {
|
||||
scrub: ScrubPosition;
|
||||
domain: [number, number];
|
||||
objective: readonly ObjectiveTimelineEvent[];
|
||||
samples: readonly PlayerStatusTimelineSample[];
|
||||
teams: GameTimelineProps["teams"];
|
||||
pov?: PlayerStatusTimelinePov;
|
||||
}) {
|
||||
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 killRadius = (KILL_READOUT_RADIUS_PX / scrub.width) * (max - min);
|
||||
const killsNow = (pov?.kills ?? []).filter(
|
||||
(kill) => Math.abs(kill.t - time) <= killRadius,
|
||||
);
|
||||
const flipped = scrub.x > scrub.width * READOUT_FLIP_RATIO;
|
||||
|
||||
return (
|
||||
@@ -230,6 +250,7 @@ function ScrubReadout({
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{pov?.side === side ? <KillsRow kills={killsNow} /> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -284,6 +305,20 @@ function StatusWeaponsRow({
|
||||
);
|
||||
}
|
||||
|
||||
function KillsRow({ kills }: { kills: PlayerStatusTimelineKill[] }) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
if (kills.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.readoutStatusRow}>
|
||||
<span className={clsx(styles.readoutStatusLabel, styles.statusLabelKill)}>
|
||||
{t("common:playerStatusTimeline.kill")}
|
||||
</span>
|
||||
<span>{kills.map((kill) => kill.name ?? "?").join(" · ")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function timelineDomain(
|
||||
objective: readonly ObjectiveTimelineEvent[],
|
||||
samples: readonly PlayerStatusTimelineSample[],
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
}
|
||||
|
||||
.legendSwatchDead,
|
||||
.legendSwatchSpecial {
|
||||
.legendSwatchSpecial,
|
||||
.legendSwatchKill,
|
||||
.legendSwatchSplattedByPov {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
@@ -36,6 +38,15 @@
|
||||
background-color: var(--color-chart-special);
|
||||
}
|
||||
|
||||
.legendSwatchKill {
|
||||
width: 3px;
|
||||
background-color: var(--color-chart-kill);
|
||||
}
|
||||
|
||||
.legendSwatchSplattedByPov {
|
||||
background-color: var(--color-chart-kill);
|
||||
}
|
||||
|
||||
.team {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -61,13 +72,25 @@
|
||||
padding-right: var(--s-2);
|
||||
}
|
||||
|
||||
.track {
|
||||
.trackArea {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-selector);
|
||||
background-color: var(--color-bg-higher);
|
||||
|
||||
&.trackPov {
|
||||
background-color: color-mix(
|
||||
in oklch,
|
||||
var(--color-bg-higher) 75%,
|
||||
var(--color-accent) 25%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.spanDead,
|
||||
@@ -81,12 +104,27 @@
|
||||
|
||||
.spanDead {
|
||||
background-color: var(--color-chart-splatted);
|
||||
|
||||
&.spanSplattedByPov {
|
||||
background-color: var(--color-chart-kill);
|
||||
}
|
||||
}
|
||||
|
||||
.spanSpecial {
|
||||
background-color: var(--color-chart-special);
|
||||
}
|
||||
|
||||
.killTick {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
width: 3px;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 2px;
|
||||
background-color: var(--color-chart-kill);
|
||||
box-shadow: 0 0 0 1px var(--color-bg-high);
|
||||
}
|
||||
|
||||
.unknownWeapon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
* Per-player splatted / special-held bands over a game's scanned icon-strip reads, rendered above
|
||||
* the ObjectiveTimeline on the same `t` axis (pass `domain` to share the range). Reads re-confirm
|
||||
* an unchanged state every few seconds; a longer gap means the HUD was not observed, so bands never
|
||||
* bridge across one.
|
||||
* bridge across one. A known POV player gets a highlighted row with their kills as ticks, and the
|
||||
* splatted bands those kills opened stand out on the victims' rows.
|
||||
*/
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { abilityImageUrl } from "~/utils/urls";
|
||||
@@ -20,6 +22,16 @@ const MAX_BRIDGE_SECONDS = 15;
|
||||
/** Trailing open band drawn this long past its last confirming read. */
|
||||
export const PLAYER_STATUS_TAIL_SECONDS = 1;
|
||||
|
||||
/**
|
||||
* The kill-feed row and the victim's crossed-out icon show together, but either read can land a
|
||||
* beat late (an ink burst hiding the feed, the strip's ~1s cadence): the victim's splatted band
|
||||
* opening within this much of the kill is that kill's.
|
||||
*/
|
||||
const KILL_SPLAT_MAX_LEAD_SECONDS = 4;
|
||||
const KILL_SPLAT_MAX_LAG_SECONDS = 3;
|
||||
|
||||
const PLAYER_SLOTS = [0, 1, 2, 3] as const;
|
||||
|
||||
type PlayerFlags = readonly [boolean, boolean, boolean, boolean];
|
||||
|
||||
/** One icon-strip read, sides in `[alpha, bravo]` order. */
|
||||
@@ -36,15 +48,33 @@ export interface PlayerStatusTimelineTeam {
|
||||
weapons: (MainWeaponId | null)[];
|
||||
}
|
||||
|
||||
/** The player whose point of view the footage is, with their kills. */
|
||||
export interface PlayerStatusTimelinePov {
|
||||
side: 0 | 1;
|
||||
slot: number;
|
||||
kills: readonly PlayerStatusTimelineKill[];
|
||||
}
|
||||
|
||||
export interface PlayerStatusTimelineKill {
|
||||
/** seconds on the same axis as the samples */
|
||||
t: number;
|
||||
/** the splatted player's name; null when unknown */
|
||||
name: string | null;
|
||||
/** the splatted player's row on the other side; null when unknown */
|
||||
victimSlot: number | null;
|
||||
}
|
||||
|
||||
export function PlayerStatusTimeline({
|
||||
samples,
|
||||
teams,
|
||||
domain,
|
||||
pov,
|
||||
}: {
|
||||
samples: readonly PlayerStatusTimelineSample[];
|
||||
teams: readonly [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam];
|
||||
/** x-axis range override, to share the objective chart's axis */
|
||||
domain?: [number, number];
|
||||
pov?: PlayerStatusTimelinePov;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const sorted = samples.toSorted((a, b) => a.t - b.t);
|
||||
@@ -56,11 +86,17 @@ export function PlayerStatusTimeline({
|
||||
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 leftOf = (at: number) => `${((at - 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)}`;
|
||||
const isPovRow = (side: 0 | 1, slot: number) =>
|
||||
pov?.side === side && pov.slot === slot;
|
||||
const killsOfVictim = (side: 0 | 1, slot: number) =>
|
||||
pov && pov.side !== side
|
||||
? pov.kills.filter((kill) => kill.victimSlot === slot)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -80,46 +116,92 @@ export function PlayerStatusTimeline({
|
||||
<span className={styles.legendSwatchSpecial} />
|
||||
{t("common:playerStatusTimeline.specialReady")}
|
||||
</span>
|
||||
{pov && pov.kills.length > 0 ? (
|
||||
<span className={styles.legendItem}>
|
||||
<span className={styles.legendSwatchKill} />
|
||||
{t("common:playerStatusTimeline.kill")}
|
||||
</span>
|
||||
) : null}
|
||||
{pov?.kills.some((kill) => kill.victimSlot !== null) ? (
|
||||
<span className={styles.legendItem}>
|
||||
<span className={styles.legendSwatchSplattedByPov} />
|
||||
{t("common:playerStatusTimeline.splattedByPov")}
|
||||
</span>
|
||||
) : null}
|
||||
</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) => (
|
||||
{PLAYER_SLOTS.map((slot) => {
|
||||
const deadSpans = statusSpans(
|
||||
sorted,
|
||||
(sample) => sample.dead[side][slot]!,
|
||||
);
|
||||
const splattedByPov = spansOpenedByKills(
|
||||
deadSpans,
|
||||
killsOfVictim(side, slot),
|
||||
);
|
||||
return (
|
||||
<div key={slot} className={styles.row}>
|
||||
<div className={styles.slotLabel}>
|
||||
<SlotWeapon weaponSplId={teams[side].weapons[slot] ?? null} />
|
||||
</div>
|
||||
<div className={styles.trackArea}>
|
||||
<div
|
||||
key={`s${i}`}
|
||||
className={styles.spanSpecial}
|
||||
style={{ left: leftOf(span), width: widthOf(span) }}
|
||||
title={titleOf(
|
||||
t("common:playerStatusTimeline.specialReady"),
|
||||
span,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
className={clsx(styles.track, {
|
||||
[styles.trackPov]: isPovRow(side, slot),
|
||||
})}
|
||||
>
|
||||
{deadSpans.map((span, i) => (
|
||||
<div
|
||||
key={`d${i}`}
|
||||
className={clsx(styles.spanDead, {
|
||||
[styles.spanSplattedByPov]: splattedByPov.has(i),
|
||||
})}
|
||||
style={{
|
||||
left: leftOf(span.start),
|
||||
width: widthOf(span),
|
||||
}}
|
||||
title={titleOf(
|
||||
splattedByPov.has(i)
|
||||
? t("common:playerStatusTimeline.splattedByPov")
|
||||
: 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.start),
|
||||
width: widthOf(span),
|
||||
}}
|
||||
title={titleOf(
|
||||
t("common:playerStatusTimeline.specialReady"),
|
||||
span,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{isPovRow(side, slot)
|
||||
? pov!.kills.map((kill, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={styles.killTick}
|
||||
style={{ left: leftOf(kill.t) }}
|
||||
title={`${t("common:playerStatusTimeline.kill")} · ${kill.name ?? "?"} · ${formatElapsed(kill.t)}`}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -145,6 +227,23 @@ interface StatusSpan {
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Indexes of the spans each kill opened: the latest one opening inside the kill's window. */
|
||||
function spansOpenedByKills(
|
||||
spans: readonly StatusSpan[],
|
||||
kills: readonly PlayerStatusTimelineKill[],
|
||||
): Set<number> {
|
||||
const opened = new Set<number>();
|
||||
for (const kill of kills) {
|
||||
const index = spans.findLastIndex(
|
||||
(span) =>
|
||||
span.start >= kill.t - KILL_SPLAT_MAX_LEAD_SECONDS &&
|
||||
span.start <= kill.t + KILL_SPLAT_MAX_LAG_SECONDS,
|
||||
);
|
||||
if (index !== -1) opened.add(index);
|
||||
}
|
||||
return opened;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contiguous stretches where the flag held true: opens at the first true read, closes at the read
|
||||
* showing false, or one second past the last confirmation when the next read is too far away.
|
||||
|
||||
@@ -39,8 +39,8 @@ import type {
|
||||
import { matchResult } from "../core/sessions";
|
||||
import type { ScannerClip } from "../store/clips";
|
||||
import type { GetFrame } from "./EventCard";
|
||||
import { displayOrder, gameTimelineProps } from "./game-timeline-view";
|
||||
import styles from "./MatchCard.module.css";
|
||||
import { playerStatusTeams } from "./player-status-view";
|
||||
import { RawDetections } from "./RawDetections";
|
||||
import type { ScanEvent, SessionKind } from "./session-data";
|
||||
import { type UploadState, UploadStatusButton } from "./UploadStatus";
|
||||
@@ -243,13 +243,7 @@ export function MatchCard({
|
||||
<Scoreboard match={match} result={result} />
|
||||
{match.objective || match.playerStatus ? (
|
||||
<GameTimeline
|
||||
objectiveEvents={(match.objective?.samples ?? []).map(
|
||||
(sample) => ({ t: sample.t - matchOrigin, data: sample }),
|
||||
)}
|
||||
playerStatusSamples={(match.playerStatus?.samples ?? []).map(
|
||||
(sample) => ({ ...sample, t: sample.t - matchOrigin }),
|
||||
)}
|
||||
teams={playerStatusTeams(match, teamLabels(match))}
|
||||
{...gameTimelineProps(match, matchOrigin, TEAM_LABELS)}
|
||||
/>
|
||||
) : null}
|
||||
<DeathsAndKills built={built} clips={clips} onPlayClip={onPlayClip} />
|
||||
@@ -289,23 +283,6 @@ function elapsed(mode: ModeShort | null, timeLeft: number): number {
|
||||
return Math.max(0, clockStart - timeLeft);
|
||||
}
|
||||
|
||||
/**
|
||||
* `teams` order is winner-first on a scoreboard-closed match, so it flips
|
||||
* between games. The card keeps the scan's own side left and the enemy right
|
||||
* for every match so consecutive games line up; footage with no POV seat
|
||||
* read (casts) keeps `teams` order.
|
||||
*/
|
||||
function displayOrder(match: ScannerMatch): [0 | 1, 0 | 1] {
|
||||
return match.pov?.team === 1 ? [1, 0] : [0, 1];
|
||||
}
|
||||
|
||||
/** labels by team index: Alpha is whichever team the card shows on the left */
|
||||
function teamLabels(match: ScannerMatch): readonly [string, string] {
|
||||
return displayOrder(match)[0] === 0
|
||||
? TEAM_LABELS
|
||||
: [TEAM_LABELS[1], TEAM_LABELS[0]];
|
||||
}
|
||||
|
||||
function Score({
|
||||
match,
|
||||
result,
|
||||
|
||||
99
app/features/scanner/components/game-timeline-view.ts
Normal file
99
app/features/scanner/components/game-timeline-view.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/** Prop derivation for rendering a ScannerMatch with the shared <GameTimeline />. */
|
||||
import type { ComponentProps } from "react";
|
||||
import type { GameTimeline } from "~/components/GameTimeline";
|
||||
import type { PlayerStatusTimelineTeam } from "~/components/PlayerStatusTimeline";
|
||||
import type { ScannerMatch, ScannerMatchPlayer } from "../core/scanner-match";
|
||||
import { rankBy } from "../core/text";
|
||||
|
||||
/**
|
||||
* A kill-feed name pins its kill on the enemy row it reads exactly (after
|
||||
* matchKey), else on the row it resembles clearly more than any other: OCR of
|
||||
* the feed's font garbles symbols the scoreboard reads fine, so a loose best
|
||||
* match stands when the other names are far off.
|
||||
*/
|
||||
const VICTIM_NAME_MIN_SIMILARITY = 0.4;
|
||||
const VICTIM_NAME_MIN_MARGIN = 0.25;
|
||||
|
||||
const PLAYER_SLOTS = [0, 1, 2, 3] as const;
|
||||
|
||||
/**
|
||||
* `teams` order is winner-first on a scoreboard-closed match, so it flips
|
||||
* between games. The card keeps the scan's own side left and the enemy right
|
||||
* for every match so consecutive games line up; footage with no POV seat
|
||||
* read (casts) keeps `teams` order.
|
||||
*/
|
||||
export function displayOrder(match: ScannerMatch): [0 | 1, 0 | 1] {
|
||||
return match.pov?.team === 1 ? [1, 0] : [0, 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* The match's counter and icon-strip reads as <GameTimeline /> props: sides
|
||||
* in display order (the POV's team first), `t` shifted to `origin`. With the
|
||||
* POV seat known, its row carries the kill feed's kills, each pinned to its
|
||||
* victim's row by name. A cast has no seat: its feed follows the camera, so
|
||||
* the kills belong to no single row.
|
||||
*/
|
||||
export function gameTimelineProps(
|
||||
match: ScannerMatch,
|
||||
origin: number,
|
||||
labels: readonly [string, string],
|
||||
): ComponentProps<typeof GameTimeline> {
|
||||
const [first, second] = displayOrder(match);
|
||||
const ordered = <T>(pair: readonly [T, T]): [T, T] => [
|
||||
pair[first],
|
||||
pair[second],
|
||||
];
|
||||
|
||||
return {
|
||||
objectiveEvents: (match.objective?.samples ?? []).map((sample) => ({
|
||||
t: sample.t - origin,
|
||||
data: {
|
||||
time: sample.time,
|
||||
score: ordered(sample.score),
|
||||
penalty: ordered(sample.penalty),
|
||||
control: ordered(sample.control),
|
||||
},
|
||||
})),
|
||||
playerStatusSamples: (match.playerStatus?.samples ?? []).map((sample) => ({
|
||||
t: sample.t - origin,
|
||||
special: ordered(sample.special),
|
||||
dead: ordered(sample.dead),
|
||||
})),
|
||||
teams: ordered(match.teams).map(
|
||||
(team, side): PlayerStatusTimelineTeam => ({
|
||||
label: labels[side]!,
|
||||
weapons: PLAYER_SLOTS.map(
|
||||
(slot) => team.players[slot]?.weaponId ?? null,
|
||||
),
|
||||
}),
|
||||
) as [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam],
|
||||
pov: match.pov
|
||||
? {
|
||||
side: 0,
|
||||
slot: match.pov.index,
|
||||
kills: (match.kills ?? []).map((kill) => ({
|
||||
t: kill.t - origin,
|
||||
name: kill.name,
|
||||
victimSlot: victimSlot(kill.name, match.teams[second].players),
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function victimSlot(
|
||||
name: string | null,
|
||||
enemies: readonly ScannerMatchPlayer[],
|
||||
): number | null {
|
||||
if (name === null) return null;
|
||||
const named = enemies.flatMap((player, slot) =>
|
||||
player.name !== null ? [{ slot, name: player.name }] : [],
|
||||
);
|
||||
const [best, runnerUp] = rankBy(name, named, (enemy) => enemy.name);
|
||||
if (!best || best.score < VICTIM_NAME_MIN_SIMILARITY) return null;
|
||||
if (best.score === 1 && runnerUp?.score !== 1) return best.entry.slot;
|
||||
if (runnerUp && best.score - runnerUp.score < VICTIM_NAME_MIN_MARGIN) {
|
||||
return null;
|
||||
}
|
||||
return best.entry.slot;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/** Prop derivation for rendering a ScannerMatch's status samples with the shared <PlayerStatusTimeline />. */
|
||||
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];
|
||||
}
|
||||
@@ -51,6 +51,13 @@ export interface TimelineOptions {
|
||||
* it for the match builder's smoothing. Confidence never moves a sample.
|
||||
*/
|
||||
sampledTypes: readonly string[];
|
||||
/**
|
||||
* types whose first read is the event: their content guard only merges
|
||||
* identical repeats, so a more confident one carries nothing new except a
|
||||
* later `t` — and a kill-feed row is re-read over its whole ~5s+ life, so
|
||||
* taking it over would move the kill to when the row was about to leave.
|
||||
*/
|
||||
firstReadTypes: readonly string[];
|
||||
}
|
||||
|
||||
const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
|
||||
@@ -90,6 +97,7 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
|
||||
[STRIP_WEAPONS_EVENT_TYPE]: 0,
|
||||
},
|
||||
sampledTypes: [PLAYER_STATUS_EVENT_TYPE],
|
||||
firstReadTypes: [KILL_EVENT_TYPE],
|
||||
};
|
||||
|
||||
export type TimelineAction =
|
||||
@@ -136,7 +144,10 @@ export class TimelineBuilder {
|
||||
this.#events.sort((a, b) => a.t - b.t);
|
||||
return { action: "added", event };
|
||||
}
|
||||
if (event.confidence > near.confidence) {
|
||||
if (
|
||||
event.confidence > near.confidence &&
|
||||
!this.#options.firstReadTypes.includes(event.type)
|
||||
) {
|
||||
this.#events[this.#events.indexOf(near)] = event;
|
||||
this.#events.sort((a, b) => a.t - b.t);
|
||||
return { action: "replaced", event, replaced: near };
|
||||
|
||||
133
app/features/scanner/tests/logic/game-timeline-view.test.ts
Normal file
133
app/features/scanner/tests/logic/game-timeline-view.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { gameTimelineProps } from "../../components/game-timeline-view";
|
||||
import type { ScannerMatch, ScannerMatchKill } from "../../core/scanner-match";
|
||||
|
||||
const LABELS = ["Alpha", "Bravo"] as const;
|
||||
|
||||
const player = (name: string | null) => ({
|
||||
name,
|
||||
weaponId: null,
|
||||
paint: null,
|
||||
ka: null,
|
||||
d: null,
|
||||
s: null,
|
||||
});
|
||||
|
||||
const MATCH: ScannerMatch = {
|
||||
startsAt: 100,
|
||||
endsAt: 400,
|
||||
playedAt: null,
|
||||
lobby: "PRIVATE",
|
||||
mode: "TC",
|
||||
stage: 3,
|
||||
matchScores: [100, 0],
|
||||
replayCode: null,
|
||||
cast: false,
|
||||
objective: {
|
||||
mode: "SZ",
|
||||
samples: [
|
||||
{
|
||||
t: 110,
|
||||
time: 290,
|
||||
score: [90, 100],
|
||||
penalty: [null, 5],
|
||||
control: [true, false],
|
||||
},
|
||||
],
|
||||
},
|
||||
playerStatus: null,
|
||||
kills: null,
|
||||
teams: [
|
||||
{
|
||||
players: [
|
||||
player("Jrod_14"),
|
||||
player("Elis"),
|
||||
player("GOLD SHIP"),
|
||||
player("merocï¬・~"),
|
||||
],
|
||||
},
|
||||
{
|
||||
players: [
|
||||
player("Florescent"),
|
||||
player("くらうに ★~"),
|
||||
player("Bocchi"),
|
||||
player("have faith"),
|
||||
],
|
||||
},
|
||||
],
|
||||
winner: 0,
|
||||
pov: { team: 1, index: 3 },
|
||||
};
|
||||
|
||||
const withKills = (
|
||||
kills: ScannerMatchKill[],
|
||||
teams: ScannerMatch["teams"] = MATCH.teams,
|
||||
): ScannerMatch => ({ ...MATCH, kills, teams });
|
||||
|
||||
describe("gameTimelineProps", () => {
|
||||
test("puts the POV's team first", () => {
|
||||
const props = gameTimelineProps(MATCH, 100, LABELS);
|
||||
|
||||
expect(props.objectiveEvents![0]).toEqual({
|
||||
t: 10,
|
||||
data: {
|
||||
time: 290,
|
||||
score: [100, 90],
|
||||
penalty: [5, null],
|
||||
control: [false, true],
|
||||
},
|
||||
});
|
||||
expect(props.pov?.side).toBe(0);
|
||||
expect(props.pov?.slot).toBe(3);
|
||||
});
|
||||
|
||||
test("has no POV row on footage without a seat", () => {
|
||||
const props = gameTimelineProps(
|
||||
{ ...withKills([{ t: 150, time: 250, name: "Elis" }]), pov: null },
|
||||
100,
|
||||
LABELS,
|
||||
);
|
||||
|
||||
expect(props.pov).toBeUndefined();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ why: "exact name", name: "Jrod_14", victimSlot: 0 },
|
||||
{
|
||||
why: "garbled symbols, others far off",
|
||||
name: "merocïつ·˜",
|
||||
victimSlot: 3,
|
||||
},
|
||||
{ why: "unrelated name", name: "Squidward", victimSlot: null },
|
||||
{ why: "unread name", name: null, victimSlot: null },
|
||||
])("pins a kill on its victim's row: $why", ({ name, victimSlot }) => {
|
||||
const props = gameTimelineProps(
|
||||
withKills([{ t: 150, time: 250, name }]),
|
||||
100,
|
||||
LABELS,
|
||||
);
|
||||
|
||||
expect(props.pov?.kills[0]?.victimSlot).toBe(victimSlot);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ why: "exact read beats a lookalike", name: "Sendou1", victimSlot: 0 },
|
||||
{
|
||||
why: "two lookalikes fit about as well",
|
||||
name: "Sendou",
|
||||
victimSlot: null,
|
||||
},
|
||||
])("near-twin enemy names: $why", ({ name, victimSlot }) => {
|
||||
const teams: ScannerMatch["teams"] = [
|
||||
{ players: [player("Sendou1"), player("Sendou2")] },
|
||||
MATCH.teams[1],
|
||||
];
|
||||
const props = gameTimelineProps(
|
||||
withKills([{ t: 150, time: 250, name }], teams),
|
||||
100,
|
||||
LABELS,
|
||||
);
|
||||
|
||||
expect(props.pov?.kills[0]?.victimSlot).toBe(victimSlot);
|
||||
});
|
||||
});
|
||||
@@ -188,6 +188,26 @@ test("kill stacks merge while unchanged and split when a row enters", () => {
|
||||
assert.equal(tl.events.length, 2);
|
||||
});
|
||||
|
||||
// Triton cup VoD: "Splatted Jrod_14!" shows from 4:21 but a sharper read at 4:16 took over
|
||||
test("a kill stack stays at its first read when a repeat reads more confidently", () => {
|
||||
const tl = new TimelineBuilder();
|
||||
tl.push({
|
||||
type: "Kill",
|
||||
t: 11077.3,
|
||||
confidence: 0.92,
|
||||
data: { time: 261, names: ["Jrod_14"] },
|
||||
});
|
||||
tl.push({
|
||||
type: "Kill",
|
||||
t: 11082.3,
|
||||
confidence: 0.937,
|
||||
data: { time: 256, names: ["Jrod_14"] },
|
||||
});
|
||||
assert.equal(tl.events.length, 1);
|
||||
assert.equal(tl.events[0]!.t, 11077.3);
|
||||
assert.deepEqual(tl.events[0]!.data, { time: 261, names: ["Jrod_14"] });
|
||||
});
|
||||
|
||||
const NO_FLAGS: PlayerStatusData["dead"] = [
|
||||
[false, false, false, false],
|
||||
[false, false, false, false],
|
||||
|
||||
@@ -164,6 +164,7 @@ html.dark [data-custom-theme],
|
||||
--color-chart-bravo: oklch(60% 0.17 275);
|
||||
--color-chart-splatted: oklch(66% 0.03 var(--_base-h));
|
||||
--color-chart-special: oklch(80% 0.14 84);
|
||||
--color-chart-kill: oklch(78% 0.19 150);
|
||||
|
||||
/* inlined instead of var() because data URIs can't read custom properties: keep in sync with --color-text-high */
|
||||
--field-icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(122, 139, 183)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
@@ -232,6 +233,7 @@ html.light [data-custom-theme],
|
||||
--color-chart-bravo: oklch(47% 0.19 275);
|
||||
--color-chart-splatted: oklch(50% 0.03 var(--_base-h));
|
||||
--color-chart-special: oklch(67% 0.14 72);
|
||||
--color-chart-kill: oklch(55% 0.17 150);
|
||||
|
||||
/* inlined instead of var() because data URIs can't read custom properties: keep in sync with --color-text-high */
|
||||
--field-icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(72, 87, 124)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"playerStatusTimeline.kill": "",
|
||||
"playerStatusTimeline.splattedByPov": "",
|
||||
"maps.createMapList": "Lav bane-liste",
|
||||
"maps.halfSz": "50% DD",
|
||||
"maps.mapPool": "Banepulje",
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"playerStatusTimeline.kill": "",
|
||||
"playerStatusTimeline.splattedByPov": "",
|
||||
"maps.createMapList": "Arenen-Liste erstellen",
|
||||
"maps.halfSz": "50% Herrschaft",
|
||||
"maps.mapPool": "Arenen-Pool",
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"objectiveTimeline.inControl": "In control",
|
||||
"playerStatusTimeline.splatted": "Splatted",
|
||||
"playerStatusTimeline.specialReady": "Special ready",
|
||||
"playerStatusTimeline.kill": "Kill",
|
||||
"playerStatusTimeline.splattedByPov": "Splatted by POV",
|
||||
"maps.createMapList": "Create map list",
|
||||
"maps.halfSz": "50% SZ",
|
||||
"maps.mapPool": "Map pool",
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"playerStatusTimeline.kill": "",
|
||||
"playerStatusTimeline.splattedByPov": "",
|
||||
"maps.createMapList": "Crear lista de mapas",
|
||||
"maps.halfSz": "50% Pintazonas",
|
||||
"maps.mapPool": "Rotación de mapas",
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"playerStatusTimeline.kill": "",
|
||||
"playerStatusTimeline.splattedByPov": "",
|
||||
"maps.createMapList": "Crear lista de escenarios",
|
||||
"maps.halfSz": "50% Pintazonas",
|
||||
"maps.mapPool": "Grupo de escenarios",
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"playerStatusTimeline.kill": "",
|
||||
"playerStatusTimeline.splattedByPov": "",
|
||||
"maps.createMapList": "Créer une liste de stages",
|
||||
"maps.halfSz": "50% DdZ",
|
||||
"maps.mapPool": "Pool de stages",
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"playerStatusTimeline.kill": "",
|
||||
"playerStatusTimeline.splattedByPov": "",
|
||||
"maps.createMapList": "Créer une liste de stages",
|
||||
"maps.halfSz": "50% DdZ",
|
||||
"maps.mapPool": "Pool de stages",
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"playerStatusTimeline.kill": "",
|
||||
"playerStatusTimeline.splattedByPov": "",
|
||||
"maps.createMapList": "יצירת רשימת מפות",
|
||||
"maps.halfSz": "50% SZ",
|
||||
"maps.mapPool": "מאגר מפות",
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"playerStatusTimeline.kill": "",
|
||||
"playerStatusTimeline.splattedByPov": "",
|
||||
"maps.createMapList": "Crea lista scenari",
|
||||
"maps.halfSz": "50% ZS",
|
||||
"maps.mapPool": "Pool di scenari",
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"playerStatusTimeline.kill": "",
|
||||
"playerStatusTimeline.splattedByPov": "",
|
||||
"maps.createMapList": "ステージリストを作る",
|
||||
"maps.halfSz": "半分エリア",
|
||||
"maps.mapPool": "選択可能なステージ",
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"playerStatusTimeline.kill": "",
|
||||
"playerStatusTimeline.splattedByPov": "",
|
||||
"maps.createMapList": "맵 목록 생성",
|
||||
"maps.halfSz": "에어리어 50%",
|
||||
"maps.mapPool": "맵 풀",
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"playerStatusTimeline.kill": "",
|
||||
"playerStatusTimeline.splattedByPov": "",
|
||||
"maps.createMapList": "Maak levellijst",
|
||||
"maps.halfSz": "50% SZ",
|
||||
"maps.mapPool": "Beschikbare levels",
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"playerStatusTimeline.kill": "",
|
||||
"playerStatusTimeline.splattedByPov": "",
|
||||
"maps.createMapList": "Stwórz liste map",
|
||||
"maps.halfSz": "50% SZ",
|
||||
"maps.mapPool": "Pula map",
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"playerStatusTimeline.kill": "",
|
||||
"playerStatusTimeline.splattedByPov": "",
|
||||
"maps.createMapList": "Criar lista de mapas",
|
||||
"maps.halfSz": "50% Zones",
|
||||
"maps.mapPool": "Seleção de mapas",
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"playerStatusTimeline.kill": "",
|
||||
"playerStatusTimeline.splattedByPov": "",
|
||||
"maps.createMapList": "Создать список карт",
|
||||
"maps.halfSz": "50% Зон",
|
||||
"maps.mapPool": "Пул карт",
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"objectiveTimeline.inControl": "",
|
||||
"playerStatusTimeline.splatted": "",
|
||||
"playerStatusTimeline.specialReady": "",
|
||||
"playerStatusTimeline.kill": "",
|
||||
"playerStatusTimeline.splattedByPov": "",
|
||||
"maps.createMapList": "创建场地列表",
|
||||
"maps.halfSz": "真格区域占 50%",
|
||||
"maps.mapPool": "场地池",
|
||||
|
||||
Reference in New Issue
Block a user