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
Binary file modified bun.lockb
Binary file not shown.
11 changes: 7 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@
"build:tailwind": "tailwindcss -i ./src/tailwind.css -o ./public/tailwind.css",
"build:server": "bun build --target=bun ./src/index.tsx --outfile=dist/index.js",
"build:client": "bun build --target=bun ./src/client/client.ts --outdir ./public",
"build": "bun run --target=bun build:server && npm run build:tailwind",
"build": "bun run --target=bun build:server && bun run --target=bun build:tailwind && bun run --target=bun build:client",
"format:check": "biome format ./src",
"format": "biome format ./src --write",
"lint": "biome lint ./src",
"check": "biome check --write ./src",
"typecheck": "tsc --noEmit",
"benchmark": "bun run --target=bun tooling/benchmark.ts",
"simulate": "bun run --target=bun tooling/simulate.ts"
"simulate": "bun run build && bun run --target=bun tooling/simulate.ts"
},
"dependencies": {
"@elysiajs/static": "^1.0.3",
Expand All @@ -29,7 +29,10 @@
"chartjs-adapter-date-fns": "^3.0.0",
"elysia": "^1.0.24",
"html-escaper": "^3.0.3",
"htmx.org": "^1.9.12",
"htmx-ext-response-targets": "^2.0.0",
"htmx-ext-sse": "^2.2.1",
"htmx.org": "2.0.1",
"hyperscript.org": "^0.9.12",
"lit": "^3.1.4"
},
"devDependencies": {
Expand All @@ -38,7 +41,7 @@
"@total-typescript/shoehorn": "^0.1.2",
"@types/bun": "^1.1.5",
"@types/html-escaper": "^3.0.2",
"bun-types": "latest",
"bun-types": "1.1.21",
"concurrently": "^8.2.2",
"daisyui": "^4.12.8",
"mitata": "^0.1.11",
Expand Down
126 changes: 74 additions & 52 deletions src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,49 +5,70 @@ import {
type Point,
registerables,
} from "chart.js";
import * as htmx from "htmx.org";
import "chartjs-adapter-date-fns";
import { CmcCounter } from "./counter";
import { startConfetti } from "./confetti";

// First some type overrides
declare global {
interface Window {
htmx: typeof htmx;
}
}

// Then we register some chart plugins and web components
Chart.register(...registerables);
customElements.define("cmc-counter", CmcCounter);

// Then define some functions to sort the scoreboard and render the chart
function sortScoreboard() {
const list = document.getElementById("scoreboard-list");
if (!list) {
return;
}
/**
* Define a type for holding a map of sort functions.
* Each function accepts two strings and returns a number.
* The key is the name of the function.
*/
type SortFunctions = {
[key: string]: (a: string, b: string) => number;
};

/**
* A map of sort function implementations.
* Allows us to sort elements on different criteria.
* For example, we can sort elements by score or by localeCompare
* if values are nicknames. If values are stringly numbers we
* can sort them by the numeric value.
*/
const sortFunctions: SortFunctions = {
score: (a, b) => Number.parseInt(b) - Number.parseInt(a),
localeCompare: (a, b) => a.localeCompare(b),
};

/**
* Sorts all elements with the attribute "sort".
* The value of the attribute specifies the value in each child
* to use for sorting. The optional attribute "sortFn" specifies
* the name of the function to use for sorting.
*
* Fallbacks to "localeCompare" if the function is not found.
* We simply remove the elements in the list and re-append them
* in the sorted order.
*
* Works with auto-animate to animate the sorting.
*/
function sortSortable() {
// First find all elements with attribute "sort"
const sortableElements = Array.from(document.querySelectorAll("[sort]"));

// For each sortable element we sort the children by the attribute "sortkey"
for (const sortableElement of sortableElements) {
const sortAttr = sortableElement.getAttribute("sort") ?? "";
const fnName = sortableElement.getAttribute("sortFn") ?? "";
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
const sortFn = sortFunctions[fnName] ?? sortFunctions["localeCompare"];
const items = Array.from(sortableElement.children);

items.sort((a, b) => {
const sortValueA = a.getAttribute(sortAttr) ?? "";
const sortValueB = b.getAttribute(sortAttr) ?? "";
return sortFn(sortValueA, sortValueB);
});

const items = Array.from(list.children);

items.sort((a, b) => {
const scoreA = Number.parseInt(
a.querySelector("span")?.innerText ?? "0",
10,
);
const scoreB = Number.parseInt(
b.querySelector("span")?.innerText ?? "0",
10,
);

const randomNumberBetweenMinus1And1 = Math.random() * 2 - 1;
return scoreB - (scoreA + randomNumberBetweenMinus1And1); // Sort descending order (highest score first)
});

// Clear the list and re-append sorted items
list.innerHTML = "";
for (const item of items) {
list.appendChild(item);
// Clear the list and re-append sorted items
sortableElement.innerHTML = "";
for (const item of items) {
sortableElement.appendChild(item);
}
}
}

Expand All @@ -61,12 +82,12 @@ let chart: Chart<"line">;

function cleanChart() {
const now = new Date().getTime();
const fiveMinutesAgo = now - 5 * 60 * 1000;

for (const dataset of chart.data.datasets) {
dataset.data = dataset.data.filter((point) => {
const x = (point as Point).x;
console.log("X", now - x < 5 * 60 * 1000);
return now - x < 5 * 60 * 1000;
return x >= fiveMinutesAgo;
});
}
chart.update();
Expand Down Expand Up @@ -146,11 +167,12 @@ function renderChart(datasets: ChartConfiguration<"line">["data"]["datasets"]) {

window.renderChart = renderChart;

htmx.onLoad(() => {
function htmxOnLoad() {
// Find all elements with the auto-animate class and animate them
for (const element of Array.from(
document.querySelectorAll(".auto-animate"),
)) {
console.info("Auto animating", element);
autoAnimate(element as HTMLElement);
}

Expand All @@ -161,26 +183,18 @@ htmx.onLoad(() => {
if (confettiCanvas) {
startConfetti(confettiCanvas);
}
});
}

// On SSE messages do DOM-manipulation where necessary
htmx.on("htmx:sseMessage", (evt) => {
// If player score changes sort the scoreboard
if (
evt instanceof CustomEvent &&
evt.detail.type.startsWith("player-score-")
) {
// Sort the scoreboard when a player's score changes
sortScoreboard();
}
function htmxSSEMessage(evt: Event) {
// On SSE messages do DOM-manipulation where necessary
sortSortable();

// If the event is a player-score-chart event, update the chart with the new score data
if (
evt instanceof CustomEvent &&
evt.detail.type.startsWith("player-score-chart-")
evt.detail.type.startsWith("player-score-chart")
) {
const nick = evt.detail.type.replace("player-score-chart-", "");
const data = JSON.parse(evt.detail.data);
const { nick, ...data } = JSON.parse(evt.detail.data);
const dataset = chart.data.datasets.find((d) => d.label === nick);

if (dataset) {
Expand All @@ -205,4 +219,12 @@ htmx.on("htmx:sseMessage", (evt) => {
chart.update();
}
}
});
}

window.onload = () => {
console.info("Running client.ts window.onload setup function");
document.body.addEventListener("htmx:load", htmxOnLoad);
document.body.addEventListener("htmx:sseMessage", htmxSSEMessage);

htmxOnLoad();
};
2 changes: 1 addition & 1 deletion src/game/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ const newWorker = (player: Player) => {
const player = state.players.find((p) => p.uuid === uuid);
if (player) {
player.log.unshift(log);
player.score += log.score;
player.score = log.score;
}
// We need to notify all relevant listeners about the new log
// Multiple listeners can be listening for the same player
Expand Down
7 changes: 4 additions & 3 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ const app = new Elysia({
Bun.file("node_modules/htmx.org/dist/htmx.min.js"),
)
.get("/public/response-targets.js", () =>
Bun.file("node_modules/htmx.org/dist/ext/response-targets.js"),
Bun.file("node_modules/htmx-ext-response-targets/response-targets.js"),
)
.get("/public/sse.js", () =>
Bun.file("node_modules/htmx.org/dist/ext/sse.js"),
.get("/public/sse.js", () => Bun.file("node_modules/htmx-ext-sse/sse.js"))
.get("/public/hyperscript.js", () =>
Bun.file("node_modules/hyperscript.org/dist/_hyperscript.min.js"),
)
.use(adminPlugin)
.use(homePlugin)
Expand Down
1 change: 1 addition & 0 deletions src/layouts/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const HTMLLayout = ({ page, header, children }: LayoutProps) => {
<script src="/public/htmx.min.js" />
<script src="/public/response-targets.js" />
<script src="/public/sse.js" />
<script src="/public/hyperscript.js" />
<script src="/public/client.js" />
</head>
<body class="bg-base-100" hx-ext="response-targets" hx-boost="true">
Expand Down
5 changes: 3 additions & 2 deletions src/pages/admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ const Round = ({ state }: RoundProps) => {
const PlayerRow = (player: Player) => {
const rowId = `player-${player.uuid}`;
return (
<tr class="odd:bg-base-300" id={rowId}>
<tr class="odd:bg-base-300" id={rowId} attrs={{ nick: player.nick }}>
<td>
<a
class="link link-hover"
Expand Down Expand Up @@ -310,12 +310,13 @@ const Admin = ({ state }: AdminProps) => {
</tr>
</thead>
<tbody
attrs={{ sort: "nick" }} // Enable auto-sorting
class="auto-animate"
sse-swap="player-joined"
hx-swap="afterbegin"
>
{state.players
.toSorted((a, b) => a.score - b.score)
.toSorted((a, b) => a.nick.localeCompare(b.nick))
.map((player) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: Don't need it here
<PlayerRow {...player} />
Expand Down
39 changes: 25 additions & 14 deletions src/pages/scoreboard/scoreboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ import { basePluginSetup } from "../../plugins";
const PlayerRow = ({ player }: { player: Player }) => {
return (
<li
attrs={{ score: player.score }}
class="flex flex-row justify-between bg-base-300 px-4 py-2 rounded-2xl bg-opacity-30 shadow-lg z-1"
id={`player-${player.nick}`}
sse-swap={`player-left-${player.nick}`}
hx-swap="delete"
// On htmx:sseMessage event, if event matches player-score-${player.nick} update attrs score with the new score
_={`on htmx:sseMessage(event) if event.detail.type === "player-score-${player.nick}" set @score to event.detail.data end`}
>
<h2 class={`text-xl ${player.color.class}`} safe>
<h2 safe class={`text-xl ${player.color.class}`}>
{player.nick}
</h2>
<span
Expand All @@ -23,10 +24,10 @@ const PlayerRow = ({ player }: { player: Player }) => {
>
{player.score}
</span>
<span // Use a hidden element to swap the chart data, don't actually swap json into the DOM
class="hidden"
sse-swap={`player-score-chart-${player.nick}`}
hx-swap="none"
<span // Use a hidden element to remove the player element from the score list
sse-swap={`player-left-${player.nick}`}
hx-swap="delete"
hx-target={`player-${player.nick}`}
/>
</li>
);
Expand All @@ -39,15 +40,18 @@ interface PlayerTableProps {
const PlayerList = ({ players }: PlayerTableProps) => {
return (
<ul
id="scoreboard-list"
// Sort here is a selector
attrs={{ sort: "score", sortFn: "score" }}
class="auto-animate flex flex-col gap-2 z-10 bg-opacity-80 backdrop-blur-sm drop-shadow-lg max-h-[80vh] overflow-y-scroll scrollbar-w-none"
sse-swap="player-joined"
hx-swap="afterbegin"
>
{players.map((player) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<PlayerRow player={player} />
))}
{players
.toSorted((a, b) => b.score - a.score)
.map((player) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: <explanation>
<PlayerRow player={player} />
))}
</ul>
);
};
Expand Down Expand Up @@ -108,6 +112,11 @@ export const scoreboardPlugin = basePluginSetup()
<div class="chart-container min-h-screen min-w-screen max-h-screen max-w-screen absolute inset-0 pt-24">
<div class="epic-dark" />
<canvas id="score-board-chart" class="relative" />
<span // Use a hidden element to swap the chart data, don't actually swap json into the DOM
class="hidden"
sse-swap="player-score-chart"
hx-swap="none"
/>
</div>
<div class="min-h-screen min-w-screen max-h-screen max-w-screen absolute inset-0 pt-24 flex flex-col items-center text-center justify-center">
<GameStatus status={state.status} />
Expand Down Expand Up @@ -171,9 +180,10 @@ export const scoreboardPlugin = basePluginSetup()
data: `${event.log.score}`,
},
{
event: `player-score-chart-${event.nick}`,
event: "player-score-chart",
data: JSON.stringify({
x: new Date(event.log.date).toISOString(),
nick: event.nick,
x: event.log.date,
y: event.log.score,
}),
},
Expand Down Expand Up @@ -215,6 +225,7 @@ export const scoreboardPlugin = basePluginSetup()
return [
{
event: `player-left-${event.nick}`,
data: "",
},
{
event: "player-left-chart",
Expand Down