Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cloudflare-workers/backblaze-proxy/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ routes = [
{ pattern = "demo-dl.5stack.gg/demo*", zone_name = "5stack.gg" },
{ pattern = "demo-dl.5stack.gg/clips*", zone_name = "5stack.gg" },
{ pattern = "demo-dl.5stack.gg/news*", zone_name = "5stack.gg" },
# Map assets: collision meshes (.tri) and callouts (.callouts.json), keyed
# maps/<cs2 build>/<map>.*. Off jsDelivr because its ~20MiB per-file cap
# forced heavy decimation; the build id in the path keeps every URL immutable,
# which is what the worker's max-age=2592000 assumes.
{ pattern = "demo-dl.5stack.gg/maps*", zone_name = "5stack.gg" },
]

# Secrets (S3_ACCESS_KEY, S3_SECRET):
Expand Down
3 changes: 2 additions & 1 deletion components/common/MeshAvailability.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { ref, onMounted } from "vue";
import { MESH_EXT } from "~/utilities/mapAssets";
import { useApolloClient } from "@vue/apollo-composable";
import gql from "graphql-tag";
import { Boxes, Check, X } from "lucide-vue-next";
Expand Down Expand Up @@ -39,7 +40,7 @@ async function probe(name: string): Promise<boolean | null> {
return null;
}
try {
const res = await fetch(`${meshCdn}/${name}.tri`, { method: "HEAD" });
const res = await fetch(`${meshCdn}/${name}${MESH_EXT}`, { method: "HEAD" });
if (res.status === 404) {
return false;
}
Expand Down
130 changes: 130 additions & 0 deletions components/common/RadarCallouts.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed } from "vue";
import { humanizeCallout } from "~/utilities/mapCallouts";
import type { MapCallout } from "~/utilities/mapCallouts";

const props = withDefaults(
defineProps<{
callouts: MapCallout[];
project: (p: {
x: number;
y: number;
z?: number;
}) => { x: number; y: number } | null;
zoom?: number;
labels?: boolean;
}>(),
{
zoom: 1,
labels: true,
},
);

// Under this many square radar pixels a label is unreadable and the box it sits
// in is a smudge, so the area keeps its outline and loses its name.
const MIN_LABEL_AREA = 900;

type DrawnBox = { x: number; y: number; w: number; h: number };
type DrawnCallout = {
name: string;
label: string;
boxes: DrawnBox[];
labelAt: { x: number; y: number } | null;
};

const drawn = computed<DrawnCallout[]>(() => {
const out: DrawnCallout[] = [];

for (const callout of props.callouts ?? []) {
const boxes: DrawnBox[] = [];
let widest: DrawnBox | null = null;

for (const box of callout.boxes ?? []) {
// Each box is projected at its OWN centre height, not the board's: on
// Nuke and Vertigo the projection reads Z to decide which level's inset a
// point belongs in, so a shared height would stack both floors.
const z = (box.min[2] + box.max[2]) / 2;
const corners = [
props.project({ x: box.min[0], y: box.min[1], z }),
props.project({ x: box.max[0], y: box.min[1], z }),
props.project({ x: box.max[0], y: box.max[1], z }),
props.project({ x: box.min[0], y: box.max[1], z }),
];
if (corners.some((c) => !c)) {
continue;
}

const xs = corners.map((c) => c!.x);
const ys = corners.map((c) => c!.y);
const drawnBox = {
x: Math.min(...xs),
y: Math.min(...ys),
w: Math.max(...xs) - Math.min(...xs),
h: Math.max(...ys) - Math.min(...ys),
};
boxes.push(drawnBox);
if (!widest || drawnBox.w * drawnBox.h > widest.w * widest.h) {
widest = drawnBox;
}
}

if (!boxes.length || !widest) {
continue;
}

out.push({
name: callout.name,
label: humanizeCallout(callout.name),
boxes,
labelAt:
widest.w * widest.h >= MIN_LABEL_AREA
? { x: widest.x + widest.w / 2, y: widest.y + widest.h / 2 }
: null,
});
}

return out;
});

// Held constant on screen while the board zooms -- a label that scales with the
// map is either illegible zoomed out or enormous zoomed in.
const strokeWidth = computed(() => 1.25 / (props.zoom || 1));
const fontSize = computed(() => 14 / (props.zoom || 1));
</script>

<template>
<g class="pointer-events-none">
<g v-for="callout of drawn" :key="callout.name">
<rect
v-for="(box, index) of callout.boxes"
:key="index"
:x="box.x"
:y="box.y"
:width="box.w"
:height="box.h"
fill="hsl(var(--tac-amber) / 0.05)"
stroke="hsl(var(--tac-amber) / 0.35)"
:stroke-width="strokeWidth"
stroke-dasharray="6 5"
rx="3"
/>
<text
v-if="labels && callout.labelAt"
:x="callout.labelAt.x"
:y="callout.labelAt.y"
text-anchor="middle"
dominant-baseline="middle"
:font-size="fontSize"
font-family="Oxanium, system-ui, sans-serif"
font-weight="600"
letter-spacing="0.08em"
fill="hsl(var(--tac-amber) / 0.85)"
stroke="hsl(var(--background))"
:stroke-width="fontSize / 4"
paint-order="stroke"
>
{{ callout.label.toUpperCase() }}
</text>
</g>
</g>
</template>
41 changes: 35 additions & 6 deletions components/match/MatchUtilityUtility.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
fetchReplayBlob,
normalizeBlobGrenades,
} from "~/composables/useReplayBlob";
import { useMapCallouts } from "~/composables/useMapCallouts";
import { useAuthStore } from "~/stores/AuthStore";
import cleanMapName from "~/utilities/cleanMapName";
import {
Expand Down Expand Up @@ -188,6 +189,9 @@ type MatchGrenadeRow = {
rawType: string;
/** Paired with a detonation in the same demo, by grenade id. */
landed: boolean;
/** Kept so the map can name the throw; the blob is the only place they exist. */
origin: { x: number; y: number; z: number } | null;
landing: { x: number; y: number; z: number } | null;
};

const matchMaps = computed<any[]>(() => props.match?.match_maps ?? []);
Expand Down Expand Up @@ -269,10 +273,14 @@ async function loadGrenades() {
return;
}
const grenades = normalizeBlobGrenades(blob?.grenade_throws ?? []);
const detonated = new Set<number>();
const detonated = new Map<number, { x: number; y: number; z: number }>();
for (const grenade of grenades) {
if (grenade.phase === "detonated" && grenade.grenade_id != null) {
detonated.add(Number(grenade.grenade_id));
detonated.set(Number(grenade.grenade_id), {
x: Number(grenade.x ?? 0),
y: Number(grenade.y ?? 0),
z: Number(grenade.z ?? 0),
});
}
}
const rows: MatchGrenadeRow[] = [];
Expand All @@ -297,6 +305,15 @@ async function loadGrenades() {
utilityType: canonicalUtilityType(rawType),
rawType,
landed: detonated.has(grenadeId),
// The blob flattens a throw's ox/oy/oz into x/y/z (mapGrenade in the
// API's demo metadata service), so a "thrown" row's position IS the
// origin -- there is no ox on the wire.
origin: {
x: Number(grenade.x ?? 0),
y: Number(grenade.y ?? 0),
z: Number(grenade.z ?? 0),
},
landing: detonated.get(grenadeId) ?? null,
});
}
rows.sort((a, b) => a.round - b.round || a.grenadeId - b.grenadeId);
Expand Down Expand Up @@ -375,15 +392,27 @@ function onSaved(id: string) {
void load();
}

const { autoName } = useMapCallouts(
() => activeMatchMap.value?.map?.name ?? null,
);

const saveDefaultName = computed(() => {
const row = saveTarget.value;
if (!row) {
return "";
}
return t("match.utility.default_lineup_name", {
utility: grenadeTypeLabel(row),
round: row.round,
});
// Where it went and where it came from, when the map can say. "Smoke, round
// 7" only ever told you when it was thrown.
const named = row.utilityType
? autoName(row.utilityType, row.origin, row.landing)
: "";
return (
named ||
t("match.utility.default_lineup_name", {
utility: grenadeTypeLabel(row),
round: row.round,
})
);
});

const canSaveLineups = computed(() => !!mySteamId.value);
Expand Down
7 changes: 2 additions & 5 deletions components/match/Replay3DLite.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
import { fetchMeshBuffer } from "~/utilities/mapAssets";
import { useI18n } from "vue-i18n";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
Expand Down Expand Up @@ -537,11 +538,7 @@ onMounted(() => {
if (meshMode) {
status.value = t("match.replay.loading_map");
loading.value = true;
fetch(props.mapMeshUrl!)
.then((r) => {
if (!r.ok) throw new Error(String(r.status));
return r.arrayBuffer();
})
fetchMeshBuffer(props.mapMeshUrl!)
.then((buf) => {
// sanity cap: our decimated meshes are well under this; guards against a
// malformed/oversized file allocating a huge BufferGeometry and OOMing.
Expand Down
19 changes: 18 additions & 1 deletion components/match/ReplayChrome.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// ONE player looks identical in 2D and 3D. Purely presentational: it renders
// HUD / kill feed / play-by-play / scoreboard / transport over whatever map
// the host (ReplayViewer) shows underneath. All data + actions come via props.
import { Skull } from "lucide-vue-next";
import { Skull, Tags } from "lucide-vue-next";
import AnimatedStat from "~/components/AnimatedStat.vue";
import { weaponIconPath } from "~/utilities/weaponIcon";
import Kbd from "~/components/ui/kbd/Kbd.vue";
Expand Down Expand Up @@ -120,6 +120,8 @@ const props = defineProps<{
showAvatars?: boolean;
traceOn?: boolean;
showDeaths?: boolean;
showCallouts?: boolean;
hasCallouts?: boolean;
// mobile: compact the floating panels for phone-sized touch screens
mobile?: boolean;
// scoreboard visibility — collapsible so there's room to move around the map
Expand All @@ -145,6 +147,7 @@ const props = defineProps<{
onToggleAvatars?: () => void;
onToggleTrace?: () => void;
onToggleDeaths?: () => void;
onToggleCallouts?: () => void;
}>();

const UTIL_TYPES = ["Smoke", "Molotov", "HE", "Flash", "Decoy"] as const;
Expand Down Expand Up @@ -857,6 +860,20 @@ const utilClusters = computed(() => {
$t("match.replay.chrome.deaths_tip")
}}</TooltipContent>
</Tooltip>
<Tooltip v-if="view === '2d' && hasCallouts">
<TooltipTrigger as-child>
<button
class="sbt sbt-icon"
:class="{ on: showCallouts }"
@click="onToggleCallouts && onToggleCallouts()"
>
<Tags :size="15" />
</button>
</TooltipTrigger>
<TooltipContent>{{
$t("match.replay.chrome.callouts_tip")
}}</TooltipContent>
</Tooltip>
</div>

<!-- transport -->
Expand Down
20 changes: 19 additions & 1 deletion components/match/ReplayViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ import {
} from "~/components/ui/popover";
import ReplayLineupTeam from "~/components/match/ReplayLineupTeam.vue";
import RoundSelector from "~/components/match/RoundSelector.vue";
import RadarCallouts from "~/components/common/RadarCallouts.vue";
import { meshUrlForMap } from "~/utilities/mapAssets";
import { useMapCallouts } from "~/composables/useMapCallouts";
import {
RADAR_CANVAS,
useRadarProjection,
Expand Down Expand Up @@ -298,7 +301,7 @@ const { calibration, radarSrc, projectCalibrated } = useRadarProjection(
// falls back to the flat radar plane when this 404s (map not yet generated).
const meshCdn = useRuntimeConfig().public.mapMeshCdn;
const mapMeshUrl = computed(() =>
normalizedMap.value ? `${meshCdn}/${normalizedMap.value}.tri` : null,
meshUrlForMap(meshCdn as string, normalizedMap.value ?? ""),
);

// Per-map ceiling boost (source-z units) added to the auto-detected ceiling, as
Expand Down Expand Up @@ -2341,10 +2344,14 @@ function persistedBool(key: string, defaultValue: boolean) {
});
return r;
}
const { callouts: mapCallouts, hasCallouts } = useMapCallouts(normalizedMap);
const showC4 = persistedBool("5s.replay.show_c4", true);
const showDefuser = persistedBool("5s.replay.show_defuser", true);
const showGroundBomb = persistedBool("5s.replay.show_ground_bomb", true);
const showGroundKits = persistedBool("5s.replay.show_ground_kits", true);
// The map's own vocabulary, off by default: it is a reference layer, and the
// board is busy enough during a round without forty labelled boxes under it.
const showCallouts = persistedBool("5s.replay.show_callouts", false);
// Pathing always starts off — it's a heavy overlay, not a sticky preference.
const pathingMode = ref<PathingMode>("off");
// The Route button is a simple on/off for the live-tracing "progress" mode;
Expand Down Expand Up @@ -4075,6 +4082,14 @@ watch(overlayMode, (on) => {
class="absolute inset-0 w-full h-full"
preserveAspectRatio="xMidYMid meet"
>
<!-- Under everything: the callouts say where the round is happening,
they are not part of what happens in it. -->
<RadarCallouts
v-if="showCallouts"
:callouts="mapCallouts"
:project="projectCalibrated"
:zoom="zoom2d"
/>
<defs>
<!-- Volumetric smoke filter: turbulence-displaced edges that
slowly evolve. The animated baseFrequency on the
Expand Down Expand Up @@ -5579,6 +5594,9 @@ watch(overlayMode, (on) => {
:on-toggle-avatars="() => (showAvatars = !showAvatars)"
:on-toggle-trace="togglePathing"
:on-toggle-deaths="() => (showDeaths = !showDeaths)"
:show-callouts="showCallouts"
:has-callouts="hasCallouts"
:on-toggle-callouts="() => (showCallouts = !showCallouts)"
/>

<!-- Rotate gate: the stage is unusable at phone-portrait widths, so we
Expand Down
7 changes: 6 additions & 1 deletion components/system-telemetry/FleetReadout.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
<template>
<div class="flex h-full flex-col">
<!-- No h-full. Grid and flex parents already stretch a readout to its own
row, and `height: 100%` measures the whole container instead: in a
flex-wrap row it resolves to every line's height, so a readout on the
second line runs a full line past the bottom of the card and drops its
value into whatever section follows. -->
<div class="flex flex-col">
<!-- flex-1, not a min-height: these sit in a row and their labels wrap to
different line counts, so the label takes the slack and every value
starts at the same baseline no matter how many lines it took. -->
Expand Down
Loading