PUBG drop map: live squad map with WebSocket rooms
All checks were successful
Deploy production / deploy (push) Successful in 1m15s

- server.js wraps Bun.serve to add /ws/<channel> upgrades (same server and port) and
  serve the map tiles from the host mount; Dockerfile now runs bun ./server.js
- ws hub (channel registry, origin check) + pubg-map channel: rooms by unguessable
  code, validated + rate-limited messages, per-room write queue, presence
- games.pubg_rooms / pubg_matches / pubg_markers; per-match sessions with history
- /trackers/games/pubg/map hub (owner: rooms, landings over time, history) and
  /trackers/games/pubg/map/<code> fullscreen Leaflet map (all 11 maps)
- deploy-remote.sh bind-mounts ~/pubg-maps; scripts/build-pubg-tiles.py rebuilds it
This commit is contained in:
2026-10-25 22:46:24 +00:00
parent d93374bca2
commit 0fe0d36f87
26 changed files with 39201 additions and 3 deletions

View File

@@ -8,6 +8,7 @@
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import MapIcon from 'lucide-svelte/icons/map';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import MatchHistory from '#lib/game-tracker/MatchHistory.svelte';
import { gameTrackers } from '#lib/projects.js';
@@ -114,6 +115,22 @@
<MatchHistory history={data.history} game={game.slug as 'cs2' | 'pubg' | 'r6'} />
{/if}
{#if game.slug === 'pubg' && data.owner}
<a
href={resolve('/trackers/games/pubg/map')}
class="deep mb-8 inline-flex items-center gap-3 p-4"
>
<MapIcon size={28} aria-hidden="true" />
<span>
<span class="block font-medium">drop map</span>
<span class="block text-sm text-[var(--site-muted)]"
>live squad map: landings, plane path, hotspots, cars</span
>
</span>
<ArrowRight size={18} aria-hidden="true" class="ml-4 text-[var(--site-muted)]" />
</a>
{/if}
{#if game.notes}
<p class="mb-8 max-w-2xl whitespace-pre-line text-[var(--site-muted)]">{game.notes}</p>
{/if}

View File

@@ -0,0 +1,53 @@
import { fail, redirect } from '@sveltejs/kit';
import { isOwner, requireOwner } from '#lib/server/owner.js';
import { cleanText } from '#lib/pubg-map/shared.js';
import * as store from '#lib/server/pubg-map/store.js';
import { roomDeleted } from '#lib/server/pubg-map/live.js';
import type { Actions, PageServerLoad } from './$types';
// The PUBG drop map hub. Stan (owner) sees his rooms, makes new ones and manages the match
// history (landings over time). Friends never need this page: they open a room's share link.
export const load: PageServerLoad = async (event) => {
const owner = isOwner(event);
const seo = {
title: 'PUBG drop map · Games',
description: 'A live squad map for PUBG: landings, plane path, hotspots and vehicles.',
index: false
};
if (!owner) return { owner, rooms: [], history: [], seo };
const [rooms, history] = await Promise.all([
store.listRooms(),
store.pastMatches({ limit: 300 })
]);
return {
owner,
rooms: rooms.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() })),
history,
seo
};
};
export const actions: Actions = {
createRoom: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const name = cleanText(form.get('name'), 40) ?? 'Squad';
const room = await store.createRoom(name);
redirect(303, `/trackers/games/pubg/map/${room.code}`);
},
deleteRoom: async (event) => {
requireOwner(event);
const id = Number((await event.request.formData()).get('id'));
if (!Number.isInteger(id)) return fail(400, { message: 'Bad room' });
await store.deleteRoom(id);
roomDeleted(id);
return { ok: true };
},
deleteMatch: async (event) => {
requireOwner(event);
const id = Number((await event.request.formData()).get('id'));
if (!Number.isInteger(id)) return fail(400, { message: 'Bad match' });
if (!(await store.deletePastMatch(id))) return fail(404, { message: 'Not an archived match' });
return { ok: true };
}
};

View File

@@ -0,0 +1,295 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import Link from 'lucide-svelte/icons/link';
import MapIcon from 'lucide-svelte/icons/map';
import Plus from 'lucide-svelte/icons/plus';
import Trash2 from 'lucide-svelte/icons/trash-2';
import { MAPS, gridRef, mapInfo } from '#lib/pubg-map/shared.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const fmt = new Intl.DateTimeFormat('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Amsterdam'
});
const roomName = (id: number) => data.rooms.find((r) => r.id === id)?.name ?? 'deleted room';
// Landings over time: every archived landing per map, newest matches drawn last (on top).
const byMap = $derived(
MAPS.map((m) => ({
map: m,
matches: data.history.filter((h) => h.map === m.slug).toReversed()
})).filter((g) => g.matches.length)
);
let copied = $state<string | null>(null);
async function copy(code: string) {
await navigator.clipboard.writeText(`${location.origin}/trackers/games/pubg/map/${code}`);
copied = code;
setTimeout(() => (copied = null), 1500);
}
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<a
href={resolve('/trackers/games/[slug]', { slug: 'pubg' })}
class="mb-6 inline-flex items-center gap-1 text-sm text-[var(--site-muted)] hover:text-foreground"
><ArrowLeft size={14} aria-hidden="true" /> PUBG</a
>
<h1 class="mb-3 flex items-center gap-3 text-4xl font-semibold tracking-tight sm:text-5xl">
<MapIcon class="size-10 text-sky-400" aria-hidden="true" /> Drop map
</h1>
<p class="mb-10 max-w-2xl text-[var(--site-muted)]">
A live squad map for a second monitor: landings, the plane path, hotspots, vehicles and notes,
synced between everyone in the room as they click.
</p>
{#if !data.owner}
<p class="card p-6 text-[var(--site-muted)]">
Rooms are private. Ask Stan for the link to his squad’s room.
</p>
{:else}
<section class="mb-12">
<h2 class="mb-4 text-xl font-semibold">Rooms</h2>
<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each data.rooms as room (room.id)}
<li class="card tile flex items-center gap-2 p-2 pl-4">
<a
class="min-w-0 flex-1 py-2"
href={resolve('/trackers/games/pubg/map/[code]', { code: room.code })}
>
<span class="block truncate font-medium">{room.name}</span>
<span class="block text-xs text-[var(--site-muted)]"
>{room.matches}
{room.matches === 1 ? 'match' : 'matches'} · since {fmt.format(
new Date(room.createdAt)
)}</span
>
</a>
<button class="icon" onclick={() => copy(room.code)} title="Copy share link"
><Link
size={16}
class={copied === room.code ? 'text-emerald-400' : 'text-sky-400'}
/></button
>
<form
method="POST"
action="?/deleteRoom"
use:enhance={({ cancel }) => {
if (!confirm(`Delete “${room.name}” and its whole history?`)) cancel();
}}
>
<input type="hidden" name="id" value={room.id} />
<button class="icon" title="Delete room"
><Trash2 size={16} class="text-rose-400" /></button
>
</form>
<a
class="icon"
href={resolve('/trackers/games/pubg/map/[code]', { code: room.code })}
title="Open"><ArrowRight size={16} /></a
>
</li>
{/each}
<li class="card p-2">
<form method="POST" action="?/createRoom" class="flex items-center gap-2">
<input
name="name"
maxlength="40"
placeholder="New room name (e.g. Squad)"
class="field min-w-0 flex-1"
/>
<button class="new"><Plus size={16} class="text-emerald-500" /> Create</button>
</form>
</li>
</ul>
<p class="mt-3 text-sm text-[var(--site-muted)]">
Friends join with the share link (no account): they pick a nickname and colour the first
time.
</p>
</section>
<section class="mb-12">
<h2 class="mb-4 text-xl font-semibold">Landings over time</h2>
{#if !byMap.length}
<p class="card p-6 text-[var(--site-muted)]">
No finished matches yet. “New match” on the map archives the current one here.
</p>
{/if}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each byMap as g (g.map.slug)}
<figure class="card overflow-hidden">
<div class="relative aspect-square">
<img
src="/pubg-tiles/{g.map.slug}/thumb.webp"
alt="{g.map.name} map"
class="absolute inset-0 size-full"
loading="lazy"
/>
<svg viewBox="0 0 1 1" class="absolute inset-0 size-full" aria-hidden="true">
{#each g.matches as h (h.id)}
{#if h.plane}<line
x1={h.plane.x}
y1={h.plane.y}
x2={h.plane.x2}
y2={h.plane.y2}
stroke="#38bdf8"
stroke-opacity="0.35"
stroke-width="0.004"
/>{/if}
{#each h.landings as l, i (i)}<circle
cx={l.x}
cy={l.y}
r="0.012"
fill={l.color}
stroke="#000"
stroke-width="0.003"
/>{/each}
{/each}
</svg>
</div>
<figcaption class="p-3 text-sm">
<b>{g.map.name}</b>
<span class="text-[var(--site-muted)]"
>· {g.matches.length}
{g.matches.length === 1 ? 'match' : 'matches'} · {g.matches.reduce(
(n, h) => n + h.landings.length,
0
)} landings</span
>
</figcaption>
</figure>
{/each}
</div>
</section>
{#if data.history.length}
<section>
<h2 class="mb-4 text-xl font-semibold">Match history</h2>
<ul class="flex flex-col gap-2">
{#each data.history as h (h.id)}
{@const m = mapInfo(h.map)}
<li class="card flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3">
<span class="w-44 shrink-0"
><b>#{h.number} {m.name}</b><span class="block text-xs text-[var(--site-muted)]"
>{fmt.format(new Date(h.startedAt))} · {roomName(h.roomId)}</span
></span
>
<span class="flex flex-1 flex-wrap gap-1.5">
{#each h.landings as l, i (i)}
<span class="chip" style:--c={l.color}
>{l.player} <i>{gridRef(m, l.x, l.y)}</i></span
>
{:else}<span class="text-sm text-[var(--site-muted)]">no landings marked</span
>{/each}
</span>
<span class="text-xs text-[var(--site-muted)]">{h.markers} markers</span>
<form
method="POST"
action="?/deleteMatch"
use:enhance={({ cancel }) => {
if (!confirm(`Delete match #${h.number}?`)) cancel();
}}
>
<input type="hidden" name="id" value={h.id} />
<button class="icon" title="Delete this match"
><Trash2 size={15} class="text-rose-400" /></button
>
</form>
</li>
{/each}
</ul>
</section>
{/if}
{/if}
<p class="mt-12 text-xs text-[var(--site-muted)]">
Map images © KRAFTON, Inc., from PUBG’s official
<a class="underline" href="https://github.com/pubg/api-assets" target="_blank" rel="noreferrer"
>api-assets</a
>, used under the
<a class="underline" href="https://developer.pubg.com/tos" target="_blank" rel="noreferrer"
>PUBG Developer API terms</a
>
and
<a
class="underline"
href="https://www.pubg.com/en/clause/content_creation_guideline"
target="_blank"
rel="noreferrer">content creation guideline</a
>. An unofficial, non-commercial fan tool; not affiliated with or endorsed by KRAFTON.
</p>
</div>
<style>
.card {
border: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent);
border-radius: 0.9rem;
background: color-mix(in oklab, var(--foreground) 2%, transparent);
}
.tile {
transition:
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
background-color 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease;
}
.tile:hover {
background: color-mix(in oklab, var(--foreground) 6%, transparent);
border-color: color-mix(in oklab, var(--foreground) 22%, transparent);
transform: translateY(-2px);
box-shadow: 0 12px 28px -16px rgb(0 0 0 / 0.55);
}
.icon {
display: inline-grid;
place-items: center;
width: 2.1rem;
height: 2.1rem;
border-radius: 0.6rem;
transition: background-color 140ms ease;
}
.icon:hover {
background: color-mix(in oklab, var(--foreground) 8%, transparent);
}
.field {
height: 2.4rem;
padding: 0 0.8rem;
border-radius: 0.6rem;
background: transparent;
border: 1px solid color-mix(in oklab, var(--foreground) 14%, transparent);
}
.new {
display: inline-flex;
align-items: center;
gap: 0.35rem;
height: 2.4rem;
padding: 0 0.9rem;
border-radius: 0.6rem;
background: var(--foreground);
color: var(--background);
font-weight: 600;
}
.chip {
font-size: 0.8rem;
padding: 0.05rem 0.55rem;
border-radius: 999px;
background: color-mix(in oklab, var(--c) 18%, transparent);
border: 1px solid color-mix(in oklab, var(--c) 45%, transparent);
}
.chip i {
font-style: normal;
color: var(--site-muted);
}
@media (prefers-reduced-motion: reduce) {
.tile,
.tile:hover {
transition: none;
transform: none;
}
}
</style>

View File

@@ -0,0 +1,22 @@
import { error } from '@sveltejs/kit';
import { ROOM_CODE_RE } from '#lib/pubg-map/shared.js';
import * as store from '#lib/server/pubg-map/store.js';
import type { PageServerLoad } from './$types';
// One drop map room. The code in the URL is the access: anyone with the link can mark this
// room's current match (live over /ws/pubg-map); unknown codes are a plain 404.
export const load: PageServerLoad = async ({ params, setHeaders }) => {
if (!ROOM_CODE_RE.test(params.code)) error(404, 'Not Found');
const room = await store.roomByCode(params.code);
if (!room) error(404, 'Not Found');
setHeaders({ 'cache-control': 'private, no-store', 'x-robots-tag': 'noindex, nofollow' });
return {
room: { code: room.code, name: room.name },
history: await store.pastMatches({ roomId: room.id, limit: 100 }),
seo: {
title: `${room.name} · PUBG drop map`,
description: 'Live PUBG squad map.',
index: false
}
};
};

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import { resolve } from '$app/paths';
import PubgMap from '#lib/pubg-map/PubgMap.svelte';
import type { PageProps } from './$types';
// Full-screen (no site header, no games switcher): made for a second monitor.
let { data }: PageProps = $props();
</script>
<PubgMap
code={data.room.code}
roomName={data.room.name}
history={data.history}
backHref={resolve('/trackers/games/[slug]', { slug: 'pubg' })}
/>

View File

@@ -0,0 +1,14 @@
import { error, json } from '@sveltejs/kit';
import { ROOM_CODE_RE } from '#lib/pubg-map/shared.js';
import * as store from '#lib/server/pubg-map/store.js';
import type { RequestHandler } from './$types';
/** A room's finished matches with their landings (refetched by the map after "New match"). */
export const GET: RequestHandler = async ({ params }) => {
if (!ROOM_CODE_RE.test(params.code)) error(404, 'Not Found');
const room = await store.roomByCode(params.code);
if (!room) error(404, 'Not Found');
return json(await store.pastMatches({ roomId: room.id, limit: 100 }), {
headers: { 'cache-control': 'private, no-store', 'x-robots-tag': 'noindex' }
});
};