added gameboard

This commit is contained in:
yoshi 2025-04-22 11:59:50 -07:00
parent 93d278340e
commit fa36efa455
68 changed files with 7800 additions and 1 deletions

View file

@ -0,0 +1,26 @@
import Mousestrap from "mousetrap";
type Options = {
keys: string[];
f: () => void;
};
export function keybind(node: HTMLElement, options: Options) {
console.debug("[keybind] binding:", options.keys);
const mousetrap = new Mousestrap(document.documentElement);
mousetrap.bind(options.keys, () => {
console.debug("[keybind] handling:", options.keys);
options.f();
// Always prevent default behavior
return false;
});
return {
destroy() {
console.debug("[keybind] destorying:", options.keys);
mousetrap.reset();
}
};
}

View file

@ -0,0 +1,20 @@
type Options = {
f: (width: number, height: number) => void;
};
export function resize(node: HTMLElement, options: Options) {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const w = entry.contentBoxSize[0].inlineSize;
const h = entry.contentBoxSize[0].blockSize;
options.f(w, h);
}
});
resizeObserver.observe(node);
return {
destroy() {
resizeObserver.unobserve(node);
}
};
}

View file

@ -0,0 +1,20 @@
import tippy from "tippy.js";
type Options = {
templateId: string;
tippyProps: object;
};
export function tooltip(node: HTMLElement, options: Options) {
const props = {
...options.tippyProps,
allowHTML: true,
content: document.getElementById(options.templateId)?.innerHTML.slice()
};
const tip = tippy(node, props);
return {
destroy: () => tip.destroy()
};
}

View file

@ -0,0 +1,94 @@
<script lang="ts">
import { playbackState } from "$lib/playback/stores";
import { highlightedSnakeID } from "$lib/highlight";
import type { SvgCalcParams } from "$lib/svg";
import SvgHazard from "./SvgHazard.svelte";
import SvgSnake from "./SvgSnake.svelte";
import SvgFood from "./SvgFood.svelte";
import SvgGrid from "./SvgGrid.svelte";
// Grid constants
const CELL_SIZE = 20;
const CELL_SIZE_HALF = CELL_SIZE / 2;
const CELL_SPACING = 4;
const GRID_BORDER = 10;
export let showCoordinates: boolean;
$: svgWidth = $playbackState
? 2 * GRID_BORDER +
$playbackState.frame.width * CELL_SIZE +
Math.max($playbackState.frame.width - 1, 0) * CELL_SPACING
: 0;
$: svgHeight = $playbackState
? 2 * GRID_BORDER +
$playbackState.frame.height * CELL_SIZE +
Math.max($playbackState.frame.height - 1, 0) * CELL_SPACING
: 0;
$: svgCalcParams = {
cellSize: CELL_SIZE,
cellSizeHalf: CELL_SIZE_HALF,
cellSpacing: CELL_SPACING,
gridBorder: GRID_BORDER,
height: svgHeight,
width: svgWidth
} as SvgCalcParams;
</script>
{#if $playbackState}
<svg class="gameboard flex-shrink" viewBox="0 0 {svgWidth} {svgHeight}">
<!-- Grid -->
<SvgGrid
gridWidth={$playbackState.frame.width}
gridHeight={$playbackState.frame.height}
showLabels={showCoordinates}
{svgCalcParams}
/>
<!-- Snakes -->
{#if $highlightedSnakeID}
<!-- Draw non-highlighted snakes under the highlighted one -->
{#each $playbackState.frame.snakes as snake}
{#if snake.id !== $highlightedSnakeID}
<SvgSnake {snake} {svgCalcParams} opacity={0.1} />
{/if}
{/each}
{#each $playbackState.frame.snakes as snake}
{#if snake.id === $highlightedSnakeID}
<SvgSnake {snake} {svgCalcParams} />
{/if}
{/each}
{:else}
<!-- Draw eliminated snakes under the alive ones -->
{#each $playbackState.frame.snakes as snake}
{#if snake.isEliminated}
<SvgSnake {snake} {svgCalcParams} opacity={0.1} />
{/if}
{/each}
{#each $playbackState.frame.snakes as snake}
{#if !snake.isEliminated}
<SvgSnake {snake} {svgCalcParams} />
{/if}
{/each}
{/if}
<!-- Hazards -->
{#each $playbackState.frame.hazards as hazard, i}
<SvgHazard point={hazard} key={`${i}`} {svgCalcParams} />
{/each}
<!-- Food -->
{#each $playbackState.frame.food as food, i}
<SvgFood point={food} key={`${i}`} {svgCalcParams} />
{/each}
</svg>
{/if}
<style lang="postcss">
/* Add a minimal drop shadow to food and snakes */
:global(svg.gameboard .food, svg.gameboard .snake) {
filter: drop-shadow(0.1em 0.1em 0.05em rgba(0, 0, 0, 0.3));
}
</style>

View file

@ -0,0 +1,55 @@
<script lang="ts">
import { playbackState } from "$lib/playback/stores";
import { PlaybackMode } from "$lib/playback/types";
import IconPlay from "~icons/heroicons/play-solid";
import IconPause from "~icons/heroicons/pause-solid";
import IconNext from "~icons/heroicons/chevron-right-solid";
import IconPrev from "~icons/heroicons/chevron-left-solid";
import IconFirst from "~icons/heroicons/chevron-double-left-solid";
import IconLast from "~icons/heroicons/chevron-double-right-solid";
$: disableDuringPlayback = $playbackState ? $playbackState.mode == PlaybackMode.PLAYING : false;
</script>
{#if $playbackState}
<div>
<button
class="mx-2 disabled:text-neutral-400"
on:click={playbackState.controls.firstFrame}
disabled={disableDuringPlayback}
>
<IconFirst />
</button>
<button
class="mx-2 disabled:text-neutral-400"
on:click={playbackState.controls.prevFrame}
disabled={disableDuringPlayback}
>
<IconPrev />
</button>
{#if $playbackState.mode == PlaybackMode.PLAYING}
<button class="mx-2" on:click={playbackState.controls.pause}>
<IconPause />
</button>
{:else if $playbackState.mode == PlaybackMode.PAUSED}
<button class="mx-2" on:click={playbackState.controls.play}>
<IconPlay />
</button>
{/if}
<button
class="mx-2 disabled:text-neutral-400"
on:click={playbackState.controls.nextFrame}
disabled={disableDuringPlayback}
>
<IconNext />
</button>
<button
class="mx-2 disabled:text-neutral-400"
on:click={playbackState.controls.lastFrame}
disabled={disableDuringPlayback}
>
<IconLast />
</button>
</div>
{/if}

View file

@ -0,0 +1,112 @@
<script lang="ts">
import { highlightedSnakeID } from "$lib/highlight";
import { playbackState } from "$lib/playback/stores";
import type { Elimination } from "$lib/playback/types";
// We sort snakes by elimination state, then lowercase name alphabetical
$: sortedSnakes = $playbackState
? [...$playbackState.frame.snakes].sort((a, b) => {
if (a.isEliminated != b.isEliminated) {
return a.isEliminated ? 1 : -1;
}
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
})
: [];
function snakeIdToName(id: string) {
if ($playbackState) {
for (let i = 0; i < $playbackState.frame.snakes.length; i++) {
if ($playbackState.frame.snakes[i].id == id) {
return $playbackState.frame.snakes[i].name;
}
}
}
return "UNKNOWN";
}
function eliminationToString(elimination: Elimination) {
// See https://github.com/BattlesnakeOfficial/rules/blob/master/standard.go
switch (elimination.cause) {
case "snake-collision":
return `Collided with body of ${snakeIdToName(elimination.by)} on Turn ${elimination.turn}`;
case "snake-self-collision":
return `Collided with itself on Turn ${elimination.turn}`;
case "out-of-health":
return `Ran out of health on Turn ${elimination.turn}`;
case "hazard":
return `Eliminated by hazard on Turn ${elimination.turn}`;
case "head-collision":
return `Lost head-to-head with ${snakeIdToName(elimination.by)} on Turn ${
elimination.turn
}`;
case "wall-collision":
return `Moved out of bounds on Turn ${elimination.turn}`;
default:
return elimination.cause;
}
}
function highlightSnake(id: string) {
if ($highlightedSnakeID == id) {
$highlightedSnakeID = null;
} else {
$highlightedSnakeID = id;
}
}
</script>
{#if $playbackState}
<div class="flex flex-row font-bold text-lg">
<div class="basis-1/2 text-right">TURN</div>
<div class="basis-1/2 pl-2">{$playbackState.frame.turn}</div>
</div>
{#each sortedSnakes as snake}
<div
class="p-2 cursor-pointer rounded-sm border-solid border-2 border-transparent hover:border-neutral-500 hover:bg-neutral-200 dark:hover:bg-neutral-800"
class:eliminated={snake.isEliminated}
class:highlighted={snake.id == $highlightedSnakeID}
on:click={() => highlightSnake(snake.id)}
role="presentation"
>
<div class="flex flex-row font-bold">
<p class="grow truncate">{snake.name}</p>
<p class="ps-4 text-right">{snake.length}</p>
</div>
<div class="flex flex-row text-xs">
<p class="grow truncate">by {snake.author}</p>
<p class="text-right">{snake.latency ? `${snake.latency}ms` : ""}</p>
</div>
<div class="h-4 text-xs mt-1">
{#if snake.elimination}
<p>{eliminationToString(snake.elimination)}</p>
{:else}
<div class="text-outline w-full h-full rounded-full bg-neutral-200 dark:bg-neutral-800">
<div
class="transition-all h-full rounded-full text-white ps-2"
style="background: {snake.color}; width: {snake.health}%"
>
{snake.health}
</div>
</div>
{/if}
</div>
</div>
{/each}
{/if}
<style lang="postcss">
.text-outline {
text-shadow: 0 0 2px black, 0 0 2px black, 0 0 2px black, 0 0 2px black;
}
.eliminated {
@apply text-neutral-500;
}
.highlighted {
@apply border-pink-500 bg-neutral-200;
}
:global(html.dark .highlighted) {
@apply bg-neutral-800;
}
</style>

View file

@ -0,0 +1,53 @@
<script lang="ts">
import { playbackState } from "$lib/playback/stores";
// Range input properties
let min = 0;
let max = 10;
let step = 1;
let disabled = true;
// Scrubber value
let value = min;
// Enable the scrubber once the final frame is known
$: if (disabled && $playbackState && $playbackState.finalFrame) {
disabled = false;
max = $playbackState.finalFrame.turn;
}
// Update range value to reflect currently displayed frame
$: if (!disabled && $playbackState) {
value = $playbackState.frame.turn;
}
function onScrub(e: Event) {
// Jump to frame on scrub event. Note that we can't use
// the bound `value` here because it hasn't updated yet.
if (e.target) {
const frame = parseInt((e.target as HTMLInputElement).value);
playbackState?.controls.jumpToFrame(frame);
}
}
function onFocus(e: Event) {
// Prevent input from being focused (it messes with hotkeys)
if (e.target) {
(e.target as HTMLElement).blur();
}
}
</script>
{#if $playbackState}
<input
class="w-full cursor-pointer disabled:cursor-not-allowed"
type="range"
{min}
{max}
{step}
{disabled}
on:focus={onFocus}
on:input={onScrub}
bind:value
/>
{/if}

View file

@ -0,0 +1,13 @@
<script lang="ts">
import type { Point } from "$lib/playback/types";
import { type SvgCalcParams, svgCalcCellCircle } from "$lib/svg";
export let key: string;
export let point: Point;
export let svgCalcParams: SvgCalcParams;
$: circleProps = svgCalcCellCircle(svgCalcParams, point);
$: foodRadius = (svgCalcParams.cellSize / 3.25).toFixed(2);
</script>
<circle id={`food-${key}`} class="food fill-rose-500" r={foodRadius} {...circleProps} />

View file

@ -0,0 +1,47 @@
<script lang="ts">
import {
type SvgCalcParams,
svgCalcCellRect,
svgCalcCellLabelBottom,
svgCalcCellLabelLeft
} from "$lib/svg";
export let gridWidth: number;
export let gridHeight: number;
export let showLabels: boolean;
export let svgCalcParams: SvgCalcParams;
</script>
<g>
{#each { length: gridWidth } as _, x}
{#each { length: gridHeight } as _, y}
<rect
id={`grid-${x}-${y}`}
class="grid fill-[#f1f1f1] dark:fill-[#393939]"
{...svgCalcCellRect(svgCalcParams, { x, y })}
/>
{/each}
{/each}
{#if showLabels}
{#each { length: gridHeight } as _, x}
<text
class="coordinate-label text-[0.35rem] fill-neutral-500"
text-anchor="middle"
transform="translate(0, 2)"
{...svgCalcCellLabelBottom(svgCalcParams, { x: x, y: 0 })}
>
{x}
</text>
{/each}
{#each { length: gridHeight } as _, y}
<text
class="coordinate-label text-[0.35rem] fill-neutral-500"
text-anchor="middle"
transform="translate(0, 2)"
{...svgCalcCellLabelLeft(svgCalcParams, { x: 0, y: y })}
>
{y}
</text>
{/each}
{/if}
</g>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import type { Point } from "$lib/playback/types";
import { type SvgCalcParams, svgCalcCellRect } from "$lib/svg";
export let key: string;
export let point: Point;
export let svgCalcParams: SvgCalcParams;
$: rectProps = svgCalcCellRect(svgCalcParams, point);
</script>
<rect id={`hazard-${key}`} class="hazard fill-black opacity-30" {...rectProps} />

View file

@ -0,0 +1,18 @@
<script lang="ts">
import type { Snake } from "$lib/playback/types";
import type { SvgCalcParams } from "$lib/svg";
import SvgSnakeTail from "./SvgSnakeTail.svelte";
import SvgSnakeHead from "./SvgSnakeHead.svelte";
import SvgSnakeBody from "./SvgSnakeBody.svelte";
export let snake: Snake;
export let svgCalcParams: SvgCalcParams;
export let opacity = 1.0;
</script>
<g id={`snake-${snake.id}`} class="snake" style:opacity>
<SvgSnakeTail {svgCalcParams} {snake} />
<SvgSnakeBody {svgCalcParams} {snake} />
<SvgSnakeHead {svgCalcParams} {snake} />
</g>

View file

@ -0,0 +1,246 @@
<script lang="ts">
import type { Point, Snake } from "$lib/playback/types";
import {
type SvgCalcParams,
type SvgPoint,
type SvgCircleProps,
svgCalcCellCenter
} from "$lib/svg";
import { calcSourceWrapPosition, isAdjacentPoint, isEqualPoint } from "$lib/geometry";
type SvgPointWithCircleProps = SvgPoint & SvgCircleProps;
// Used to very slightly extend body segments to ensure overlap with head and tail
const OVERLAP = 0.1;
export let snake: Snake;
export let svgCalcParams: SvgCalcParams;
// Calculate the center points of a line that paths along snake.body
$: bodyPolylinesPoints = calcBodyPolylinesPoints(snake);
function calcBodyPolylinesPoints(snake: Snake): SvgPoint[][] {
// Make a copy of snake body and separate into head, tail, and body.
const body: Point[] = [...snake.body];
const head = body.shift() as Point;
const tail = body.pop() as Point;
// Ignore body parts that are stacked on the tail
// This ensures that the tail is always shown even when the snake has grown
for (let last = body.at(-1); isEqualPoint(last, tail); last = body.at(-1)) {
body.pop();
}
if (body.length == 0) {
// If we're drawing no body, but head and tail are different,
// they still need to be connected.
if (!isEqualPoint(head, tail)) {
const svgCenter = svgCalcCellCenter(svgCalcParams, head);
return [calcHeadToTailJoint(head, tail, svgCenter)];
}
return [[]];
}
return convertBodyToPolilines(body, head, tail);
}
function convertBodyToPolilines(body: Point[], head: Point, tail: Point): SvgPoint[][] {
const gapSize = svgCalcParams.cellSpacing + OVERLAP;
// Split wrapped body parts into separated segments
const bodySegments = splitBodySegments(body);
// Get the center point of each body square we're going to render
const bodySegmentsCenterPoints = bodySegments.map((segment) =>
segment.map(enrichSvgCellCenter)
);
// Extend each wrapped segment towards border
for (let i = 0; i < bodySegmentsCenterPoints.length; i++) {
// Extend each segment last point towards border
if (i < bodySegmentsCenterPoints.length - 1) {
const cur = bodySegmentsCenterPoints[i].at(-1) as SvgPointWithCircleProps;
const next = bodySegmentsCenterPoints[i + 1][0];
bodySegmentsCenterPoints[i].push(calcBorderJoint(cur, next));
}
// Extend segment's first point toward border portal
if (i > 0) {
const cur = bodySegmentsCenterPoints[i][0];
const prev = bodySegmentsCenterPoints[i - 1].at(-1) as Point;
bodySegmentsCenterPoints[i].unshift(calcBorderJoint(cur, prev));
}
}
// Extend first point towards head
const firstPoint = bodySegmentsCenterPoints[0][0];
if (isAdjacentPoint(head, firstPoint)) {
bodySegmentsCenterPoints[0].unshift(calcJoint(firstPoint, head, gapSize));
} else {
// Add head portal
bodySegmentsCenterPoints[0].unshift(calcBorderJoint(enrichSvgCellCenter(firstPoint), head));
}
// Extend last point towards tail
const lastPoint = bodySegmentsCenterPoints.at(-1)?.at(-1) as SvgPointWithCircleProps;
if (isAdjacentPoint(lastPoint, tail)) {
bodySegmentsCenterPoints.at(-1)?.push(calcJoint(lastPoint, tail, gapSize));
} else {
// Add tail portal
bodySegmentsCenterPoints.at(-1)?.push(calcBorderJoint(lastPoint, tail));
}
// Finally, return an array of SvgPoints to use for a polyline
return bodySegmentsCenterPoints.map((segment) =>
segment.map((obj) => ({ x: obj.cx, y: obj.cy }))
);
}
function splitBodySegments(body: Point[]): Point[][] {
if (body.length == 0) {
return [[]];
}
let prev = body[0];
const segments: Point[][] = [[prev]];
for (let i = 1; i < body.length; i++) {
const cur = body[i];
// Start new segment
if (!isAdjacentPoint(cur, prev)) {
segments.push([]);
}
segments.at(-1)?.push(cur);
prev = cur;
}
return segments;
}
function enrichSvgCellCenter(p: Point): SvgPointWithCircleProps {
const c = svgCalcCellCenter(svgCalcParams, p);
return {
cx: c.x,
cy: c.y,
...p
};
}
function calcBorderJoint(src: SvgPointWithCircleProps, dst: Point): SvgPointWithCircleProps {
const border = calcSourceWrapPosition(src, dst);
return calcJoint(src, border);
}
function calcJoint(
src: SvgPointWithCircleProps,
dst: Point,
gapSize = 0
): SvgPointWithCircleProps {
// Extend source point towards destination
if (dst.x > src.x) {
return {
...src,
cx: src.cx + svgCalcParams.cellSizeHalf + gapSize,
cy: src.cy
};
} else if (dst.x < src.x) {
return {
...src,
cx: src.cx - svgCalcParams.cellSizeHalf - gapSize,
cy: src.cy
};
} else if (dst.y > src.y) {
return {
...src,
cx: src.cx,
cy: src.cy - svgCalcParams.cellSizeHalf - gapSize
};
} else if (dst.y < src.y) {
return {
...src,
cx: src.cx,
cy: src.cy + svgCalcParams.cellSizeHalf + gapSize
};
}
// In error cases there could be duplicate point
throw new Error("Same point have no joint.");
}
function calcHeadToTailJoint(head: Point, tail: Point, svgCenter: Point): SvgPoint[] {
if (head.x > tail.x) {
return [
{
x: svgCenter.x - svgCalcParams.cellSizeHalf + OVERLAP,
y: svgCenter.y
},
{
x: svgCenter.x - svgCalcParams.cellSizeHalf - svgCalcParams.cellSpacing - OVERLAP,
y: svgCenter.y
}
];
} else if (head.x < tail.x) {
return [
{
x: svgCenter.x + svgCalcParams.cellSizeHalf - OVERLAP,
y: svgCenter.y
},
{
x: svgCenter.x + svgCalcParams.cellSizeHalf + svgCalcParams.cellSpacing + OVERLAP,
y: svgCenter.y
}
];
} else if (head.y > tail.y) {
return [
{
x: svgCenter.x,
y: svgCenter.y + svgCalcParams.cellSizeHalf - OVERLAP
},
{
x: svgCenter.x,
y: svgCenter.y + svgCalcParams.cellSizeHalf + svgCalcParams.cellSpacing + OVERLAP
}
];
} else if (head.y < tail.y) {
return [
{
x: svgCenter.x,
y: svgCenter.y - svgCalcParams.cellSizeHalf + OVERLAP
},
{
x: svgCenter.x,
y: svgCenter.y - svgCalcParams.cellSizeHalf - svgCalcParams.cellSpacing - OVERLAP
}
];
}
throw new Error("Head and tail is a same point.");
}
$: drawBody = bodyPolylinesPoints[0].length > 0;
$: bodyPolylinesProps = bodyPolylinesPoints.map(calcBodyPolylineProps);
function calcBodyPolylineProps(polylinePoints: SvgPoint[]) {
// Convert points into a string of the format "x1,y1 x2,y2, ...
const points = polylinePoints
.map((p) => {
return `${p.x},${p.y}`;
})
.join(" ");
return {
points,
"stroke-width": svgCalcParams.cellSize,
"stroke-linecap": "butt" as const,
"stroke-linejoin": "round" as const
};
}
</script>
{#if drawBody}
{#each bodyPolylinesProps as polylineProps}
<polyline stroke={snake.color} fill="transparent" {...polylineProps} />
{/each}
{/if}

View file

@ -0,0 +1,93 @@
<script lang="ts">
import type { Snake } from "$lib/playback/types";
import { fetchCustomizationSvgDef } from "$lib/customizations";
import { type SvgCalcParams, svgCalcCellRect } from "$lib/svg";
import { calcDestinationWrapPosition, isAdjacentPoint } from "$lib/geometry";
export let snake: Snake;
export let svgCalcParams: SvgCalcParams;
$: drawHead = calcDrawHead(snake);
function calcDrawHead(_: Snake): boolean {
return true; // Snake heads are always drawn!
}
$: headRectProps = svgCalcCellRect(svgCalcParams, snake.body[0]);
$: headDirection = calcHeadDirection(snake);
function calcHeadDirection(snake: Snake): string {
const [head, neckPoint] = snake.body.slice(0, 2);
let neck = neckPoint;
// If head is wrapped we need to calcualte neck position on border
if (!isAdjacentPoint(neck, head)) {
neck = calcDestinationWrapPosition(neck, head);
}
// Determine head direction based on relative position of neck and tail.
// If neck and tail overlap, we return the default direction (right).
if (head.x < neck.x) {
return "left";
} else if (head.y > neck.y) {
return "up";
} else if (head.y < neck.y) {
return "down";
}
return "right";
}
$: headTransform = calcHeadTransform(headDirection);
function calcHeadTransform(headDirection: string): string {
if (headDirection == "left") {
return "scale(-1,1) translate(-100, 0)";
} else if (headDirection == "up") {
return "rotate(-90, 50, 50)";
} else if (headDirection == "down") {
return "rotate(90, 50, 50)";
}
// Moving right/default
return "";
}
// If the snake is eliminated by self collision we give its head
// a drop shadow for dramatic effect.
$: drawHeadShadow = calcDrawHeadShadow(snake);
function calcDrawHeadShadow(snake: Snake): boolean {
return snake.isEliminated && snake.elimination?.cause == "snake-self-collision";
}
</script>
{#await fetchCustomizationSvgDef("head", snake.head) then headSvgDef}
{#if drawHead}
<svg
class="head {headDirection}"
class:shadow={drawHeadShadow}
viewBox="0 0 100 100"
fill={snake.color}
{...headRectProps}
>
<g transform={headTransform}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html headSvgDef}
</g>
</svg>
{/if}
{/await}
<style lang="postcss">
/* Offset shadows in the direction the head is
facing to avoid drawing shadow over the body */
svg.head.up.shadow {
filter: drop-shadow(0 -0.4rem 0.2rem rgba(0, 0, 0, 0.3));
}
svg.head.down.shadow {
filter: drop-shadow(0 0.4rem 0.2rem rgba(0, 0, 0, 0.3));
}
svg.head.left.shadow {
filter: drop-shadow(-0.4rem 0 0.2rem rgba(0, 0, 0, 0.3));
}
svg.head.right.shadow {
filter: drop-shadow(0.4rem 0 0.2rem rgba(0, 0, 0, 0.3));
}
</style>

View file

@ -0,0 +1,69 @@
<script lang="ts">
import type { Snake } from "$lib/playback/types";
import { fetchCustomizationSvgDef } from "$lib/customizations";
import { type SvgCalcParams, svgCalcCellRect } from "$lib/svg";
import { calcDestinationWrapPosition, isAdjacentPoint } from "$lib/geometry";
export let snake: Snake;
export let svgCalcParams: SvgCalcParams;
$: drawTail = calcDrawTail(snake);
function calcDrawTail(snake: Snake): boolean {
// Skip drawing the tail if the snake head occupies the same spot
const head = snake.body[0];
const tail = snake.body[snake.body.length - 1];
if (head.x == tail.x && head.y == tail.y) {
return false;
}
return true;
}
$: tailRectProps = svgCalcCellRect(svgCalcParams, snake.body[snake.body.length - 1]);
$: tailTransform = calcTailTransform(snake);
function calcTailTransform(snake: Snake): string {
const tail = snake.body[snake.body.length - 1];
// Work backwards from the tail until we reach a segment that isn't stacked.
let preTailIndex = snake.body.length - 2;
let preTail = snake.body[preTailIndex];
while (preTail.x == tail.x && preTail.y == tail.y) {
preTailIndex -= 1;
if (preTailIndex < 0) {
return "";
}
preTail = snake.body[preTailIndex];
}
// If tail is wrapped we need to calcualte neck position on border
if (!isAdjacentPoint(preTail, tail)) {
preTail = calcDestinationWrapPosition(preTail, tail);
}
// Return transform based on relative location
if (preTail.x > tail.x) {
// Moving right
return "scale(-1,1) translate(-100,0)";
} else if (preTail.y > tail.y) {
// Moving up
return "scale(-1,1) translate(-100,0) rotate(90, 50, 50)";
} else if (preTail.y < tail.y) {
// Moving down
return "scale(-1,1) translate(-100,0) rotate(-90, 50, 50)";
}
// Moving left
return "";
}
</script>
{#await fetchCustomizationSvgDef("tail", snake.tail) then tailSvgDef}
{#if drawTail}
<svg class="tail" viewBox="0 0 100 100" fill={snake.color} {...tailRectProps}>
<g transform={tailTransform}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html tailSvgDef}
</g>
</svg>
{/if}
{/await}

View file

@ -0,0 +1,54 @@
<script lang="ts">
export let id: string;
</script>
<template {id}>
<div class="grid grid-cols-2 gap-x-2 gap-y-1 p-2">
<p class="font-bold col-span-2 mb-2">Keyboard Shortcuts</p>
<p>Play/Pause</p>
<p class="text-right">
<span class="hotkey">Space</span>
<span class="hotkey">s</span>
<span class="hotkey">k</span>
</p>
<p>Next Turn</p>
<p class="text-right">
<span class="hotkey"></span>
<span class="hotkey">d</span>
<span class="hotkey">l</span>
</p>
<p>Previous Turn</p>
<p class="text-right">
<span class="hotkey"></span>
<span class="hotkey">a</span>
<span class="hotkey">j</span>
</p>
<p>Next Elimination</p>
<p class="text-right">
<span class="hotkey">e</span>
</p>
<p>Previous Elimination</p>
<p class="text-right">
<span class="hotkey">q</span>
</p>
<p>Jump to Start</p>
<p class="text-right">
<span class="hotkey">r</span>
</p>
<p>Jump to End</p>
<p class="text-right">
<span class="hotkey">t</span>
</p>
<p>Settings</p>
<p class="text-right">
<span class="hotkey">,</span>
</p>
</div>
</template>
<style lang="postcss">
.hotkey {
@apply px-2 rounded bg-pink-800/50;
border: 1px solid #888;
}
</style>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { Settings } from "$lib/settings/stores";
export let id: string;
export let settings: Settings;
</script>
<template {id}>
<div class="grid grid-cols-2 gap-x-2 gap-y-1 p-2">
<p class="font-bold col-span-2 mb-2">Playback Settings</p>
<p>autoplay:</p>
<p class="text-right">{settings.autoplay}</p>
<p>fps:</p>
<p class="text-right">{settings.fps}</p>
<p>showCoords:</p>
<p class="text-right">{settings.showCoords}</p>
<p>theme:</p>
<p class="text-right">{settings.theme}</p>
</div>
</template>

View file

@ -0,0 +1,24 @@
const mediaCache: { [key: string]: string } = {};
export async function fetchCustomizationSvgDef(type: string, name: string) {
const mediaPath = `snakes/${type}s/${name}.svg`;
if (!(mediaPath in mediaCache)) {
mediaCache[mediaPath] = await fetch(`https://media.battlesnake.com/${mediaPath}`)
.then((response) => response.text())
.then((textSVG) => {
const tempElememt = document.createElement("template");
tempElememt.innerHTML = textSVG.trim();
console.debug(`[customizations] loaded svg definition for ${mediaPath}`);
if (tempElememt.content.firstChild === null) {
console.debug("[customizations] error loading customization, no elements found");
return "";
}
const child = <HTMLElement>tempElememt.content.firstChild;
return child.innerHTML;
});
}
return mediaCache[mediaPath];
}

View file

@ -0,0 +1,31 @@
import type { Point } from "./playback/types";
export function isEqualPoint(p1?: Point, p2?: Point): boolean {
if (p1 == undefined || p2 == undefined) {
return false;
}
return p1.x == p2.x && p1.y == p2.y;
}
export function isAdjacentPoint(p1: Point, p2: Point): boolean {
return calcManhattan(p1, p2) == 1;
}
export function calcManhattan(p1: Point, p2: Point): number {
return Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y);
}
export function calcSourceWrapPosition(src: Point, dst: Point): Point {
return {
x: src.x - Math.sign(dst.x - src.x),
y: src.y - Math.sign(dst.y - src.y)
};
}
export function calcDestinationWrapPosition(src: Point, dst: Point): Point {
return {
x: dst.x + Math.sign(dst.x - src.x),
y: dst.y + Math.sign(dst.y - src.y)
};
}

View file

@ -0,0 +1,3 @@
import { writable } from "svelte/store";
export const highlightedSnakeID = writable<string | null>(null);

View file

@ -0,0 +1,26 @@
let activeInterval: undefined | ReturnType<typeof setInterval>;
export function startPlayback(fps: number, callback: () => void) {
// Do nothing if playback is active
if (activeInterval) {
return;
}
console.debug(`[playback] starting playback at ${fps} fps`);
// Play first frame immediately
callback();
// Set interval for future frames
const delayMS = 1000 / Math.ceil(fps);
activeInterval = setInterval(() => {
callback();
}, delayMS);
}
export function stopPlayback(): void {
if (activeInterval) {
clearInterval(activeInterval);
activeInterval = undefined;
}
}

View file

@ -0,0 +1,84 @@
import ReconnectingWebSocket from "reconnecting-websocket";
import { engineEventToFrame, type Frame } from "./types";
type FrameCallback = (frame: Frame) => void;
// Engine data
let ws: WebSocket;
let loadedFrames = new Set();
// Converts http://foo to ws://foo or https://foo to wss://foo
export function httpToWsProtocol(url: string) {
return url
.replace(/^https:\/\//i, "wss://") // https:// --> wss://
.replace(/^http:\/\//i, "ws://"); // http:// --> ws://
}
export function fetchGame(
fetchFunc: typeof fetch,
gameID: string,
engineURL: string,
frames: Frame[],
onFrameLoad: FrameCallback,
onFinalFrame: FrameCallback,
onError: (message: string) => void
) {
console.debug(`[playback] loading game ${gameID}`);
// Reset
if (ws) ws.close();
loadedFrames = new Set();
const gameInfoUrl = `${engineURL}/games/${gameID}`;
const gameEventsUrl = `${httpToWsProtocol(engineURL)}/games/${gameID}/events`;
fetchFunc(gameInfoUrl)
.then(async (response) => {
if (response.status == 404) {
throw new Error("Game not found");
} else if (!response.ok) {
throw new Error("Error loading game");
}
const gameInfo = await response.json();
const ws = new ReconnectingWebSocket(gameEventsUrl);
ws.onopen = () => {
console.debug("[playback] opening engine websocket");
};
ws.onmessage = (message) => {
const engineEvent = JSON.parse(message.data);
if (engineEvent.Type == "frame" && !loadedFrames.has(engineEvent.Data.Turn)) {
loadedFrames.add(engineEvent.Data.Turn);
const frame = engineEventToFrame(gameInfo, engineEvent.Data);
frames.push(frame);
frames.sort((a: Frame, b: Frame) => a.turn - b.turn);
// Fire frame callback
if (engineEvent.Data.Turn == 0) {
console.debug("[playback] received first frame");
}
onFrameLoad(frame);
} else if (engineEvent.Type == "game_end") {
console.debug("[playback] received final frame");
if (ws) ws.close();
// Flag last frame as the last one and fire callback
frames[frames.length - 1].isFinalFrame = true;
onFinalFrame(frames[frames.length - 1]);
}
};
ws.onclose = () => {
console.debug("[playback] closing engine websocket");
};
})
.catch(function (e) {
console.error(e);
onError(e.message);
});
}

View file

@ -0,0 +1,54 @@
import { browser } from "$app/environment";
import { playbackState } from "./stores";
import type { PlaybackState } from "./types";
enum GameEvent {
// Basic display messages
RESIZE = "RESIZE",
TURN = "TURN",
GAME_OVER = "GAME_OVER"
// Could do eliminations, food spawns, hazard damage, etc etc etc.
}
type Message = {
event: GameEvent;
data: object;
};
function postMessageToParent(message: Message) {
if (browser) {
try {
window.parent.postMessage(message, "*");
} catch (e) {
console.error(e);
}
}
}
export function sendResizeMessage(width: number, height: number) {
postMessageToParent({
event: GameEvent.RESIZE,
data: { width, height }
});
}
export function initWindowMessages() {
playbackState.subscribe((state: PlaybackState | null) => {
if (state) {
postMessageToParent({
event: GameEvent.TURN,
data: {
turn: state.frame.turn
}
});
if (state.frame.isFinalFrame) {
postMessageToParent({
event: GameEvent.GAME_OVER,
data: {}
});
}
}
});
}

View file

@ -0,0 +1,178 @@
import { get, writable } from "svelte/store";
import { type Settings, getDefaultSettings } from "$lib/settings/stores";
import { startPlayback, stopPlayback } from "./animation";
import { fetchGame } from "./engine";
import { type Frame, PlaybackMode, type PlaybackState } from "./types";
const AUTOPLAY_DELAY_MS = 250;
const LOOP_DELAY_MS = 1500;
let frames: Frame[] = [];
let currentFrameIndex = 0;
let settings: Settings = getDefaultSettings();
const writableState = writable<PlaybackState | null>(null);
export const playbackError = writable<string | null>(null);
const reset = () => {
frames = [];
currentFrameIndex = 0;
settings = getDefaultSettings();
writableState.set(null);
playbackError.set(null);
};
const setCurrentFrame = (index: number) => {
const clampedIndex = Math.min(Math.max(index, 0), frames.length - 1);
const newFrame = frames[clampedIndex];
writableState.update(($state) => {
if ($state) {
currentFrameIndex = clampedIndex;
$state.frame = newFrame;
if ($state.frame.isFinalFrame && $state.mode == PlaybackMode.PLAYING) {
stopPlayback();
$state.mode = PlaybackMode.PAUSED;
if (settings.loop) {
setTimeout(controls.firstFrame, LOOP_DELAY_MS);
setTimeout(controls.play, LOOP_DELAY_MS * 2);
}
}
}
return $state;
});
};
const setMode = (mode: PlaybackMode) => {
writableState.update(($state) => {
if ($state) {
$state.mode = mode;
}
return $state;
});
};
const controls = {
firstFrame: () => {
if (get(writableState)?.mode == PlaybackMode.PAUSED) {
setCurrentFrame(0);
}
},
lastFrame: () => {
if (get(writableState)?.mode == PlaybackMode.PAUSED) {
setCurrentFrame(frames.length - 1);
}
},
prevFrame: () => {
if (get(writableState)?.mode == PlaybackMode.PAUSED) {
setCurrentFrame(currentFrameIndex - 1);
}
},
nextFrame: () => {
if (get(writableState)?.mode == PlaybackMode.PAUSED) {
setCurrentFrame(currentFrameIndex + 1);
}
},
prevEliminationFrame: () => {
if (get(writableState)?.mode == PlaybackMode.PAUSED) {
for (let i = currentFrameIndex; i >= 0; i--) {
for (let s = 0; s < frames[i].snakes.length; s++) {
const snake = frames[i].snakes[s];
if (snake.elimination && snake.elimination.turn <= currentFrameIndex) {
const newIndex = snake.elimination.turn - 1;
console.debug(`[playback] jump to elimination frame ${newIndex}`);
setCurrentFrame(newIndex);
return;
}
}
controls.firstFrame();
}
}
},
nextEliminationFrame: () => {
if (get(writableState)?.mode == PlaybackMode.PAUSED) {
for (let i = currentFrameIndex + 2; i < frames.length; i++) {
for (let s = 0; s < frames[i].snakes.length; s++) {
const snake = frames[i].snakes[s];
if (snake.elimination && snake.elimination.turn > currentFrameIndex + 1) {
const newIndex = snake.elimination.turn - 1;
console.debug(`[playback] jump to elimination frame ${newIndex}`);
setCurrentFrame(newIndex);
return;
}
}
}
controls.lastFrame();
}
},
play: () => {
if (get(writableState)?.mode == PlaybackMode.PAUSED) {
startPlayback(settings.fps, () => {
setCurrentFrame(currentFrameIndex + 1);
});
setMode(PlaybackMode.PLAYING);
}
},
pause: () => {
stopPlayback();
setMode(PlaybackMode.PAUSED);
},
togglePlayPause: () => {
if (get(writableState)?.mode == PlaybackMode.PAUSED) {
controls.play();
} else if (get(writableState)?.mode == PlaybackMode.PLAYING) {
controls.pause();
}
},
jumpToFrame: (i: number) => {
controls.pause();
setCurrentFrame(i);
}
};
const onFrameLoad = (frame: Frame) => {
// Load the first frame when we see it.
if (frame.turn == settings.turn) {
writableState.set({
frame: frame,
mode: PlaybackMode.PAUSED,
finalFrame: null
});
setCurrentFrame(settings.turn);
if (settings.autoplay) {
setTimeout(controls.play, AUTOPLAY_DELAY_MS);
}
}
};
const onFinalFrame = (frame: Frame) => {
writableState.update(($state) => {
if ($state) {
$state.finalFrame = frame;
}
return $state;
});
};
const onEngineError = (message: string) => {
playbackError.set(message);
};
function createPlaybackState() {
return {
controls,
subscribe: writableState.subscribe,
load: (fetchFunc: typeof fetch, s: Settings) => {
settings = { ...s };
fetchGame(fetchFunc, s.game, s.engine, frames, onFrameLoad, onFinalFrame, onEngineError);
},
reset
};
}
export const playbackState = createPlaybackState();

View file

@ -0,0 +1,98 @@
export type Point = {
x: number;
y: number;
};
export type Elimination = {
turn: number;
cause: string;
by: string;
};
export type Snake = {
id: string;
name: string;
author: string;
color: string;
head: string;
tail: string;
health: number;
latency: number;
body: Point[];
length: number;
elimination: Elimination | null;
// Helpers
isEliminated: boolean;
};
export type Frame = {
turn: number;
width: number;
height: number;
snakes: Snake[];
food: Point[];
hazards: Point[];
isFinalFrame: boolean;
};
export enum PlaybackMode {
PAUSED,
PLAYING
}
export type PlaybackHandler = () => void;
export type PlaybackState = {
frame: Frame;
mode: PlaybackMode;
finalFrame: null | Frame;
};
// We're lenient with typing data that's received from the game engine
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
type EngineObject = any;
export function engineEventToFrame(
engineGameInfo: EngineObject,
engineGameEvent: EngineObject
): Frame {
const engineCoordsToPoint = function (engineCoords: EngineObject): Point {
return { x: engineCoords.X, y: engineCoords.Y };
};
const engineSnakeToSnake = function (engineSnake: EngineObject): Snake {
return {
// Fixed properties
id: engineSnake.ID,
name: engineSnake.Name,
author: engineSnake.Author,
color: engineSnake.Color,
head: engineSnake.HeadType,
tail: engineSnake.TailType,
// Frame specific
health: engineSnake.Health,
latency: engineSnake.Latency,
body: engineSnake.Body.map(engineCoordsToPoint),
length: engineSnake.Body.length,
elimination: engineSnake.Death
? {
turn: engineSnake.Death.Turn,
cause: engineSnake.Death.Cause,
by: engineSnake.Death.EliminatedBy
}
: null,
// Helpers
isEliminated: engineSnake.Death != null
};
};
return {
turn: engineGameEvent.Turn,
width: engineGameInfo.Game.Width,
height: engineGameInfo.Game.Height,
snakes: engineGameEvent.Snakes.map(engineSnakeToSnake),
food: engineGameEvent.Food.map(engineCoordsToPoint),
hazards: engineGameEvent.Hazards.map(engineCoordsToPoint),
isFinalFrame: false
};
}

View file

@ -0,0 +1,45 @@
import { browser } from "$app/environment";
export function fromLocalStorage(key: string, defaultValue: boolean | number | string) {
if (browser) {
const val = localStorage.getItem(`setting.${key}`);
if (val) {
return JSON.parse(val);
}
}
return defaultValue;
}
export function toLocalStorage(key: string, value: boolean | number | string) {
if (browser) {
localStorage.setItem(`setting.${key}`, JSON.stringify(value));
}
}
export function getBoolFromURL(url: URL, key: string, defaultValue: boolean): boolean {
const val = url.searchParams.get(key);
if (val) {
if (val === "true") return true;
if (val === "false") return false;
}
return defaultValue;
}
export function getIntFromURL(url: URL, key: string, defaultValue: number): number {
const val = url.searchParams.get(key);
if (val) {
const parsedVal = parseInt(val);
if (!isNaN(parsedVal)) {
return parsedVal;
}
}
return defaultValue;
}
export function getStringFromURL(url: URL, key: string, defaultValue: string): string {
const val = url.searchParams.get(key);
if (val) {
return val;
}
return defaultValue;
}

View file

@ -0,0 +1,133 @@
import { get, writable } from "svelte/store";
import {
fromLocalStorage,
toLocalStorage,
getBoolFromURL,
getIntFromURL,
getStringFromURL
} from "./helpers";
import { setTheme } from "$lib/theme";
// Each setting receives it's value using the following algorithm:
// If url param is set, take value from URL.
// Else if setting is backed by local storage, take value from local storage.
// Else load default value from getDefaultSettings()
//
// Only a subset of settings are backed by local storage and user preference. This is by design.
// Keys for load from URL and local storage
export enum Setting {
AUTOPLAY = "autoplay",
ENGINE = "engine",
FPS = "fps",
GAME = "game",
LOOP = "loop",
SHOW_CONTROLS = "showControls",
SHOW_COORDS = "showCoords",
SHOW_SCRUBBER = "showScrubber",
SHOW_SCOREBOARD = "showScoreboard",
THEME = "theme",
TITLE = "title",
TURN = "turn"
}
export enum Theme {
DARK = "dark",
LIGHT = "light",
SYSTEM = "system"
}
export type Settings = {
autoplay: boolean;
engine: string;
fps: number;
game: string;
loop: boolean;
showControls: boolean;
showCoords: boolean;
showScrubber: boolean;
showScoreboard: boolean;
theme: Theme;
title: string;
turn: number;
};
export function getDefaultSettings(): Settings {
return {
autoplay: false,
engine: "https://engine.battlesnake.com",
fps: 6,
game: "",
loop: false,
showControls: true,
showCoords: false,
showScrubber: false,
showScoreboard: true,
theme: Theme.SYSTEM,
title: "",
turn: 0
};
}
// These settings are backed by user preference, stored in local storage
// Autoplay
export const autoplay = writable<boolean>(
fromLocalStorage(Setting.AUTOPLAY, getDefaultSettings().autoplay)
);
autoplay.subscribe((value: boolean) => {
toLocalStorage(Setting.AUTOPLAY, value);
});
// FPS
export const fps = writable<number>(fromLocalStorage(Setting.FPS, getDefaultSettings().fps));
fps.subscribe((value: number) => {
toLocalStorage(Setting.FPS, value);
});
// Show Coordinates
export const showCoords = writable<boolean>(
fromLocalStorage(Setting.SHOW_COORDS, getDefaultSettings().showCoords)
);
showCoords.subscribe((value: boolean) => {
toLocalStorage(Setting.SHOW_COORDS, value);
});
// Show Turn Scrubber
export const showScrubber = writable<boolean>(
fromLocalStorage(Setting.SHOW_SCRUBBER, getDefaultSettings().showScrubber)
);
showScrubber.subscribe((value: boolean) => {
toLocalStorage(Setting.SHOW_SCRUBBER, value);
});
// Theme
export const theme = writable<Theme>(fromLocalStorage(Setting.THEME, getDefaultSettings().theme));
theme.subscribe((value: Theme) => {
toLocalStorage(Setting.THEME, value);
setTheme(value);
});
// Load settings, with option to override via URL params
export function loadSettingsWithURLOverrides(url: URL): Settings {
const defaults = getDefaultSettings();
// Note that defaults are already baked into the settings backed by local storage
return {
// Preference controlled
autoplay: getBoolFromURL(url, Setting.AUTOPLAY, get(autoplay)),
fps: getIntFromURL(url, Setting.FPS, get(fps)),
showCoords: getBoolFromURL(url, Setting.SHOW_COORDS, get(showCoords)),
showScrubber: getBoolFromURL(url, Setting.SHOW_SCRUBBER, get(showScrubber)),
theme: getStringFromURL(url, Setting.THEME, get(theme)) as Theme,
// URL param controlled
engine: getStringFromURL(url, Setting.ENGINE, defaults.engine),
game: getStringFromURL(url, Setting.GAME, defaults.game),
loop: getBoolFromURL(url, Setting.LOOP, defaults.loop),
showControls: getBoolFromURL(url, Setting.SHOW_CONTROLS, defaults.showControls),
showScoreboard: getBoolFromURL(url, Setting.SHOW_SCOREBOARD, defaults.showScoreboard),
title: getStringFromURL(url, Setting.TITLE, defaults.title),
turn: getIntFromURL(url, Setting.TURN, defaults.turn)
};
}

View file

@ -0,0 +1,72 @@
import type { Point } from "$lib/playback/types";
// Parameters used when drawing the gameboard svg
export type SvgCalcParams = {
cellSize: number;
cellSizeHalf: number;
cellSpacing: number;
gridBorder: number;
height: number;
width: number;
};
// Declare a new type to make it more obvious when translating board space to svg space
export type SvgPoint = {
x: number;
y: number;
};
export type SvgCircleProps = {
cx: number;
cy: number;
};
export type SvgRectProps = {
x: number;
y: number;
width: number;
height: number;
};
export function svgCalcCellCenter(params: SvgCalcParams, p: Point): SvgPoint {
const topLeft = svgCalcCellTopLeft(params, p);
return {
x: topLeft.x + params.cellSizeHalf,
y: topLeft.y + params.cellSizeHalf
};
}
export function svgCalcCellTopLeft(params: SvgCalcParams, p: Point): SvgPoint {
return {
x: params.gridBorder + p.x * (params.cellSize + params.cellSpacing),
y:
params.height -
(params.gridBorder + p.y * (params.cellSize + params.cellSpacing) + params.cellSize)
};
}
export function svgCalcCellCircle(params: SvgCalcParams, p: Point): SvgCircleProps {
const center = svgCalcCellCenter(params, p);
return { cx: center.x, cy: center.y };
}
export function svgCalcCellRect(params: SvgCalcParams, p: Point): SvgRectProps {
const topLeft = svgCalcCellTopLeft(params, p);
return { x: topLeft.x, y: topLeft.y, width: params.cellSize, height: params.cellSize };
}
export function svgCalcCellLabelBottom(params: SvgCalcParams, p: Point): SvgPoint {
const center = svgCalcCellCenter(params, p);
return {
x: center.x,
y: center.y + params.cellSizeHalf + params.gridBorder / 2
};
}
export function svgCalcCellLabelLeft(params: SvgCalcParams, p: Point): SvgPoint {
const center = svgCalcCellCenter(params, p);
return {
x: center.x - params.cellSizeHalf - params.gridBorder / 2,
y: center.y
};
}

View file

@ -0,0 +1,19 @@
import { browser } from "$app/environment";
import { Theme } from "$lib/settings/stores";
export function setTheme(theme: Theme) {
if (browser) {
if (theme == Theme.DARK) {
document.documentElement.classList.add("dark");
} else if (theme == Theme.LIGHT) {
document.documentElement.classList.remove("dark");
} else if (theme == Theme.SYSTEM) {
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
}
}