PUBG drop map: live squad map with WebSocket rooms
All checks were successful
Deploy production / deploy (push) Successful in 1m15s
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:
@@ -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}
|
||||
|
||||
53
src/routes/trackers/games/pubg/map/+page.server.ts
Normal file
53
src/routes/trackers/games/pubg/map/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
295
src/routes/trackers/games/pubg/map/+page.svelte
Normal file
295
src/routes/trackers/games/pubg/map/+page.svelte
Normal 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>
|
||||
22
src/routes/trackers/games/pubg/map/[code]/+page.server.ts
Normal file
22
src/routes/trackers/games/pubg/map/[code]/+page.server.ts
Normal 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
|
||||
}
|
||||
};
|
||||
};
|
||||
15
src/routes/trackers/games/pubg/map/[code]/+page@.svelte
Normal file
15
src/routes/trackers/games/pubg/map/[code]/+page@.svelte
Normal 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' })}
|
||||
/>
|
||||
14
src/routes/trackers/games/pubg/map/[code]/history/+server.ts
Normal file
14
src/routes/trackers/games/pubg/map/[code]/history/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user