diff --git a/app.vue b/app.vue index 37b708401..8d703376b 100644 --- a/app.vue +++ b/app.vue @@ -36,6 +36,20 @@ const manifestHref = ? "/manifest.webmanifest" : "/branding/manifest.webmanifest"; +// Every avatar, banner and clip thumbnail is served from the API origin, which +// is a different host to the panel. The LCP element on /watch is one of those +// banners, and its request cannot even be issued until the page's GraphQL has +// resolved -- so without this the DNS + TCP + TLS handshake to that origin is +// paid at ~4.1s, right on the critical path. Warming it during boot moves that +// cost off the LCP chain entirely. +const apiOrigin = (() => { + const domain = useRuntimeConfig().public.apiDomain; + if (!domain) { + return undefined; + } + return domain.startsWith("http") ? domain : `https://${domain}`; +})(); + useHead({ title: () => brandName.value || "5Stack", titleTemplate: (pageTitle?: string) => { @@ -45,7 +59,15 @@ useHead({ } return `${base} | ${t("branding.site_title_suffix")}`; }, - link: [{ rel: "manifest", href: manifestHref }], + link: [ + { rel: "manifest", href: manifestHref }, + ...(apiOrigin + ? [ + { rel: "preconnect", href: apiOrigin, crossorigin: "anonymous" }, + { rel: "dns-prefetch", href: apiOrigin }, + ] + : []), + ], // iOS home-screen label — plain brand name, no " | …" suffix. meta: [ { @@ -64,6 +86,7 @@ const hasGlobalStream = computed(() => !!applicationSettingsStore.globalStream); const TAB_QUERY_KEYS = new Set(["tab", "mode"]); function pageKeyWithoutTabQuery(route: { + name?: string | symbol | null; path: string; query: Record; hash?: string; @@ -78,6 +101,16 @@ function pageKeyWithoutTabQuery(route: { return `/apps/${plugin[1]}`; } + // Same idea, and for the same reason: the map is a PARAMETER of the utility + // library, not a different page. Keying it on the path made every map switch + // a full remount — the whole page torn down and rebuilt behind a 520ms slide, + // the 740px board gone and back, every panel refiring its queries from + // nothing — for what is really one prop changing. The page watches `mapName` + // and swaps its contents in place instead. + if (route.name === "utility-map") { + return "/utility/:map"; + } + const query = new URLSearchParams(); const persisted = new Set([ ...TAB_QUERY_KEYS, diff --git a/assets/css/tailwind.css b/assets/css/tailwind.css index 68419ef28..4ca55404e 100644 --- a/assets/css/tailwind.css +++ b/assets/css/tailwind.css @@ -377,6 +377,23 @@ mark { opacity: 0.6; } +/* The utility feature is iconed with the in-game HE artwork instead of a lucide + glyph, so the nav entry reads as the same thing as the utility markers in 2D + and 3D playback. The file is a single white path, so masking it lets the + silhouette take currentColor the way a lucide icon does. Plain CSS because + Tailwind will not generate the -webkit- half of the mask pair from arbitrary + values, and Safari still needs it. */ +.he-grenade-mask { + mask-image: url("/img/equipment/hegrenade.svg"); + -webkit-mask-image: url("/img/equipment/hegrenade.svg"); + mask-size: contain; + -webkit-mask-size: contain; + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-position: center; +} + /* Faint HUD scanlines laid over event banner media, so photography/video reads as composed into the tactical chrome instead of pasted above it. */ .tac-scanlines { @@ -747,3 +764,139 @@ mark { color: hsl(var(--tac-amber)); background: hsl(var(--tac-amber) / 0.12); } + +/* The utility board's overlays are bounded by the board, not by the viewport: + collapsing the left nav or opening the right hub narrows it on a desktop, so + a viewport breakpoint fires at the wrong times. Written here rather than as a + Tailwind arbitrary variant because no container-query plugin is installed -- + same reason .tac-chip and .tac-amber-cta live here. */ +.utility-board { + container-type: inline-size; + container-name: utility-board; +} + +/* The view strip collapses to its icons before it can start eating the map's + name. Each tab keeps its icon, its count and its tooltip, and the strip still + scrolls -- so nothing is lost, it just stops competing for the same row. */ +@container utility-board (max-width: 44rem) { + .utility-board-tabs .af-label { + display: none; + } +} + +/* The strip, the toggle and the type chips beside them are all exactly 2rem, + but any height the strip spends on a frame comes off the pill inside it, and + the pill is what the eye actually compares against the toggle: 26px of amber + next to a 32px amber square still read as two different controls. So the + frame costs nothing here -- no padding, and the hairline is an inset ring + rather than a border. The pill is then the full 2rem, exactly the toggle's + square, and the strip is still 2rem overall. */ +.utility-meta-threshold { + padding: 0; + border-width: 0; + box-shadow: inset 0 0 0 1px hsl(0 0% 100% / 0.1); +} +.utility-meta-threshold > button { + height: 2rem; +} +/* Full-bleed now, so the indicator wears the strip's own corner radius -- the + toggle's rounded-md -- instead of a smaller one floating inside a frame. */ +.utility-meta-threshold > span { + border-radius: 0.375rem; +} + +/* On a narrow board the threshold knob leaves the chips' row entirely: it + stands up as a ladder and that ladder sits directly above the toggle, so the + whole meta control is one narrow column pinned to the bottom-right corner and + the chips get the full width of the row back. + + Only direction is set here -- AnimatedFilters measures its indicator with + offsetLeft/offsetTop and re-measures on a ResizeObserver, so it follows the + flip on its own without needing a prop. */ +@container utility-board (max-width: 38rem) { + .utility-meta-controls { + flex-direction: column; + /* flex-end, not stretch: the toggle keeps its own 2rem square and never + grows to the ladder's width. Stretching it widened this whole right-hand + column, which is width taken straight off the type chips beside it -- + the toggle is a fixed anchor in the corner and has to stay one. */ + align-items: flex-end; + gap: 0.375rem; + } + .utility-meta-threshold { + flex-direction: column; + } + /* The pills already share a width down a column; this only centres their + labels in it rather than leaving them ragged. */ + .utility-meta-threshold > button { + justify-content: center; + } + + /* Opening the overlay must cost the chips nothing, and keeping the toggle a + fixed square was only half of it: the ladder is wider than the toggle and + the META n/total caption is wider still, so both grew this column and took + that width straight off the chips. Out of flow, the column contributes + nothing at all -- the chips are measured against the toggle alone, whether + the overlay is open or shut. + + The row is already position:absolute, so it is its own containing block and + no extra positioning context is needed. Both extras stack upward from the + toggle, which is above the chips' line rather than on it, so nothing can + collide with them. */ + .utility-meta-cluster { + position: absolute; + right: 0; + bottom: 0; + } + /* The gutter the absolute cluster no longer reserves for itself: the toggle's + 2rem plus the row's gap. */ + .utility-board-chips { + padding-right: 2.5rem; + } +} + +/* The left-hand column keeps everything it has at every width. The legend used + to drop at 34rem to buy vertical air, but it sits above the chips rather than + beside them, so it never bought a single pixel of the width that was actually + under pressure -- and losing it left the colour chips explaining themselves. + The meta toggle is not in this cascade either: it is a bare square icon at + every width, because its glyph draws the rings the overlay draws. */ + +/* Last, the practice button drops its word and squares off. A lone 16px glyph + sitting inside sm's px-3 reads as a button that failed to load its label + rather than as an icon button, so the padding has to go with the text. */ +@container utility-board (max-width: 26rem) { + .utility-board-practice-label { + display: none; + } + .utility-board-practice-btn { + width: 2rem; + padding-left: 0; + padding-right: 0; + } +} + +/* A field that stays out of the way until you go near it. An authoring panel + that is mostly *reading* -- an execute you are checking, a name you are not + changing -- turns into a wall of boxes when every value wears a border and a + label. The value is the interface; the box appears on hover or focus to say + it can be edited. Lives here rather than in a .ts class map because Tailwind + generates no CSS for arbitrary variants defined outside a scanned template + (same reason .tac-chip and .tac-amber-cta are here). */ +.tac-quiet-field { + border-color: transparent; + background-color: transparent; + transition: + border-color 150ms ease, + background-color 150ms ease; +} +.tac-quiet-field:hover:not(:disabled), +.tac-quiet-field:focus, +.tac-quiet-field:focus-visible, +.tac-quiet-field:focus-within { + border-color: hsl(var(--border)); + background-color: hsl(var(--muted) / 0.3); +} +.tac-quiet-field:disabled { + opacity: 1; +} diff --git a/components/BreadCrumbs.vue b/components/BreadCrumbs.vue index cc899ca6e..38ecd4c0c 100644 --- a/components/BreadCrumbs.vue +++ b/components/BreadCrumbs.vue @@ -48,6 +48,7 @@ import { useTeamContext } from "~/composables/useTeamContext"; import { useDraftRoomContext } from "~/composables/useDraftRoomContext"; import { useSeasonContext } from "~/composables/useSeasonContext"; import { useAwardContext } from "~/composables/useAwardContext"; +import cleanMapName from "~/utilities/cleanMapName"; export default { computed: { @@ -173,6 +174,21 @@ export default { return; } + // /utility/: the segment is the map's file name, and every other + // surface -- the board title, the picker, the practice dialog -- shows + // the title. Named routes only, so /utility/lineup/ is untouched. + if ( + segments[0] === "utility" && + index === 1 && + this.$route.name === "utility-map" + ) { + breadcrumbs.push({ + text: cleanMapName(segment), + to: path, + }); + return; + } + if (segments[0] === "draft-room" && index === 1) { if (drc.value?.id !== segment) { return; diff --git a/components/clips/ClipDetailModal.vue b/components/clips/ClipDetailModal.vue index 6652a2cc5..39e0319df 100644 --- a/components/clips/ClipDetailModal.vue +++ b/components/clips/ClipDetailModal.vue @@ -20,7 +20,6 @@ import { Lock, Globe, X, - Radio, ChevronLeft, ChevronRight, ListVideo, @@ -530,47 +529,15 @@ onMounted(() => { }} - - - - -
- - - - - - - {{ $t("clips.detail.default_title") }} - - +
@@ -643,7 +610,7 @@ onMounted(() => { - {{ queuePositionLabel }} - -
- + {{ queuePositionLabel }} + +
+ + +
+
- -
{ @click="showDelete = true" > - {{ $t("ui_extras.delete_clip") }} + {{ $t("common.delete") }}
diff --git a/components/clips/MatchClipsGroupCard.vue b/components/clips/MatchClipsGroupCard.vue index 25d961407..bf2eda31a 100644 --- a/components/clips/MatchClipsGroupCard.vue +++ b/components/clips/MatchClipsGroupCard.vue @@ -111,6 +111,7 @@ const pillBaseClasses = v-if="thumbnailSrc" :src="thumbnailSrc" :alt="matchupLabel ?? $t('ui_extras.match_highlights_alt')" + loading="lazy" class="absolute inset-0 h-full w-full object-cover transition-[transform,opacity] duration-500 group-hover/group-card:scale-[1.03]" :class="thumbLoaded ? 'opacity-100' : 'opacity-0'" @load="thumbLoaded = true" diff --git a/components/common/AnimatedFilters.vue b/components/common/AnimatedFilters.vue index d53698469..5fc1cc2dc 100644 --- a/components/common/AnimatedFilters.vue +++ b/components/common/AnimatedFilters.vue @@ -25,18 +25,24 @@ const props = defineProps<{ size?: "lg"; block?: boolean; fill?: boolean; + // Count over label, in equal columns. For narrow columns where a row of + // label-plus-count pills does not fit and wrapping strands the last one. + stacked?: boolean; }>(); const model = defineModel(); const containerShape = computed(() => - props.square ? "rounded-md" : "rounded-full", + props.square || props.stacked ? "rounded-md" : "rounded-full", ); const indicatorShape = computed(() => - props.square ? "rounded" : "rounded-full", + props.square || props.stacked ? "rounded" : "rounded-full", ); const buttonShape = computed(() => { - const base = props.block ? "flex-1" : ""; + const base = props.block ? "min-w-0 flex-1" : ""; + if (props.stacked) { + return "flex min-w-0 flex-col items-center justify-center gap-1 rounded px-1 py-1.5"; + } if (props.size === "lg") { return `inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-2.5 font-mono text-[0.72rem] font-bold uppercase leading-tight tracking-[0.08em] ${base}`; } @@ -46,6 +52,13 @@ const buttonShape = computed(() => { ? `inline-flex h-[1.375rem] items-center justify-center gap-1.5 rounded px-2.5 font-mono text-[0.65rem] font-semibold uppercase leading-none tracking-[0.12em] ${base}` : `inline-flex items-center justify-center gap-1.5 rounded-full px-3 py-1.5 text-xs tracking-[0.06em] ${base}`; }); +function countTone(opt: FilterOption) { + if (model.value === opt.key || opt.disabled) { + return ""; + } + return (opt.count ?? 0) === 0 ? "opacity-35" : ""; +} + function buttonState(opt: FilterOption) { const selected = model.value === opt.key; if (opt.disabled) { @@ -119,7 +132,11 @@ watch( :class="[ containerShape, fill ? 'flex-1 self-stretch' : 'self-start', - block ? 'flex w-full' : 'inline-flex w-fit flex-wrap', + stacked + ? 'grid w-full auto-cols-fr grid-flow-col' + : block + ? 'flex w-full' + : 'inline-flex w-fit flex-wrap', ]" > - - {{ opt.label }} - {{ - opt.count - }} + +
@@ -186,11 +219,24 @@ watch( :class="[buttonShape, buttonState(opt)]" @click="!opt.disabled && (model = opt.key)" > - - {{ opt.label }} - {{ - opt.count - }} + +
diff --git a/components/icons/HeGrenadeIcon.vue b/components/icons/HeGrenadeIcon.vue new file mode 100644 index 000000000..408988a36 --- /dev/null +++ b/components/icons/HeGrenadeIcon.vue @@ -0,0 +1,6 @@ + diff --git a/components/icons/UtilityDriftIcon.vue b/components/icons/UtilityDriftIcon.vue new file mode 100644 index 000000000..42754083f --- /dev/null +++ b/components/icons/UtilityDriftIcon.vue @@ -0,0 +1,23 @@ + diff --git a/components/match/DesktopSnapshot.vue b/components/match/DesktopSnapshot.vue index 60258f654..3d20fd779 100644 --- a/components/match/DesktopSnapshot.vue +++ b/components/match/DesktopSnapshot.vue @@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { Monitor } from "lucide-vue-next"; import socket from "~/web-sockets/Socket"; -type SnapshotKind = "live" | "demo" | "bake" | "clips"; +type SnapshotKind = "live" | "demo" | "bake" | "clips" | "nades"; const props = withDefaults( defineProps<{ diff --git a/components/match/MatchMapAnalysis.vue b/components/match/MatchMapAnalysis.vue index 4a25d4c5a..54389ec97 100644 --- a/components/match/MatchMapAnalysis.vue +++ b/components/match/MatchMapAnalysis.vue @@ -29,16 +29,10 @@ import { import { matchHeatmapQuery } from "~/graphql/matchHeatmapGraphql"; import { matchMovementMapQuery } from "~/graphql/matchMovementPathsGraphql"; import RoundSelector from "~/components/match/RoundSelector.vue"; - -type MapSplit = { - bounds: { top: number; bottom: number }; - offset: { x: number; y: number }; -}; -type RadarMeta = { - resolution: number; - offset: { x: number; y: number }; - splits?: MapSplit[]; -}; +import { + RADAR_CANVAS, + useRadarProjection, +} from "~/composables/useRadarProjection"; type DotCategory = "kills" | "deaths" | "utility" | "util_damage"; @@ -86,8 +80,7 @@ const { t } = useI18n(); const side = useMatchSide(); const { client } = useApolloClient(); -const CANVAS = 1024; -const RADAR_PX = 1024; +const CANVAS = RADAR_CANVAS; const KILL_COLOR = "#fbbf24"; const DEATH_COLOR = "rgb(239, 68, 68)"; @@ -117,7 +110,6 @@ const WINDOW_OPTIONS = [10, 20, 30, 45]; const mode = ref<"heatmap" | "movement">("heatmap"); const renderStyle = ref<"heat" | "dots">("dots"); -const calibrations = ref | null>(null); const radarFailed = ref(false); const selectedMapId = ref(null); @@ -237,30 +229,13 @@ function open3dPlayback() { } } -const normalizedMap = computed(() => - (activeMatchMap.value?.map?.name || "") - .trim() - .toLowerCase() - .replace(/_night$/, ""), -); - -const calibration = computed(() => { - if (!calibrations.value || !normalizedMap.value) { - return null; - } - return calibrations.value[normalizedMap.value] ?? null; -}); - -const radarSrc = computed(() => { - if (!calibration.value || !normalizedMap.value || radarFailed.value) { - return null; - } - return `/radars/${normalizedMap.value}.png`; -}); - -const has2dRadar = computed(() => - calibrations.value === null ? true : !!calibration.value, -); +const { + normalizedMap, + calibration, + radarSrc, + hasCalibration: has2dRadar, + projectCalibrated, +} = useRadarProjection(() => activeMatchMap.value?.map?.name, { radarFailed }); const has3dMesh = ref(true); watch( @@ -281,19 +256,6 @@ watch( { immediate: true }, ); -onMounted(async () => { - try { - const res = await fetch("/radars/metadata.json"); - if (res.ok) { - const data = await res.json(); - const { _comment, ...rest } = data; - calibrations.value = rest as Record; - } - } catch { - /* */ - } -}); - async function loadHeatmap() { const matchId = props.match?.id; if (!matchId || matchId === loadedHeatmapMatchId.value) { @@ -403,33 +365,7 @@ function parseCoords( return { x: parts[0], y: parts[1], z: parts[2] ?? 0 }; } -function applySplit(z: number, splits: MapSplit[] | undefined) { - if (!splits) { - return { dx: 0, dy: 0 }; - } - for (const s of splits) { - if (z > s.bounds.bottom && z < s.bounds.top) { - return { dx: s.offset.x, dy: s.offset.y }; - } - } - return { dx: 0, dy: 0 }; -} - -function projectRaw(p: { x: number; y: number; z?: number }) { - if (!calibration.value) { - return null; - } - const { resolution, offset, splits } = calibration.value; - const split = applySplit(p.z ?? 0, splits); - const gameX = p.x + offset.x; - const gameY = p.y + offset.y; - const pxX = gameX / resolution + (split.dx / 100) * RADAR_PX; - const pxYFromBottom = gameY / resolution + (split.dy / 100) * RADAR_PX; - return { - x: pxX * (CANVAS / RADAR_PX), - y: CANVAS - pxYFromBottom * (CANVAS / RADAR_PX), - }; -} +const projectRaw = projectCalibrated; const lineupBySteamId = computed(() => { const out = new Map(); diff --git a/components/match/MatchTabs.vue b/components/match/MatchTabs.vue index 70e83a3e4..85d016483 100644 --- a/components/match/MatchTabs.vue +++ b/components/match/MatchTabs.vue @@ -14,6 +14,7 @@ import MatchMapAnalysis from "~/components/match/MatchMapAnalysis.vue"; import MatchEconomyTimeline from "~/components/match/MatchEconomyTimeline.vue"; import HeadToHead from "~/components/match/HeadToHead.vue"; import MatchRoles from "~/components/match/MatchRoles.vue"; +import MatchUtilityUtility from "~/components/match/MatchUtilityUtility.vue"; import MatchSideFilter from "~/components/match/MatchSideFilter.vue"; import TableColumnPicker from "~/components/common/TableColumnPicker.vue"; import TeamUtilitySummary from "~/components/match/TeamUtilitySummary.vue"; @@ -208,6 +209,9 @@ provide("commander", commander); {{ $t("match.tabs.map_analysis") }} + + {{ $t("match.tabs.utility") }} + {{ $t("match.tabs.settings") }} @@ -250,6 +254,9 @@ provide("commander", commander); {{ $t("match.tabs.map_analysis") }} + + {{ $t("match.tabs.utility") }} + @@ -552,6 +559,11 @@ provide("commander", commander); + +
+ +
+
+import { computed, ref, watch } from "vue"; +import { useI18n } from "vue-i18n"; +import { useApolloClient } from "@vue/apollo-composable"; +import { ArrowUpRight, Info, Save } from "lucide-vue-next"; +import HeGrenadeIcon from "~/components/icons/HeGrenadeIcon.vue"; +import { Card, CardContent } from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; +import { Skeleton } from "~/components/ui/skeleton"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; +import UtilitySaveLineupDialog from "~/components/utility/UtilitySaveLineupDialog.vue"; +import { utilityMatchUtilityReportQuery } from "~/graphql/utilityGraphql"; +import { matchMovementMapQuery } from "~/graphql/matchMovementPathsGraphql"; +import { + fetchReplayBlob, + normalizeBlobGrenades, +} from "~/composables/useReplayBlob"; +import { useAuthStore } from "~/stores/AuthStore"; +import cleanMapName from "~/utilities/cleanMapName"; +import { + UTILITY_TYPES, + UTILITY_TYPE_COLORS, + canonicalUtilityType, + humanizeUtilityToken, + utilityLineupRoute, +} from "~/utilities/utilityDisplay"; +import { + tacticalSectionLabelClasses, + tacticalSectionTickClasses, + tacticalSectionDescriptionClasses, +} from "~/utilities/tacticalClasses"; +import { readUtilityUtilityReport } from "~/types/utility"; +import type { + UtilityType, + UtilityUtilityReportOutput, + UtilityUtilityReportView, +} from "~/types/utility"; + +const props = defineProps<{ + match: any; +}>(); + +const { t } = useI18n(); +const { client: apolloClient } = useApolloClient(); + +const EVERYONE = "everyone"; + +const report = ref(null); +const loading = ref(true); + +const players = computed(() => { + const rows: Array<{ steamId: string; name: string }> = []; + const seen = new Set(); + for (const lineup of [props.match?.lineup_1, props.match?.lineup_2]) { + for (const entry of lineup?.lineup_players ?? []) { + const steamId = entry?.steam_id ? String(entry.steam_id) : null; + if (!steamId || seen.has(steamId)) { + continue; + } + seen.add(steamId); + rows.push({ + steamId, + name: entry.player?.name ?? entry.placeholder_name ?? steamId, + }); + } + } + return rows; +}); + +const mySteamId = computed(() => useAuthStore().me?.steam_id ?? null); + +const selectedSteamId = ref(EVERYONE); + +// Fires once, on the first roster that has anybody in it: "you threw twelve" is +// the version of this that means something, so it opens on the viewer whenever +// the viewer played. After that the choice is the viewer's, and a later roster +// update must not drag it back. +const autoPicked = ref(false); + +watch( + [players, mySteamId], + ([roster, me]) => { + if (autoPicked.value || !roster.length) { + return; + } + autoPicked.value = true; + if (me && roster.some((entry) => entry.steamId === String(me))) { + selectedSteamId.value = String(me); + } + }, + { immediate: true }, +); + +let loadGen = 0; + +async function load() { + const matchId = props.match?.id; + if (!matchId) { + report.value = null; + return; + } + const gen = ++loadGen; + loading.value = true; + try { + const { data } = await apolloClient.query({ + query: utilityMatchUtilityReportQuery, + variables: { + match_id: matchId, + steam_id: + selectedSteamId.value === EVERYONE ? null : selectedSteamId.value, + }, + fetchPolicy: "no-cache", + }); + if (gen !== loadGen) { + return; + } + report.value = readUtilityUtilityReport( + (data as any)?.utilityMatchUtilityReport as + | UtilityUtilityReportOutput + | undefined, + ); + } catch (error) { + if (gen === loadGen) { + console.error("[utility] match utility report error:", error); + report.value = null; + } + } finally { + if (gen === loadGen) { + loading.value = false; + } + } +} + +watch(() => [props.match?.id, selectedSteamId.value], load, { + immediate: true, +}); + +// The parser's own word for the utility, shown as it came when it is not one of +// the five this UI has names for. +function utilityLabel(utilityType: string) { + return (UTILITY_TYPES as readonly string[]).includes(utilityType) + ? t(`pages.utility.types.${utilityType}`) + : humanizeUtilityToken(utilityType) || utilityType; +} + +const counters = computed(() => { + const view = report.value; + if (!view) { + return []; + } + return [ + { key: "throws", label: t("match.utility.throws"), value: view.throws }, + { + key: "matched_lineups", + label: t("match.utility.matched_lineups"), + value: view.matchedLineups, + }, + { + key: "matched_meta", + label: t("match.utility.matched_meta"), + value: view.matchedMeta, + }, + { key: "landed", label: t("match.utility.landed"), value: view.landed }, + ]; +}); + +type MatchGrenadeRow = { + key: string; + grenadeId: number; + round: number; + throwerSteamId: string; + throwerName: string; + utilityType: UtilityType | null; + rawType: string; + /** Paired with a detonation in the same demo, by grenade id. */ + landed: boolean; +}; + +const matchMaps = computed(() => props.match?.match_maps ?? []); + +const selectedMapId = ref(""); + +watch( + matchMaps, + (maps) => { + if (!maps.length) { + selectedMapId.value = ""; + return; + } + if (!maps.some((entry) => entry.id === selectedMapId.value)) { + selectedMapId.value = maps[0].id; + } + }, + { immediate: true }, +); + +const activeMatchMap = computed( + () => + matchMaps.value.find((entry) => entry.id === selectedMapId.value) ?? + matchMaps.value[0] ?? + null, +); + +// The playback blob is a 1-3MB download. The report above never needs it, so +// nobody pays for it until they ask to see the grenades themselves. +const grenadesOpen = ref(false); +const grenadesLoading = ref(false); +const grenadesFailed = ref(false); +const grenadeRows = ref([]); +// Throws the demo recorded without a grenade id. They cannot be paired and +// cannot be saved, but they are counted and said out loud rather than dropped. +const unidentifiedThrows = ref(0); +const loadedMapId = ref(null); +const visibleCount = ref(25); + +const GRENADE_PAGE = 25; + +const playerNames = computed(() => { + const names: Record = {}; + for (const entry of players.value) { + names[entry.steamId] = entry.name; + } + return names; +}); + +let blobGen = 0; + +async function loadGrenades() { + const mapId = activeMatchMap.value?.id; + if (!mapId || loadedMapId.value === mapId) { + return; + } + const gen = ++blobGen; + grenadesLoading.value = true; + grenadesFailed.value = false; + grenadeRows.value = []; + unidentifiedThrows.value = 0; + try { + const { data } = await apolloClient.query({ + query: matchMovementMapQuery, + variables: { matchMapId: mapId }, + fetchPolicy: "cache-first", + }); + if (gen !== blobGen) { + return; + } + const url: string | null = + (data as any)?.match_maps_by_pk?.demos?.[0]?.playback_url ?? null; + if (!url) { + loadedMapId.value = mapId; + return; + } + const blob = await fetchReplayBlob(url); + if (gen !== blobGen) { + return; + } + const grenades = normalizeBlobGrenades(blob?.grenade_throws ?? []); + const detonated = new Set(); + for (const grenade of grenades) { + if (grenade.phase === "detonated" && grenade.grenade_id != null) { + detonated.add(Number(grenade.grenade_id)); + } + } + const rows: MatchGrenadeRow[] = []; + let unidentified = 0; + for (const grenade of grenades) { + if (grenade.phase !== "thrown") { + continue; + } + if (grenade.grenade_id == null) { + unidentified += 1; + continue; + } + const grenadeId = Number(grenade.grenade_id); + const steamId = String(grenade.thrower_steam_id ?? ""); + const rawType = String(grenade.type ?? ""); + rows.push({ + key: `${mapId}:${grenadeId}`, + grenadeId, + round: Number(grenade.round ?? 0), + throwerSteamId: steamId, + throwerName: playerNames.value[steamId] ?? steamId, + utilityType: canonicalUtilityType(rawType), + rawType, + landed: detonated.has(grenadeId), + }); + } + rows.sort((a, b) => a.round - b.round || a.grenadeId - b.grenadeId); + grenadeRows.value = rows; + unidentifiedThrows.value = unidentified; + loadedMapId.value = mapId; + } catch (error) { + if (gen === blobGen) { + console.error("[utility] match grenade load error:", error); + grenadesFailed.value = true; + } + } finally { + if (gen === blobGen) { + grenadesLoading.value = false; + } + } +} + +watch([grenadesOpen, () => activeMatchMap.value?.id], () => { + if (grenadesOpen.value) { + void loadGrenades(); + } +}); + +// The roster picker above is the same filter here: "the twelve you threw" is +// what somebody opening this is usually after. +const filteredGrenades = computed(() => { + if (selectedSteamId.value === EVERYONE) { + return grenadeRows.value; + } + return grenadeRows.value.filter( + (row) => row.throwerSteamId === selectedSteamId.value, + ); +}); + +const visibleGrenades = computed(() => + filteredGrenades.value.slice(0, visibleCount.value), +); + +watch([filteredGrenades, () => activeMatchMap.value?.id], () => { + visibleCount.value = GRENADE_PAGE; +}); + +function grenadeTypeLabel(row: MatchGrenadeRow) { + if (row.utilityType) { + return t(`pages.utility.types.${row.utilityType}`); + } + return humanizeUtilityToken(row.rawType) || t("common.unknown"); +} + +function grenadeColor(row: MatchGrenadeRow) { + return row.utilityType ? UTILITY_TYPE_COLORS[row.utilityType] : "#8b93a5"; +} + +const saveTarget = ref(null); +const saveOpen = ref(false); + +// Keyed by the row key, so a saved grenade turns into a link to what it became +// rather than an unchanged Save button. +const savedLineupIds = ref>({}); + +function openSave(row: MatchGrenadeRow) { + saveTarget.value = row; + saveOpen.value = true; +} + +function onSaved(id: string) { + const row = saveTarget.value; + if (!row) { + return; + } + savedLineupIds.value = { ...savedLineupIds.value, [row.key]: id }; + // The counters above count throws that matched a saved lineup, and one more + // lineup now exists — leaving them alone would show a stale "matched" against + // a library the viewer just changed. + void load(); +} + +const saveDefaultName = computed(() => { + const row = saveTarget.value; + if (!row) { + return ""; + } + return t("match.utility.default_lineup_name", { + utility: grenadeTypeLabel(row), + round: row.round, + }); +}); + +const canSaveLineups = computed(() => !!mySteamId.value); + + + diff --git a/components/match/Replay3DLite.vue b/components/match/Replay3DLite.vue index 1f0153f5b..6cf0daee1 100644 --- a/components/match/Replay3DLite.vue +++ b/components/match/Replay3DLite.vue @@ -198,7 +198,7 @@ function canvasTex(canvas: HTMLCanvasElement) { t.colorSpace = THREE.SRGBColorSpace; return t; } -const NADE_COL: Record = { +const UTILITY_COL: Record = { Smoke: 0x32d6e0, Molotov: 0xff6a1a, HE: 0xff3b3b, @@ -1816,7 +1816,7 @@ onMounted(() => { // the old 3D player). Shape reads the type; depthTest off = visible through // walls like the lines. ===== const G = 11 * U; // base grenade dimension - const nadeMatU = (hex: number) => + const utilityMatU = (hex: number) => new THREE.MeshStandardMaterial({ color: hex, emissive: hex, @@ -1830,18 +1830,18 @@ onMounted(() => { g.add( new THREE.Mesh( new THREE.CylinderGeometry(G * 0.55, G * 0.55, G * 1.7, 14), - nadeMatU(hex), + utilityMatU(hex), ), ); const cap = new THREE.Mesh( new THREE.CylinderGeometry(G * 0.46, G * 0.46, G * 0.45, 14), - nadeMatU(0x2a2e34), + utilityMatU(0x2a2e34), ); cap.position.y = G * 0.95; g.add(cap); const lip = new THREE.Mesh( new THREE.CylinderGeometry(G * 0.6, G * 0.6, G * 0.2, 14), - nadeMatU(0x20242a), + utilityMatU(0x20242a), ); lip.position.y = G * 0.72; g.add(lip); @@ -1851,13 +1851,13 @@ onMounted(() => { const g = new THREE.Group(); const b = new THREE.Mesh( new THREE.SphereGeometry(G * 0.8, 14, 12), - nadeMatU(hex), + utilityMatU(hex), ); b.scale.set(1, 1.3, 1); g.add(b); const band = new THREE.Mesh( new THREE.CylinderGeometry(G * 0.85, G * 0.85, G * 0.3, 14), - nadeMatU(0x33373d), + utilityMatU(0x33373d), ); g.add(band); return g; @@ -1867,31 +1867,31 @@ onMounted(() => { g.add( new THREE.Mesh( new THREE.CylinderGeometry(G * 0.55, G * 0.62, G * 1.6, 14), - nadeMatU(hex), + utilityMatU(hex), ), ); const neck = new THREE.Mesh( new THREE.CylinderGeometry(G * 0.24, G * 0.46, G * 0.7, 10), - nadeMatU(hex), + utilityMatU(hex), ); neck.position.y = G; g.add(neck); const rag = new THREE.Mesh( new THREE.SphereGeometry(G * 0.3, 8, 6), - nadeMatU(0xe8d8b0), + utilityMatU(0xe8d8b0), ); rag.position.y = G * 1.45; g.add(rag); return g; } - function makeNadeModels() { + function makeUtilityModels() { const grp = new THREE.Group(); const models: Record = { - Smoke: makeCanister(NADE_COL.Smoke), - Flash: makeCanister(NADE_COL.Flash), - Decoy: makeCanister(NADE_COL.Decoy), - HE: makeFrag(NADE_COL.HE), - Molotov: makeBottle(NADE_COL.Molotov), + Smoke: makeCanister(UTILITY_COL.Smoke), + Flash: makeCanister(UTILITY_COL.Flash), + Decoy: makeCanister(UTILITY_COL.Decoy), + HE: makeFrag(UTILITY_COL.HE), + Molotov: makeBottle(UTILITY_COL.Molotov), }; for (const k in models) { models[k].visible = false; @@ -1905,7 +1905,7 @@ onMounted(() => { scene.add(grp); return { grp, models }; } - const projs = Array.from({ length: 12 }, makeNadeModels); + const projs = Array.from({ length: 12 }, makeUtilityModels); const arcMat = () => new THREE.MeshStandardMaterial({ color: 0xffffff, @@ -2378,7 +2378,7 @@ onMounted(() => { ? 0xfff4d6 : g.type === "HE" ? 0xff5a2a - : (NADE_COL[g.type] ?? 0xffffff); + : (UTILITY_COL[g.type] ?? 0xffffff); // The pressure front expands at the engine's own ~1250 u/s, so the ring // crosses a 250-unit influence radius in 0.2s. Previously it was a // taste-tuned curve stretched over the whole lifetime, which made an @@ -2417,7 +2417,7 @@ onMounted(() => { m.position.copy(_v).setY(_v.y + 1); const r = (g.type === "Smoke" ? SMOKE_R : FIRE_R) / SMOKE_R; m.scale.set(r, r, r); - m.material.uniforms.uColor.value.setHex(NADE_COL[g.type]); + m.material.uniforms.uColor.value.setHex(UTILITY_COL[g.type]); m.material.uniforms.uRemain.value = life; } } @@ -2530,12 +2530,12 @@ onMounted(() => { arcHeads[i].visible = false; continue; } - const hex = NADE_COL[g.type] ?? 0xffffff; + const hex = UTILITY_COL[g.type] ?? 0xffffff; const arc: any = arcs[i]; // Rebuild the tube only when the slot changes grenade. This used to run // every frame — disposing and re-tessellating a 32-segment tube per // grenade per frame — which is pure waste, since the flight path is fixed - // the moment the nade is thrown. + // the moment the utility is thrown. const arcKey = `${g.key}`; if (arc.userData.arcKey !== arcKey) { arc.userData.arcKey = arcKey; @@ -2556,12 +2556,12 @@ onMounted(() => { // Reveal the trail only as far as the grenade has actually flown. A // TubeGeometry emits its triangles in order along the curve, so a prefix // of the index buffer is exactly the flown portion — which turns a static - // line into the nade drawing its own arc as it travels. + // line into the utility drawing its own arc as it travels. const prog = Math.max(0, Math.min(1, g.progress)); // Quantise to whole tube segments and drive BOTH the trail and the // grenade from that same value. Revealing the trail by a rounded segment // count while positioning the head at the exact fraction let the grenade - // run ahead of its own trail, which read as the nade arriving before the + // run ahead of its own trail, which read as the utility arriving before the // line caught up. // TubeGeometry lays its rings out by getPointAt — arc length — while // `progress` is a fraction of flight time, which getPoint consumes. On a @@ -2605,7 +2605,7 @@ onMounted(() => { m.visible = true; m.position.copy(_v).setY(_v.y + 3); (m.material as THREE.MeshBasicMaterial).color.setHex( - NADE_COL[g.type] ?? 0xffffff, + UTILITY_COL[g.type] ?? 0xffffff, ); (m.material as THREE.MeshBasicMaterial).opacity = 0.5; m.userData.gid = g.gid ?? null; @@ -2668,7 +2668,7 @@ onMounted(() => { sa.geometry.dispose(); sa.geometry = new THREE.TubeGeometry(curve, 48, 9 * U + 2, 8, false); sa.visible = true; - const hex = NADE_COL[u.type] ?? 0xffffff; + const hex = UTILITY_COL[u.type] ?? 0xffffff; const m = sa.material as THREE.MeshStandardMaterial; m.color.setHex(hex); m.emissive.setHex(hex); diff --git a/components/match/ReplayChrome.vue b/components/match/ReplayChrome.vue index e8457fba6..9b19044b4 100644 --- a/components/match/ReplayChrome.vue +++ b/components/match/ReplayChrome.vue @@ -35,7 +35,7 @@ type Row = { a: number; dmg: number; weapon: string | null; - nades: string[]; + utility: string[]; bomb: boolean; kit: boolean; avatarUrl: string | null; @@ -299,7 +299,7 @@ function onCeilScrubUp() { window.removeEventListener("pointercancel", onCeilScrubUp); } const utilClusters = computed(() => { - // Respect the util-type filter so toggling a type also drops its nade icons + // Respect the util-type filter so toggling a type also drops its utility icons // from the seek bar (2D + 3D), not just the map. const sorted = [...(props.utilMarkers || [])] .filter((m) => props.typeFilter?.[(m as any).type] !== false) @@ -736,10 +736,10 @@ const utilClusters = computed(() => { @@ -1693,7 +1693,7 @@ const utilClusters = computed(() => { .eq.gun { height: 20px; } -.eq.nade { +.eq.utility { height: 16px; } .eq.kit { @@ -2127,8 +2127,8 @@ const utilClusters = computed(() => { gap: 4px; } /* On touch the seek bar keeps its normal height (so the transport row stays - vertically centered), and the nade lane drops into reserved space BELOW it - via padding — a finger dragging the bar never lands on a nade. Desktop keeps + vertically centered), and the utility lane drops into reserved space BELOW it + via padding — a finger dragging the bar never lands on a utility. Desktop keeps them overlaid since a mouse can thread between them. */ .bp-chrome.is-mobile .seek-wrap { height: 38px; diff --git a/components/match/ReplayViewer.vue b/components/match/ReplayViewer.vue index 79c5a832e..479cc95b3 100644 --- a/components/match/ReplayViewer.vue +++ b/components/match/ReplayViewer.vue @@ -75,6 +75,10 @@ import { } from "~/components/ui/popover"; import ReplayLineupTeam from "~/components/match/ReplayLineupTeam.vue"; import RoundSelector from "~/components/match/RoundSelector.vue"; +import { + RADAR_CANVAS, + useRadarProjection, +} from "~/composables/useRadarProjection"; import Replay3DLite from "~/components/match/Replay3DLite.vue"; import ReplayChrome from "~/components/match/ReplayChrome.vue"; @@ -226,16 +230,6 @@ type Damage = { health: number; }; -type MapSplit = { - bounds: { top: number; bottom: number }; - offset: { x: number; y: number }; -}; -type RadarMeta = { - resolution: number; - offset: { x: number; y: number }; - splits?: MapSplit[]; -}; - type DemoPlayer = { steam_id: string; name: string }; const props = defineProps<{ @@ -281,26 +275,12 @@ const { t } = useI18n(); const ROUND_TIME_SEC = 115; const BOMB_TIMER_SEC = 40; -const calibrations = ref | null>(null); const radarFailed = ref(false); // 2D top-down board vs lightweight 3D perspective (radar plane + Z-lifted // entities, no map geometry). Toggling preserves all playback state. const viewMode = ref<"2d" | "3d">(props.initialView ?? "2d"); -onMounted(async () => { - try { - const res = await fetch("/radars/metadata.json"); - if (res.ok) { - const data = await res.json(); - const { _comment, ...rest } = data; - calibrations.value = rest as Record; - } - } catch { - /* metadata absent — fall back to auto-fit */ - } -}); - const normalizedMap = computed(() => (props.mapName || "") .trim() @@ -308,16 +288,10 @@ const normalizedMap = computed(() => .replace(/_night$/, ""), ); -const calibration = computed(() => { - if (!calibrations.value || !normalizedMap.value) return null; - return calibrations.value[normalizedMap.value] ?? null; -}); - -const radarSrc = computed(() => { - if (!calibration.value || !normalizedMap.value || radarFailed.value) - return null; - return `/radars/${normalizedMap.value}.png`; -}); +const { calibration, radarSrc, projectCalibrated } = useRadarProjection( + normalizedMap, + { radarFailed }, +); // Lightweight collision mesh (awpy .tri) for 3D-lite, served from the CDN // (config.public.mapMeshCdn, build-tag pinned + Brotli'd). The 3D renderer @@ -689,14 +663,14 @@ const currentTick = computed(() => ticks.value[tickIndex.value] ?? 0); const SCRUB_KILL_CT = "rgb(56,189,248)"; const SCRUB_KILL_T = "rgb(251,191,36)"; const SCRUB_KILL_NEUTRAL = "rgb(248,113,113)"; -const SCRUB_NADE_COLORS: Record = { +const SCRUB_UTILITY_COLORS: Record = { HE: "rgb(239,68,68)", Molotov: "rgb(249,115,22)", Smoke: "rgb(148,163,184)", Flash: "rgb(250,204,21)", Decoy: "rgb(34,211,238)", }; -const NADE_SCRUB_ICON: Record = { +const UTILITY_SCRUB_ICON: Record = { Smoke: "/img/equipment/smokegrenade.svg", Molotov: "/img/equipment/molotov.svg", HE: "/img/equipment/hegrenade.svg", @@ -706,7 +680,7 @@ const NADE_SCRUB_ICON: Record = { const scrubberMarkers = computed< Array<{ left: number; - lane: "kill" | "nade" | "bomb"; + lane: "kill" | "utility" | "bomb"; color: string; title: string; icon?: string; @@ -721,7 +695,7 @@ const scrubberMarkers = computed< } const out: Array<{ left: number; - lane: "kill" | "nade" | "bomb"; + lane: "kill" | "utility" | "bomb"; color: string; title: string; icon?: string; @@ -754,10 +728,10 @@ const scrubberMarkers = computed< } out.push({ left: Math.max(0, Math.min(100, left)), - lane: "nade", - color: SCRUB_NADE_COLORS[g.type] ?? "rgb(148,163,184)", + lane: "utility", + color: SCRUB_UTILITY_COLORS[g.type] ?? "rgb(148,163,184)", title: g.type, - icon: NADE_SCRUB_ICON[g.type], + icon: UTILITY_SCRUB_ICON[g.type], gid: g.grenade_id ?? undefined, }); } @@ -1926,32 +1900,14 @@ const bounds = computed(() => { }; }); -const CANVAS = 1024; -const RADAR_PX = 1024; - -function applySplit(z: number, splits: MapSplit[] | undefined) { - if (!splits) return { dx: 0, dy: 0 }; - for (const s of splits) { - if (z > s.bounds.bottom && z < s.bounds.top) { - return { dx: s.offset.x, dy: s.offset.y }; - } - } - return { dx: 0, dy: 0 }; -} +const CANVAS = RADAR_CANVAS; function projectRaw(p: { x: number; y: number; z?: number }) { - if (calibration.value) { - const { resolution, offset, splits } = calibration.value; - const split = applySplit(p.z ?? 0, splits); - const gameX = p.x + offset.x; - const gameY = p.y + offset.y; - const pxX = gameX / resolution + (split.dx / 100) * RADAR_PX; - const pxYFromBottom = gameY / resolution + (split.dy / 100) * RADAR_PX; - return { - x: pxX * (CANVAS / RADAR_PX), - y: CANVAS - pxYFromBottom * (CANVAS / RADAR_PX), - }; + const projected = projectCalibrated(p); + if (projected) { + return projected; } + // No calibration for this map: auto-fit to the bbox of everything sampled. const b = bounds.value; const w = b.maxX - b.minX || 1; const h = b.maxY - b.minY || 1; @@ -2637,7 +2593,7 @@ onMounted(() => { const compact = mobileChrome.value && !isTablet.value; chromeScoreboardOpen.value = !compact; if (compact) showPbpPanel.value = false; - // On touch, start with only smokes shown — the full nade set clutters the + // On touch, start with only smokes shown — the full utility set clutters the // smaller map; the rest are one tap away in the util filters. if (mobileChrome.value) { utilTypeFilter.value = { @@ -3873,7 +3829,7 @@ const utilTypeFilter = ref>({ const CT_HEX = "hsl(210, 80%, 60%)"; const T_HEX = "hsl(33, 94%, 58%)"; -function nadeArray(lo: RoundInventoryEntry | undefined): string[] { +function utilityArray(lo: RoundInventoryEntry | undefined): string[] { if (!lo) return []; const out: string[] = []; for (let i = 0; i < (lo.flash ?? 0); i++) out.push("flash"); @@ -3913,7 +3869,7 @@ function buildChromeRows(rows: RosterEntry[], side: number) { a: st.a, dmg: st.dmg, weapon: lo?.primary || lo?.secondary || null, - nades: nadeArray(lo), + utility: utilityArray(lo), bomb: hasBombFor(r.steamId), kit: lo?.kit ?? false, avatarUrl: r.avatarUrl, @@ -3986,7 +3942,7 @@ const chromeUtilMarkers = computed(() => { for (const u of roundUtilities.value) if (u.gid != null) nameByGid.set(u.gid, u.name); return scrubberMarkers.value - .filter((m) => m.lane === "nade" && m.icon) + .filter((m) => m.lane === "utility" && m.icon) .map((m) => ({ frac: m.left / 100, icon: m.icon as string, @@ -4667,7 +4623,7 @@ watch(overlayMode, (on) => { :cx="project({ x: g.rx, y: g.ry, z: g.rz }).x" :cy="project({ x: g.rx, y: g.ry, z: g.rz }).y" r="16" - :fill="SCRUB_NADE_COLORS[g.type] || 'rgb(148,163,184)'" + :fill="SCRUB_UTILITY_COLORS[g.type] || 'rgb(148,163,184)'" :fill-opacity=" g.gid != null && selectedGi.includes(g.gid) ? 0.85 : 0.4 " @@ -6255,7 +6211,7 @@ watch(overlayMode, (on) => { >
- +
- - - {{ $t("server.form.game") }} - - -
- -
- -
-
-
- -
- -
-
-
-
- -
-
- - + {{ $t("server.form.type") }} - +
@@ -115,7 +70,7 @@ const showConnectPassword = ref(false); ? 'cursor-not-allowed' : 'cursor-pointer' " - for="mode-ranked" + for="kind-ranked" > {{ $t("server.form.ranked_server") }} @@ -125,32 +80,182 @@ const showConnectPassword = ref(false);
- +

- {{ $t("server.form.valve_presets_description") }} + {{ $t("server.form.practice_server_description") }}

+
+ +
+ +

+ {{ $t("server.form.valve_modes_description") }} +

+
+
+
+ +
+ +

+ {{ $t("server.form.custom_presets_description") }} +

+
+
+ + + + + + + + + +
+ {{ + $t("server.form.server_configuration") + }} + + {{ + useGameServerNode + ? $t("server.form.use_game_server_node") + : $t("server.form.use_manual_host_configuration") + }} + +
+ + + +
+
+
+ + +
+ + + {{ $t("server.form.region") }} + + + + +
+
+ +
+ + +
+ + + {{ $t("server.form.game") }} + + +
+ +
+ +
+
+
+ +
+ +
+
+ @@ -164,57 +269,51 @@ const showConnectPassword = ref(false); - - - - {{ $t("server.form.game_mode") }} - - - - + + + + +
- - - -
- {{ - $t("server.form.server_configuration") - }} - - {{ - useGameServerNode - ? $t("server.form.use_game_server_node") - : $t("server.form.use_manual_host_configuration") - }} - -
- - - -
-
-
-
- - - {{ $t("server.form.region") }} - - - -
@@ -435,13 +480,13 @@ const showConnectPassword = ref(false);
- +
{{ $t("server.form.connect_password") }} @@ -475,7 +520,7 @@ const showConnectPassword = ref(false); {{ $t("server.form.max_players") }} @@ -521,9 +566,18 @@ import { generateMutation, generateQuery } from "~/graphql/graphqlGen"; import { typedGql } from "~/generated/zeus/typedDocumentNode"; import { order_by } from "~/generated/zeus"; import { e_server_types_enum } from "~/generated/zeus"; + import { toast } from "@/components/ui/toast"; import { useApplicationSettingsStore } from "~/stores/ApplicationSettings"; +// Literals rather than the generated enum: a type added by a migration is +// absent from ~/generated/zeus until codegen runs against a migrated database, +// and a server holding one must not read as a custom mode id -- dropStaleMode +// would rewrite it to a preset and save that. +const SERVER_TYPE_RANKED = "Ranked"; +const SERVER_TYPE_PRACTICE = "Practice"; +const SERVER_TYPE_CUSTOM = "Custom"; + export default { emits: ["updated"], props: { @@ -628,7 +682,7 @@ export default { region: z.string().optional(), use_game_server_node: z.boolean().default(false), game_server_node_id: z.string().optional(), - type: z.string().default(e_server_types_enum.Ranked), + type: z.string().default(SERVER_TYPE_RANKED), connect_password: z.string().optional(), port: z.number().min(2).max(65535).default(27015).optional(), tv_port: z.number().min(2).max(65535).default(27020).optional(), @@ -685,6 +739,7 @@ export default { "form.values.game": { handler(newGame) { if (newGame === "csgo" && !this.form.values.use_valve_modes) { + this.form.setFieldValue("type", this.valveModeTypes[0]); this.form.setFieldValue("use_valve_modes", true); } }, @@ -692,14 +747,24 @@ export default { "form.values.use_valve_modes": { immediate: true, handler(newValue) { + const selected = this.form.values.type; + // Ranked and Practice are the two that run no Valve preset, so they + // are the two this watcher must leave alone in either direction. + const runsNoPreset = + selected === SERVER_TYPE_RANKED || + selected === SERVER_TYPE_PRACTICE; + if (!newValue) { - this.form.setFieldValue("type", e_server_types_enum.Ranked); + if (!runsNoPreset) { + this.form.setFieldValue("type", SERVER_TYPE_RANKED); + } return; } - if (newValue && this.form.values.type === e_server_types_enum.Ranked) { - const firstNonRanked = this.valveModeTypes[0]; - if (firstNonRanked) { - this.form.setFieldValue("type", firstNonRanked); + + if (runsNoPreset) { + const firstPreset = this.valveModeTypes[0]; + if (firstPreset) { + this.form.setFieldValue("type", firstPreset); } } }, @@ -741,8 +806,8 @@ export default { }, }, computed: { - isPublicServerType() { - return this.form.values.type !== "Ranked"; + isManagedRankedServer() { + return this.form.values.type === SERVER_TYPE_RANKED; }, useGameServerNode() { return this.form.values.use_game_server_node; @@ -750,11 +815,40 @@ export default { serverTypes() { return Object.values(e_server_types_enum); }, + knownServerTypes(): Array { + return Array.from( + new Set([ + ...Object.values(e_server_types_enum), + SERVER_TYPE_RANKED, + SERVER_TYPE_PRACTICE, + SERVER_TYPE_CUSTOM, + ]), + ); + }, valveModeTypes() { - return Object.values(e_server_types_enum).filter( - (t) => t !== e_server_types_enum.Ranked, + return this.knownServerTypes.filter( + (t) => + t !== SERVER_TYPE_RANKED && + t !== SERVER_TYPE_PRACTICE && + t !== SERVER_TYPE_CUSTOM, ); }, + // Which of the four top-level choices the current `type` represents. The + // field itself still holds either an enum value or a mode uuid; this is + // only how the radios read it back. + serverKind(): string { + const selected = this.form.values.type; + if (selected === SERVER_TYPE_RANKED) { + return "ranked"; + } + if (selected === SERVER_TYPE_PRACTICE) { + return "practice"; + } + if (this.holdsModeId || selected === SERVER_TYPE_CUSTOM) { + return "presets"; + } + return "valve"; + }, // Custom game modes share the picker with the Valve presets: both answer // "what does this server play". A preset is stored in servers.type, a mode // in servers.game_mode_id, and the mode's uuid never collides with an enum @@ -786,7 +880,7 @@ export default { // separate question -- see isCustomModeSelected. holdsModeId(): boolean { const selected = this.form.values.type; - return !!selected && !Object.values(e_server_types_enum).includes(selected); + return !!selected && !this.knownServerTypes.includes(selected); }, isCustomModeSelected(): boolean { const selected = this.form.values.type; @@ -813,13 +907,42 @@ export default { resolveTypeAndMode(): { type: string; game_mode_id: string | null } { const selected = this.form.values.type; if (this.isCustomModeSelected || (this.holdsModeId && !this.modesKnown)) { - return { type: e_server_types_enum.Custom, game_mode_id: selected }; + return { type: SERVER_TYPE_CUSTOM, game_mode_id: selected }; } if (this.holdsModeId) { return { type: this.valveModeTypes[0], game_mode_id: null }; } return { type: selected || "Ranked", game_mode_id: null }; }, + setServerKind(kind: string) { + if (kind === "ranked") { + this.form.setFieldValue("use_valve_modes", false); + this.form.setFieldValue("type", SERVER_TYPE_RANKED); + return; + } + + if (kind === "practice") { + this.form.setFieldValue("use_valve_modes", false); + this.form.setFieldValue("type", SERVER_TYPE_PRACTICE); + return; + } + + this.form.setFieldValue("use_valve_modes", true); + + if (kind === "valve") { + if (!this.valveModeTypes.includes(this.form.values.type)) { + this.form.setFieldValue("type", this.valveModeTypes[0]); + } + return; + } + + if (!this.isCustomModeSelected) { + this.form.setFieldValue( + "type", + this.customModes[0]?.id ?? SERVER_TYPE_CUSTOM, + ); + } + }, dropStaleMode() { if (this.modesKnown && this.holdsModeId && !this.isCustomModeSelected) { this.form.setFieldValue("type", this.valveModeTypes[0]); @@ -845,7 +968,8 @@ export default { region, tv_port, game: server.game || "cs2", - use_valve_modes: type !== e_server_types_enum.Ranked, + use_valve_modes: + type !== SERVER_TYPE_RANKED && type !== SERVER_TYPE_PRACTICE, use_game_server_node: !!game_server_node_id, game_server_node_id: game_server_node_id ? game_server_node_id.toString() diff --git a/components/settings/ApplicationSettingsShell.vue b/components/settings/ApplicationSettingsShell.vue index 7461df7a9..d9c471dfa 100644 --- a/components/settings/ApplicationSettingsShell.vue +++ b/components/settings/ApplicationSettingsShell.vue @@ -50,73 +50,91 @@ useScrollIntoViewOnChange(contentRow, () => route.path); diff --git a/components/settings/ProfileSettingsShell.vue b/components/settings/ProfileSettingsShell.vue index 223a46cc2..cd7472ced 100644 --- a/components/settings/ProfileSettingsShell.vue +++ b/components/settings/ProfileSettingsShell.vue @@ -95,6 +95,20 @@ const contentRow = ref(null); useScrollIntoViewOnChange(contentRow, () => route.path); +// The content pane owns its own scroll at lg, so a section switch has to be +// sent back to the top by hand -- unlike the application shell, this pane is +// not keyed per route. +const contentPane = ref(null); + +watch( + () => route.path, + () => { + if (contentPane.value) { + contentPane.value.scrollTop = 0; + } + }, +); + function linkDiscord() { if (hasDiscordLinked.value) return; window.location.href = `https://${useRuntimeConfig().public.webDomain}/auth/discord?redirect=${encodeURIComponent(window.location.toString())}`; @@ -123,73 +137,84 @@ async function unlinkDiscord() { diff --git a/components/tournament/TournamentFeatureCard.vue b/components/tournament/TournamentFeatureCard.vue index 1b1be74fd..a0bab7a04 100644 --- a/components/tournament/TournamentFeatureCard.vue +++ b/components/tournament/TournamentFeatureCard.vue @@ -15,10 +15,17 @@ const props = withDefaults( statusLabel?: string; statusVariant?: TournamentStatusVariant; tournament: any; + // Set on the first card in a list. Its banner is the LCP element on + // /watch, and it cannot start downloading until the page's GraphQL has + // resolved -- measured at 4149ms, with LCP landing at 4876ms. Left at the + // browser's own guess it gets Low priority and queues behind whatever else + // the render kicked off. Every other card is below the fold and lazies. + priority?: boolean; }>(), { statusLabel: undefined, statusVariant: "default", + priority: false, }, ); @@ -127,6 +134,9 @@ const statusChipClasses = computed(() => { :src="bannerUrl" :alt="tournament.name" aria-hidden="true" + :loading="priority ? 'eager' : 'lazy'" + :fetchpriority="priority ? 'high' : 'auto'" + decoding="async" class="absolute inset-0 h-full w-full object-cover object-center transition-transform duration-500 group-hover/tournament:scale-105" /> diff --git a/components/tournament/TournamentJoinForm.vue b/components/tournament/TournamentJoinForm.vue index 59316eb1b..d085710c7 100644 --- a/components/tournament/TournamentJoinForm.vue +++ b/components/tournament/TournamentJoinForm.vue @@ -548,9 +548,10 @@ export default { tournament_id: this.$route.params.tournamentId, name: teamName, short_name: this.form.values.new_team ? shortName : null, - ...(this.tournament.is_organizer && addPlayerSteamId - ? { owner_steam_id: addPlayerSteamId } - : {}), + // `owner_steam_id` is a Hasura session preset on insert and + // must never be submitted from here. The player an organizer + // picks still reaches the team as its captain and its first + // roster row; only the owner column is the caller. ...(captainSteamId ? { captain_steam_id: captainSteamId } : {}), diff --git a/components/ui/transitions/HeightSwap.vue b/components/ui/transitions/HeightSwap.vue index 79bd9e20d..107d19f75 100644 --- a/components/ui/transitions/HeightSwap.vue +++ b/components/ui/transitions/HeightSwap.vue @@ -1,5 +1,5 @@ + + diff --git a/components/utility/DeleteRenderDialog.vue b/components/utility/DeleteRenderDialog.vue new file mode 100644 index 000000000..417a0ffec --- /dev/null +++ b/components/utility/DeleteRenderDialog.vue @@ -0,0 +1,104 @@ + + + diff --git a/components/utility/StartPracticeDialog.vue b/components/utility/StartPracticeDialog.vue new file mode 100644 index 000000000..29cf977b0 --- /dev/null +++ b/components/utility/StartPracticeDialog.vue @@ -0,0 +1,735 @@ + + + diff --git a/components/utility/UtilityArchiveDialog.vue b/components/utility/UtilityArchiveDialog.vue new file mode 100644 index 000000000..8d1364e88 --- /dev/null +++ b/components/utility/UtilityArchiveDialog.vue @@ -0,0 +1,136 @@ + + + diff --git a/components/utility/UtilityBlockPanel.vue b/components/utility/UtilityBlockPanel.vue new file mode 100644 index 000000000..df0f0172c --- /dev/null +++ b/components/utility/UtilityBlockPanel.vue @@ -0,0 +1,454 @@ + + + diff --git a/components/utility/UtilityCalibrationGate.vue b/components/utility/UtilityCalibrationGate.vue new file mode 100644 index 000000000..fbdb1cfee --- /dev/null +++ b/components/utility/UtilityCalibrationGate.vue @@ -0,0 +1,71 @@ + + + diff --git a/components/utility/UtilityCollectionPicker.vue b/components/utility/UtilityCollectionPicker.vue new file mode 100644 index 000000000..824f4e9fc --- /dev/null +++ b/components/utility/UtilityCollectionPicker.vue @@ -0,0 +1,285 @@ + + + diff --git a/components/utility/UtilityCollectionsPanel.vue b/components/utility/UtilityCollectionsPanel.vue new file mode 100644 index 000000000..a09b827f6 --- /dev/null +++ b/components/utility/UtilityCollectionsPanel.vue @@ -0,0 +1,324 @@ + + + diff --git a/components/utility/UtilityConfidenceNote.vue b/components/utility/UtilityConfidenceNote.vue new file mode 100644 index 000000000..0fb35d440 --- /dev/null +++ b/components/utility/UtilityConfidenceNote.vue @@ -0,0 +1,121 @@ + + + diff --git a/components/utility/UtilityCreatePanel.vue b/components/utility/UtilityCreatePanel.vue new file mode 100644 index 000000000..d48429061 --- /dev/null +++ b/components/utility/UtilityCreatePanel.vue @@ -0,0 +1,982 @@ + + + +