Skip to content

Commit

Permalink
feat: Winner page when game is ended (#26)
Browse files Browse the repository at this point in the history
Add a new page announcing the winners when game is over. The page
displays the three highest ranked player with medal emojis and score.
Shows confetti in the background to add some cheer to the game.

Add game status indicator to the scoreboard page with live updates using
SSE. When a game is stopped, paused or continued the status message
changes. In the case of the game starting the status message
disappears. When the status is game ended we navigate to the winner page
with the confetti.
  • Loading branch information
snorremd committed Jul 19, 2024
1 parent 2634212 commit 62673ff
Show file tree
Hide file tree
Showing 7 changed files with 303 additions and 12 deletions.
9 changes: 9 additions & 0 deletions src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
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 {
Expand Down Expand Up @@ -152,6 +153,14 @@ htmx.onLoad(() => {
)) {
autoAnimate(element as HTMLElement);
}

// If the confetti canvas is present, start the confetti animation
const confettiCanvas = document.getElementById(
"confetti",
) as HTMLCanvasElement | null;
if (confettiCanvas) {
startConfetti(confettiCanvas);
}
});

// On SSE messages do DOM-manipulation where necessary
Expand Down
136 changes: 136 additions & 0 deletions src/client/confetti.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* This module contains logic to render confetti on the screen.
* It draws to a canvas element and draws confetti using Canvas
* draw paths.
*
* Each confetti is modeled as a particle with position coordinates,
* color, tilt, rotation and other properties. We use requestAnimationFrame
* to make a render loop that updates the position of each confetti particle
* and redraws the canvas.
*/

/** Number of confettis to draw */
const confettiCount = 500;

/** Adjust the vertical speed of the confetti */
const speedFactor = -2;

/** Confetti colors from Daisy UI */
const colors = [
"#1fb2a6", // Primary
"#e879f9", // Secondary
"#fcd34d", // Accent
"#f87171", // Neutral
"#60a5fa", // Info
"#34d399", // Success
"#fb923c", // Warning
"#f87171", // Error
] as const;

type Color = (typeof colors)[number];

/**
* The TypeScript interface for a confetti particle.
* Defines properties and methods for a single confetti particle.
*/
interface ConfettiParticle {
/** Canvas element to draw to */
canvas: HTMLCanvasElement;
/** The Canvas 2D context */
ctx: CanvasRenderingContext2D;
/** x-coordinate of the particle */
x: number;
/** y-coordinate of the particle */
y: number;
/** Radius of the particle */
r: number;
/** Affects vertical and horizontal velocity/speed */
d: number;
/** Color of the particle */
color: Color;
/** Tilt of the particle */
tilt: number;
/** Draw the particle on the canvas */
draw: () => void;
}

class ConfettiParticleImpl implements ConfettiParticle {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
x: number;
y: number;
r: number;
d: number;
color: Color;
tilt: number;

/**
* Create a new confetti particle with random position and movement properties.
* @param canvas The canvas element to draw the confetti particle on
* @param confettiCount The index count of this confetti particle to introduce randomness
*/
constructor(canvas: HTMLCanvasElement, confettiCount: number) {
this.canvas = canvas;
// biome-ignore lint/style/noNonNullAssertion: <explanation>
this.ctx = canvas.getContext("2d")!;
this.x = Math.random() * this.canvas.width;
this.y = Math.random() * this.canvas.height - canvas.height;
this.r = Math.random() * 10 + 10;
this.d = Math.random() * confettiCount;
this.color = colors[Math.floor(Math.random() * colors.length)];
this.tilt = Math.floor(Math.random() * 10) - 10;
}

draw() {
// Draw the confetti particle using a path
this.ctx.beginPath(); // Start a new path
this.ctx.lineWidth = this.r / 2; // Sets thickness for lines in this 2D Canvas Context to half the radius of the particle
this.ctx.strokeStyle = this.color; // Sets the color used to stroke the lines of the path to the particle's color
this.ctx.moveTo(this.x + this.tilt + this.r / 4, this.y); // Begin new sub-path at the left edge of the particle
this.ctx.lineTo(this.x + this.tilt, this.y + this.tilt + this.r / 4); // Add a line to the right edge of the particle
this.ctx.stroke(); // Draw the stroke of the path using defined styles
}
}

/**
* The main function to start the confetti animation.
* Accepts a canvas element to draw the confetti on.
*
*
* @param canvas the canvas element to draw the confetti on
*/
export function startConfetti(canvas: HTMLCanvasElement) {
// biome-ignore lint/style/noNonNullAssertion: 2d context is always available
const ctx = canvas.getContext("2d")!;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

// Create an array of confetti particles
const confettis: ConfettiParticle[] = [];

for (let i = 0; i < confettiCount; i++) {
confettis.push(new ConfettiParticleImpl(canvas, i));
}

// Inner confetti loop function to draw the confetti on each requestAnimationFrame
function drawConfetti() {
requestAnimationFrame(drawConfetti);
ctx.clearRect(0, 0, canvas.width, canvas.height);

for (let i = 0; i < confettiCount; i++) {
const confetti = confettis[i];
confetti.y += (Math.cos(confetti.d) + speedFactor + confetti.r / 2) / 2;
confetti.x += Math.sin(confetti.d);

confetti.draw();

if (confetti.y > canvas.height) {
confetti.x = Math.random() * canvas.width;
confetti.y = -20;
confetti.tilt = Math.floor(Math.random() * 10) - 10;
}
}
}

drawConfetti();
}
10 changes: 10 additions & 0 deletions src/game/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ interface GamePaused {
type: "game-paused";
}

/**
* Event sent to player worker thread when the game ends.
* This event is sent when the game is over and a winner should be declared.
* The worker thread should be terminated after this event.
*/
interface GameEnded {
type: "game-ended";
}

/**
* Event sent to player worker thread when the game continues
* after being paused.
Expand Down Expand Up @@ -103,6 +112,7 @@ export type MainWorkerEvent =
| PlayerChangeUrl
| GameStarted
| GameStopped
| GameEnded
| GamePaused
| GameContinued
| ChangeRound;
Expand Down
59 changes: 53 additions & 6 deletions src/game/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const stateLocation = Bun.env["CMC_STATE_FILE"] ?? "./state.json";
const maxRounds = Math.floor(gameQuestions.length / 2);

// Setup types for the game state including player logs, workers, etc
export type GameStatus = "playing" | "paused" | "stopped";
export type GameStatus = "playing" | "paused" | "stopped" | "ended";
export type GameMode = "demo" | "game";

export interface PlayerLog {
Expand Down Expand Up @@ -224,18 +224,48 @@ export const startGame = (state: State, mode: State["mode"]) => {
state.gameStartedAt = new Date().toISOString();
state.roundStartedAt = new Date().toISOString();

const event = {
type: "game-started",
mode: mode ?? "demo",
round: state.round,
} satisfies GameEvent;

// Notify all player workers that the game has started
for (const player of state.players) {
player.worker = newWorker(player);
player.worker?.postMessage({
type: "game-started",
mode: mode ?? "demo",
round: state.round,
});
player.worker?.postMessage(event);
}
saveState(state);

for (const listener of Object.values(state.uiListeners)) {
listener(event);
}
};

/**
* Ends the current game and announces the winner.
* Terminates the player worker loops, but does not reset game state.
* At this point the game is considered over.
*/
export const endGame = (state: State) => {
state.status = "ended";
for (const player of state.players) {
player.worker?.postMessage({ type: "game-ended" });
}

saveState(state);

for (const listener of Object.values(state.uiListeners)) {
listener({ type: "game-ended" });
}
};

/**
* Stops the current game without announcing the winner.
* Terminates the player worker loops, but does not reset player states.
* Resets game information about round, time played, etc.
* @param state The current game state
*/
export const stopGame = (state: State) => {
state.status = "stopped";
state.round = 0;
Expand All @@ -246,15 +276,28 @@ export const stopGame = (state: State) => {
}

saveState(state);

for (const listener of Object.values(state.uiListeners)) {
listener({ type: "game-stopped" });
}
};

/**
* Pauses the current game, terminates the player workers loops,
* but does not reset any game state information.
* @param state
*/
export const pauseGame = (state: State) => {
state.status = "paused";
for (const player of state.players) {
player.worker?.postMessage({ type: "game-paused" });
}

saveState(state);

for (const listener of Object.values(state.uiListeners)) {
listener({ type: "game-paused" });
}
};

export const resetGame = (state: State) => {
Expand Down Expand Up @@ -287,6 +330,10 @@ export const continueGame = (state: State) => {
}

saveState(state);

for (const listener of Object.values(state.uiListeners)) {
listener({ type: "game-continued" });
}
};

export const nextRound = (state: State) => {
Expand Down
4 changes: 4 additions & 0 deletions src/game/workers/player-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ self.onmessage = (event: MessageEvent<MainWorkerEvent>) => {
gameStopped(workerState);
quit = true;
break;
case "game-ended":
gameStopped(workerState);
quit = true;
break;
case "game-paused":
gamePaused(workerState);
break;
Expand Down
25 changes: 21 additions & 4 deletions src/pages/admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
resetGame,
startGame,
stopGame,
endGame,
} from "../game/state";
import { mapValidationError } from "../helpers/helpers";
import { HTMLLayout, HXLayout } from "../layouts/main";
Expand Down Expand Up @@ -108,7 +109,7 @@ const PlayOrStop = ({ state }: RoundProps) => {

return (
<form class="flex flex-row gap-8" method="POST" action="" hx-target="this">
{status === "stopped" ? (
{status === "stopped" || status === "ended" ? (
<>
<div class="flex flex-row gap-2">
<button
Expand Down Expand Up @@ -164,6 +165,13 @@ const PlayOrStop = ({ state }: RoundProps) => {
>
Stop Game
</button>
<button
type="submit"
formaction="/admin/end-game"
class="btn btn-outline btn-success"
>
End game
</button>
</>
) : null}
{status === "paused" ? (
Expand Down Expand Up @@ -193,13 +201,13 @@ const Round = ({ state }: RoundProps) => {
&nbsp;of&nbsp;
{total}
</p>
<form class="" hx-trigger="click">
<form>
<div class="flex flex-row join gap-[1px]">
<button
class="join-item btn btn-outline btn-error"
type="submit"
action="/admin/previous-round"
disabled={status === "stopped" || round === 0}
disabled={status === "stopped" || round === 1}
hx-post="/admin/previous-round"
>
Previous Round
Expand All @@ -209,7 +217,7 @@ const Round = ({ state }: RoundProps) => {
class="join-item btn btn-outline btn-success"
type="submit"
action="/admin/next-round"
disabled={status === "stopped" || round === total - 1}
disabled={status === "stopped" || round === total}
hx-post="/admin/next-round"
>
Next Round
Expand Down Expand Up @@ -434,6 +442,15 @@ export const plugin = basePluginSetup()
}),
},
)
.post("/admin/end-game", ({ htmx, set, store: { state } }) => {
endGame(state);

if (htmx.is) {
htmx.location({ path: "/admin", target: "#main" });
} else {
set.redirect = "/admin";
}
})
.post("/admin/stop-game", ({ htmx, set, store: { state } }) => {
stopGame(state);

Expand Down
Loading

0 comments on commit 62673ff

Please sign in to comment.