diff --git a/app/components/GameTimeline.module.css b/app/components/GameTimeline.module.css index 9d63bcf59..59eb89a89 100644 --- a/app/components/GameTimeline.module.css +++ b/app/components/GameTimeline.module.css @@ -107,6 +107,10 @@ &.statusLabelSpecial { color: var(--color-chart-special); } + + &.statusLabelKill { + color: var(--color-chart-kill); + } } .readoutWeapons { diff --git a/app/components/GameTimeline.tsx b/app/components/GameTimeline.tsx index 71f2eefcb..c1777a662 100644 --- a/app/components/GameTimeline.tsx +++ b/app/components/GameTimeline.tsx @@ -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(null); const plotRef = useRef(null); @@ -91,6 +97,7 @@ export function GameTimeline({ objectiveEvents={objectiveEvents} playerStatusSamples={playerStatusSamples} teams={teams} + pov={pov} />
{scrub ? ( @@ -100,6 +107,7 @@ export function GameTimeline({ objective={objective} samples={samples} teams={teams} + pov={samples.length > 0 ? pov : undefined} /> ) : null}
@@ -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 ? ( - + ) : null} {objective.length > 0 ? ( 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 ? : null} ))} @@ -284,6 +305,20 @@ function StatusWeaponsRow({ ); } +function KillsRow({ kills }: { kills: PlayerStatusTimelineKill[] }) { + const { t } = useTranslation(["common"]); + if (kills.length === 0) return null; + + return ( +
+ + {t("common:playerStatusTimeline.kill")} + + {kills.map((kill) => kill.name ?? "?").join(" · ")} +
+ ); +} + function timelineDomain( objective: readonly ObjectiveTimelineEvent[], samples: readonly PlayerStatusTimelineSample[], diff --git a/app/components/PlayerStatusTimeline.module.css b/app/components/PlayerStatusTimeline.module.css index 2e0404f62..6fac6e9fc 100644 --- a/app/components/PlayerStatusTimeline.module.css +++ b/app/components/PlayerStatusTimeline.module.css @@ -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; } diff --git a/app/components/PlayerStatusTimeline.tsx b/app/components/PlayerStatusTimeline.tsx index bb6c78bc4..1ea4033f4 100644 --- a/app/components/PlayerStatusTimeline.tsx +++ b/app/components/PlayerStatusTimeline.tsx @@ -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 (
{t("common:playerStatusTimeline.specialReady")} + {pov && pov.kills.length > 0 ? ( + + + {t("common:playerStatusTimeline.kill")} + + ) : null} + {pov?.kills.some((kill) => kill.victimSlot !== null) ? ( + + + {t("common:playerStatusTimeline.splattedByPov")} + + ) : null}
{([0, 1] as const).map((side) => (
{teams[side].label}
- {[0, 1, 2, 3].map((slot) => ( -
-
- -
-
- {statusSpans(sorted, (sample) => sample.dead[side][slot]!).map( - (span, i) => ( -
- ), - )} - {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 ( +
+
+ +
+
- ))} + className={clsx(styles.track, { + [styles.trackPov]: isPovRow(side, slot), + })} + > + {deadSpans.map((span, i) => ( +
+ ))} + {statusSpans( + sorted, + (sample) => sample.special[side][slot]!, + ).map((span, i) => ( +
+ ))} +
+ {isPovRow(side, slot) + ? pov!.kills.map((kill, i) => ( +
+ )) + : null} +
-
- ))} + ); + })}
))}
@@ -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 { + const opened = new Set(); + 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. diff --git a/app/features/scanner/components/MatchCard.tsx b/app/features/scanner/components/MatchCard.tsx index e458fdb52..06532f821 100644 --- a/app/features/scanner/components/MatchCard.tsx +++ b/app/features/scanner/components/MatchCard.tsx @@ -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({ {match.objective || match.playerStatus ? ( ({ 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} @@ -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, diff --git a/app/features/scanner/components/game-timeline-view.ts b/app/features/scanner/components/game-timeline-view.ts new file mode 100644 index 000000000..84634c1fc --- /dev/null +++ b/app/features/scanner/components/game-timeline-view.ts @@ -0,0 +1,99 @@ +/** Prop derivation for rendering a ScannerMatch with the shared . */ +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 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 { + const [first, second] = displayOrder(match); + const ordered = (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; +} diff --git a/app/features/scanner/components/player-status-view.ts b/app/features/scanner/components/player-status-view.ts deleted file mode 100644 index a02213104..000000000 --- a/app/features/scanner/components/player-status-view.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** Prop derivation for rendering a ScannerMatch's status samples with the shared . */ -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]; -} diff --git a/app/features/scanner/core/timeline/index.ts b/app/features/scanner/core/timeline/index.ts index c445416f8..58815a926 100644 --- a/app/features/scanner/core/timeline/index.ts +++ b/app/features/scanner/core/timeline/index.ts @@ -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 }; diff --git a/app/features/scanner/tests/logic/game-timeline-view.test.ts b/app/features/scanner/tests/logic/game-timeline-view.test.ts new file mode 100644 index 000000000..907ba3acf --- /dev/null +++ b/app/features/scanner/tests/logic/game-timeline-view.test.ts @@ -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); + }); +}); diff --git a/app/features/scanner/tests/timeline.test.ts b/app/features/scanner/tests/timeline.test.ts index b84b2df1f..c9116b7d5 100644 --- a/app/features/scanner/tests/timeline.test.ts +++ b/app/features/scanner/tests/timeline.test.ts @@ -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], diff --git a/app/styles/vars.css b/app/styles/vars.css index dcad8d30b..6fcb0304b 100644 --- a/app/styles/vars.css +++ b/app/styles/vars.css @@ -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"); diff --git a/locales/da/common.json b/locales/da/common.json index 4f4b983fd..e4ec47b61 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index 4b4eedb5d..931d03032 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index fe0bd222a..87fa29cdd 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 444329816..49d78dfee 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -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", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index 8f21c8839..7c2600371 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -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", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index bbf579589..2f2ea3653 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -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", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index 92c086fa2..a66f92093 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index 46cd172f8..2ae3fdf19 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -213,6 +213,8 @@ "objectiveTimeline.inControl": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", + "playerStatusTimeline.kill": "", + "playerStatusTimeline.splattedByPov": "", "maps.createMapList": "יצירת רשימת מפות", "maps.halfSz": "50% SZ", "maps.mapPool": "מאגר מפות", diff --git a/locales/it/common.json b/locales/it/common.json index 81dbce1f5..7d7b7f599 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index d6e3c1901..3c49ad5a4 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -213,6 +213,8 @@ "objectiveTimeline.inControl": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", + "playerStatusTimeline.kill": "", + "playerStatusTimeline.splattedByPov": "", "maps.createMapList": "ステージリストを作る", "maps.halfSz": "半分エリア", "maps.mapPool": "選択可能なステージ", diff --git a/locales/ko/common.json b/locales/ko/common.json index 4ca9bf118..6f4697fad 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -213,6 +213,8 @@ "objectiveTimeline.inControl": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", + "playerStatusTimeline.kill": "", + "playerStatusTimeline.splattedByPov": "", "maps.createMapList": "맵 목록 생성", "maps.halfSz": "에어리어 50%", "maps.mapPool": "맵 풀", diff --git a/locales/nl/common.json b/locales/nl/common.json index 5ecc58070..6d2766a4f 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index a47bbc65b..0ca1bf85d 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 866202b68..bbecce356 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -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", diff --git a/locales/ru/common.json b/locales/ru/common.json index c8ba194b2..b4cb37a68 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -213,6 +213,8 @@ "objectiveTimeline.inControl": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", + "playerStatusTimeline.kill": "", + "playerStatusTimeline.splattedByPov": "", "maps.createMapList": "Создать список карт", "maps.halfSz": "50% Зон", "maps.mapPool": "Пул карт", diff --git a/locales/zh/common.json b/locales/zh/common.json index e51b2364b..60f7ab5ed 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -213,6 +213,8 @@ "objectiveTimeline.inControl": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", + "playerStatusTimeline.kill": "", + "playerStatusTimeline.splattedByPov": "", "maps.createMapList": "创建场地列表", "maps.halfSz": "真格区域占 50%", "maps.mapPool": "场地池",