diff --git a/app/features/scanner/components/MatchCard.module.css b/app/features/scanner/components/MatchCard.module.css index 6fd895ef8..f3698dc5d 100644 --- a/app/features/scanner/components/MatchCard.module.css +++ b/app/features/scanner/components/MatchCard.module.css @@ -342,14 +342,6 @@ button.expand { border-radius: var(--radius-box); background: color-mix(in oklab, var(--color-bg-high) 60%, transparent); border: var(--border-width) solid var(--color-border); - - &.teamWin { - border-color: var(--color-success-low); - } - - &.teamLoss { - border-color: var(--color-error-low); - } } .teamHeading { @@ -469,50 +461,48 @@ button.expand { } } -.builds { +.abilities { display: flex; - flex-direction: column; - gap: var(--s-2); + align-items: center; + gap: 2px; +} + +.abilitySlot { + width: 26px; + height: 26px; +} + +.buildButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + margin-inline-start: var(--s-1); + padding: 0; + border: none; + border-radius: var(--radius-full); + background: var(--color-bg-higher); + cursor: pointer; + + &:hover { + box-shadow: 0 0 0 2px var(--color-fg-accent); + } + + &:focus-visible { + outline: var(--focus-ring); + } +} + +.buildPopover { + padding: var(--s-3); } .buildGrid { - display: flex; - flex-wrap: wrap; - gap: var(--s-3) var(--s-4); -} - -.build { - display: flex; - flex-direction: column; - gap: var(--s-1); -} - -.buildHead { - display: flex; - align-items: center; - gap: var(--s-1-5); - font-size: var(--font-2xs); - font-weight: var(--weight-semi); -} - -.buildName { - max-width: 9rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.buildRows { - display: flex; - flex-direction: column; - gap: 2px; -} - -.buildRow { - display: flex; - align-items: center; - gap: 2px; - min-height: 22px; + display: grid; + grid-template-columns: repeat(4, max-content); + place-items: center; + gap: var(--s-2) var(--s-1); } @media (prefers-reduced-motion: reduce) { diff --git a/app/features/scanner/components/MatchCard.tsx b/app/features/scanner/components/MatchCard.tsx index 1fedb9496..e458fdb52 100644 --- a/app/features/scanner/components/MatchCard.tsx +++ b/app/features/scanner/components/MatchCard.tsx @@ -4,16 +4,16 @@ * the game's clips and, once the game is over, its upload state beside the * expand arrow. Expanded it shows the data and * nothing interpreted: the scoreboard, the objective + player-status - * timeline, deaths and kills (each with a ▶ when a clip covers it) and the - * builds read for both teams. + * timeline and deaths and kills (each with a ▶ when a clip covers it). */ import clsx from "clsx"; import { ChevronDown, Play } from "lucide-react"; import { useState } from "react"; import { Ability } from "~/components/Ability"; import { SendouButton } from "~/components/elements/Button"; +import { SendouPopover } from "~/components/elements/Popover"; import { GameTimeline } from "~/components/GameTimeline"; -import { ModeImage, WeaponImage } from "~/components/Image"; +import { Image, ModeImage, WeaponImage } from "~/components/Image"; import { LocaleTime } from "~/components/LocaleTime"; import { matchScoresFromObjective } from "~/components/objective-timeline-utils"; import { StageBannerBox } from "~/components/StageBannerBox"; @@ -22,6 +22,7 @@ import type { AbilityWithUnknown, ModeShort, } from "~/modules/in-game-lists/types"; +import { navIconUrl } from "~/utils/urls"; import { clipCovers } from "../core/clips/scoring"; import { DEATH_EVENT_TYPE, @@ -54,10 +55,11 @@ const KO_MATCH_SCORE = 100; const MATCH_CLOCK_SECONDS: Partial> = { TW: 180 }; const DEFAULT_MATCH_CLOCK_SECONDS = 300; -/** The scan knows the on-screen sides only, not who is playing. */ +/** The scan knows the on-screen sides only, not who is playing: left, right. */ const TEAM_LABELS = ["Alpha", "Bravo"] as const; -const ROW_LABELS = ["head", "clothes", "shoes"] as const; +const GEAR_ROWS = 3; +const SLOTS_PER_ROW = 4; /** the gear row a slot-only main can only sit in; stackables fit anywhere */ const MAIN_ONLY_ROW = new Map( @@ -239,7 +241,6 @@ export function MatchCard({ {expanded && expandable ? (
- {match.objective || match.playerStatus ? ( ({ ...sample, t: sample.t - matchOrigin }), )} - teams={playerStatusTeams(match, TEAM_LABELS)} + teams={playerStatusTeams(match, teamLabels(match))} /> ) : null} @@ -298,6 +299,13 @@ 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, @@ -395,16 +403,10 @@ function Scoreboard({ const [left, right] = displayOrder(match); return (
- {[left, right].map((team) => ( -
+ {[left, right].map((team, side) => ( +
- {TEAM_LABELS[team]} + {TEAM_LABELS[side]} {match.winner === team ? " · WIN" : null} {result === null && match.pov?.team === team ? " · you" : null}
@@ -437,6 +439,9 @@ function Scoreboard({ )} {player.name ?? "?"} + + + {player.ka ?? "?"}/{player.d ?? "?"}/{player.s ?? "?"} @@ -579,63 +584,56 @@ function matchClockAt(match: ScannerMatch, t: number): number | null { } /** - * Builds cover both teams: the POV player's full gear comes from the - * personal-results screen, an enemy's full grid from the death overlay when - * they splatted you, and everyone else's mains from the minimap cards. A row - * renders as far as it was read, with unread slots blank. + * A player's mains in head/clothes/shoes order, then a build button when + * every slot was read. The POV player's full gear comes from the + * personal-results screen, an enemy's from the death overlay when they + * splatted you, everyone else's mains from the minimap cards. */ -function Builds({ match }: { match: ScannerMatch }) { - const [left, right] = displayOrder(match); - const players = [left, right].flatMap((team) => - match.teams[team].players - .map((player, index) => ({ - player, - isPov: match.pov?.team === team && match.pov.index === index, - })) - .filter(({ player }) => player.abilities && player.abilities.length > 0), - ); - if (players.length === 0) return null; - return ( -
-
Builds
-
- {players.map(({ player, isPov }, i) => ( -
-
- {player.weaponId !== null ? ( - - ) : null} - - {isPov ? "You" : (player.name ?? "?")} - -
- -
- ))} -
-
- ); -} - -function BuildRows({ abilities }: { abilities: AbilityWithUnknown[][] }) { +function PlayerAbilities({ + abilities, +}: { + abilities: AbilityWithUnknown[][] | undefined; +}) { + if (!abilities || abilities.length === 0) return null; const rows = gearRows(abilities); + const fullBuild = rows.every((row) => row?.length === SLOTS_PER_ROW) + ? (rows as AbilityWithUnknown[][]) + : null; return ( -
- {ROW_LABELS.map((label, row) => ( -
- {(rows[row] ?? []).map((ability, slot) => ( - - ))} -
- ))} +
+ {rows.map((row, i) => + row?.[0] ? ( + + ) : ( + + ), + )} + {fullBuild ? ( + + + + } + > +
+ {fullBuild.flatMap((row, rowIndex) => + row.map((ability, slot) => ( + + )), + )} +
+
+ ) : null}
); } @@ -655,7 +653,7 @@ function gearRows( undefined, ]; const loose: AbilityWithUnknown[][] = []; - for (const row of abilities.slice(0, ROW_LABELS.length)) { + for (const row of abilities.slice(0, GEAR_ROWS)) { const pinned = row[0] === undefined ? undefined : MAIN_ONLY_ROW.get(row[0]); if (pinned !== undefined && rows[pinned] === undefined) rows[pinned] = row; else loose.push(row);