refactor(web): migrate from Vike+React to SvelteKit

- Replace Vike+React with SvelteKit for simpler SSR and routing
- Update WASM build output paths from public/ to static/
- Add wasm-opt integration for WASM size optimization
- Streamline tooling: remove ESLint, Prettier configs (use defaults)
- Move build.rs to pacman-server/ (frontend no longer needs it)
This commit is contained in:
2025-12-30 02:15:42 -06:00
parent 1c333c83fc
commit a636870661
52 changed files with 2291 additions and 2039 deletions
+194
View File
@@ -0,0 +1,194 @@
@import 'tailwindcss';
/* Custom subsetted fonts (glyphhanger-optimized) */
@import '$lib/fonts.css';
/* OverlayScrollbars styles */
@import 'overlayscrollbars/overlayscrollbars.css';
/* View Transitions API - page transition animations */
::view-transition-group(root) {
background: black;
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 150ms;
animation-timing-function: ease-out;
mix-blend-mode: normal;
}
@keyframes fade-out {
to {
opacity: 0;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
}
@layer base {
:root {
font-family:
'Outfit',
ui-sans-serif,
system-ui,
-apple-system,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
'Noto Sans',
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol',
sans-serif;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 500;
}
/* OverlayScrollbars theme */
.os-scrollbar-handle {
background: rgb(250 204 21 / 0.25) !important;
}
.os-scrollbar-handle:hover {
background: rgb(250 204 21 / 0.4) !important;
}
.os-scrollbar-track {
background: transparent !important;
}
}
@keyframes glimmer {
0% {
background-position: -200% center;
}
100% {
background-position: 200% center;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Loading spinner */
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid rgb(250 204 21 / 0.3);
border-top-color: rgb(250 204 21);
border-radius: 50%;
animation: spin 1s linear infinite;
}
/* Error indicator */
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
20%,
60% {
transform: translateX(-4px);
}
40%,
80% {
transform: translateX(4px);
}
}
.error-indicator {
width: 48px;
height: 48px;
position: relative;
animation: shake 0.5s ease-out;
}
.error-indicator::before,
.error-indicator::after {
content: '';
position: absolute;
width: 4px;
height: 32px;
background: rgb(239 68 68);
border-radius: 2px;
top: 50%;
left: 50%;
}
.error-indicator::before {
transform: translate(-50%, -50%) rotate(45deg);
}
.error-indicator::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
@layer utilities {
.page-container {
@apply mx-auto max-w-3xl py-8 px-4;
}
.card {
@apply border border-yellow-400/20 rounded-md bg-transparent p-6 shadow-[0_4px_20px_rgba(250,204,21,0.08)];
}
.title-hover {
transition:
transform 0.2s ease-out,
filter 0.2s ease-out;
}
.title-hover:hover {
transform: scale(1.03);
filter: brightness(1.15);
}
.title-base {
position: relative;
}
.title-base::before {
content: attr(data-text);
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
rgb(156 163 175) 0%,
rgb(156 163 175) 35%,
rgb(250 204 21) 45%,
rgb(250 204 21) 55%,
rgb(156 163 175) 65%,
rgb(156 163 175) 100%
);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: glimmer 3s ease-in-out infinite;
opacity: 0;
transition: opacity 0.2s ease-out;
}
.title-base.title-glimmer::before {
opacity: 1;
}
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+34
View File
@@ -0,0 +1,34 @@
/* Auto-generated by vite-plugin-font-subset */
/* Do not edit manually - changes will be overwritten */
/* Subsetted fonts for optimal loading */
/* Russo One 400 - Only contains: PACMN- */
@font-face {
font-family: 'Russo One';
font-weight: 400;
font-style: normal;
font-display: swap;
src: url('/fonts/russo-one-400-normal-subset.woff2') format('woff2');
unicode-range: U+2D, U+41, U+43, U+4D-4E, U+50;
}
/* Outfit 400 - letters, numbers, punctuation */
@font-face {
font-family: 'Outfit';
font-weight: 400;
font-style: normal;
font-display: swap;
src: url('/fonts/outfit-400-normal-subset.woff2') format('woff2');
unicode-range: U+20-21, U+23, U+25-5A, U+5F, U+61-7A;
}
/* Outfit 500 - letters, numbers, punctuation */
@font-face {
font-family: 'Outfit';
font-weight: 500;
font-style: normal;
font-display: swap;
src: url('/fonts/outfit-500-normal-subset.woff2') format('woff2');
unicode-range: U+20-21, U+23, U+25-5A, U+5F, U+61-7A;
}
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+216
View File
@@ -0,0 +1,216 @@
export interface LeaderboardEntry {
id: number;
rank: number;
name: string;
score: number;
duration: string;
levelCount: number;
submittedAt: string;
avatar?: string;
}
export const mockGlobalData: LeaderboardEntry[] = [
{
id: 1,
rank: 1,
name: 'PacMaster2024',
score: 125000,
duration: '45:32',
levelCount: 12,
submittedAt: '2 hours ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=PacMaster2024'
},
{
id: 2,
rank: 2,
name: 'GhostHunter',
score: 118750,
duration: '42:18',
levelCount: 11,
submittedAt: '5 hours ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=GhostHunter'
},
{
id: 3,
rank: 3,
name: 'DotCollector',
score: 112500,
duration: '38:45',
levelCount: 10,
submittedAt: '1 day ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=DotCollector'
},
{
id: 4,
rank: 4,
name: 'MazeRunner',
score: 108900,
duration: '41:12',
levelCount: 10,
submittedAt: '2 days ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=MazeRunner'
},
{
id: 5,
rank: 5,
name: 'PowerPellet',
score: 102300,
duration: '36:28',
levelCount: 9,
submittedAt: '3 days ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=PowerPellet'
},
{
id: 6,
rank: 6,
name: 'CherryPicker',
score: 98750,
duration: '39:15',
levelCount: 9,
submittedAt: '4 days ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=CherryPicker'
},
{
id: 7,
rank: 7,
name: 'BlinkyBeater',
score: 94500,
duration: '35:42',
levelCount: 8,
submittedAt: '5 days ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=BlinkyBeater'
},
{
id: 8,
rank: 8,
name: 'PinkyPac',
score: 91200,
duration: '37:55',
levelCount: 8,
submittedAt: '1 week ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=PinkyPac'
},
{
id: 9,
rank: 9,
name: 'InkyDestroyer',
score: 88800,
duration: '34:18',
levelCount: 8,
submittedAt: '1 week ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=InkyDestroyer'
},
{
id: 10,
rank: 10,
name: 'ClydeChaser',
score: 85600,
duration: '33:45',
levelCount: 7,
submittedAt: '1 week ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=ClydeChaser'
}
];
export const mockMonthlyData: LeaderboardEntry[] = [
{
id: 1,
rank: 1,
name: 'JanuaryChamp',
score: 115000,
duration: '43:22',
levelCount: 11,
submittedAt: '1 day ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=JanuaryChamp'
},
{
id: 2,
rank: 2,
name: 'NewYearPac',
score: 108500,
duration: '40:15',
levelCount: 10,
submittedAt: '3 days ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=NewYearPac'
},
{
id: 3,
rank: 3,
name: 'WinterWarrior',
score: 102000,
duration: '38:30',
levelCount: 10,
submittedAt: '5 days ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=WinterWarrior'
},
{
id: 4,
rank: 4,
name: 'FrostyPac',
score: 98500,
duration: '37:45',
levelCount: 9,
submittedAt: '1 week ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=FrostyPac'
},
{
id: 5,
rank: 5,
name: 'IceBreaker',
score: 95200,
duration: '36:12',
levelCount: 9,
submittedAt: '1 week ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=IceBreaker'
},
{
id: 6,
rank: 6,
name: 'SnowPac',
score: 91800,
duration: '35:28',
levelCount: 8,
submittedAt: '2 weeks ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=SnowPac'
},
{
id: 7,
rank: 7,
name: 'BlizzardBeast',
score: 88500,
duration: '34:15',
levelCount: 8,
submittedAt: '2 weeks ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=BlizzardBeast'
},
{
id: 8,
rank: 8,
name: 'ColdSnap',
score: 85200,
duration: '33:42',
levelCount: 8,
submittedAt: '3 weeks ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=ColdSnap'
},
{
id: 9,
rank: 9,
name: 'FrozenFury',
score: 81900,
duration: '32:55',
levelCount: 7,
submittedAt: '3 weeks ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=FrozenFury'
},
{
id: 10,
rank: 10,
name: 'ArcticAce',
score: 78600,
duration: '31:18',
levelCount: 7,
submittedAt: '4 weeks ago',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=ArcticAce'
}
];
+30
View File
@@ -0,0 +1,30 @@
export interface PacmanModule {
canvas: HTMLCanvasElement;
// Restrict keyboard capture to this element (default: document)
keyboardListeningElement?: HTMLElement;
_start_game?: () => void;
_stop_game?: () => void;
_restart_game?: () => void;
locateFile: (path: string) => string;
preRun: unknown[];
// Emscripten lifecycle hooks
onAbort?: (what: unknown) => void;
onRuntimeInitialized?: () => void;
monitorRunDependencies?: (left: number) => void;
// Preloaded data file provider - called by Emscripten's file packager
getPreloadedPackage?: (name: string, size: number) => ArrayBuffer;
}
export type LoadingError =
| { type: 'timeout' }
| { type: 'script'; message: string }
| { type: 'runtime'; message: string };
export interface PacmanWindow extends Window {
Module?: PacmanModule;
pacmanReady?: () => void;
pacmanError?: (error: LoadingError) => void;
SDL_CANVAS_ID?: string;
}
export const getPacmanWindow = (): PacmanWindow => window as unknown as PacmanWindow;
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import { page } from '$app/stores';
</script>
<div class="flex flex-col items-center justify-center min-h-[50vh] text-center px-4">
<h1 class="text-4xl font-bold mb-4">
{$page.status === 404 ? 'Page Not Found' : 'Internal Error'}
</h1>
<p class="text-gray-400">
{$page.status === 404 ? 'This page could not be found.' : 'Something went wrong.'}
</p>
</div>
+281
View File
@@ -0,0 +1,281 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { onNavigate } from '$app/navigation';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-svelte';
import {
IconBrandGithub,
IconDownload,
IconDeviceGamepad3,
IconTrophy
} from '@tabler/icons-svelte';
let { children } = $props();
let opened = $state(false);
let mounted = $state(false);
// Keys that the game uses - only these should reach SDL/Emscripten on the play page
const GAME_KEYS = new Set([
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'w',
'W',
'a',
'A',
's',
'S',
'd',
'D',
'Escape',
' ',
'm',
'M',
'r',
'R',
't',
'T'
]);
onMount(() => {
mounted = true;
// Global keyboard filter to prevent SDL/Emscripten from capturing keys.
// SDL's handlers persist globally even after navigating away from the play page.
// This filter ensures browser shortcuts (F5, F12, Ctrl+R, etc.) always work.
const filterKeyEvent = (event: KeyboardEvent) => {
const isPlayPage = window.location.pathname === '/';
const canvas = document.getElementById('canvas');
// On non-play pages, block ALL keys from reaching SDL
if (!isPlayPage) {
event.stopPropagation();
return;
}
// On play page: nuanced filtering
// Tab: blur canvas and let browser handle focus navigation
if (event.key === 'Tab') {
if (document.activeElement === canvas && canvas) {
canvas.blur();
// Focus first tabbable element in the header
const firstLink = document.querySelector('header a') as HTMLElement | null;
if (firstLink && !event.shiftKey) {
firstLink.focus();
event.preventDefault();
}
}
event.stopPropagation();
return;
}
// Escape: let it through to game (for pause) but also blur canvas
if (event.key === 'Escape') {
canvas?.blur();
return; // Don't stop propagation - game still receives it for pause
}
// If it's a game key, let it through to SDL
if (GAME_KEYS.has(event.key)) {
return;
}
// For all other keys (F5, F12, Ctrl+anything, etc.):
// Stop SDL from seeing them so browser can handle them normally
event.stopPropagation();
};
// Register in capturing phase to intercept before SDL sees the events
window.addEventListener('keydown', filterKeyEvent, true);
window.addEventListener('keyup', filterKeyEvent, true);
window.addEventListener('keypress', filterKeyEvent, true);
return () => {
window.removeEventListener('keydown', filterKeyEvent, true);
window.removeEventListener('keyup', filterKeyEvent, true);
window.removeEventListener('keypress', filterKeyEvent, true);
};
});
// Use View Transitions API for smooth page transitions
onNavigate((navigation) => {
if (!document.startViewTransition) return;
return new Promise((resolve) => {
document.startViewTransition(async () => {
resolve();
await navigation.complete;
});
});
});
const links = [
{
label: 'Play',
href: '/',
icon: IconDeviceGamepad3
},
{
label: 'Leaderboard',
href: '/leaderboard',
icon: IconTrophy
},
{
label: 'Download',
href: '/download',
icon: IconDownload
},
{
label: 'GitHub',
href: 'https://github.com/Xevion/Pac-Man',
icon: IconBrandGithub
}
];
const toggle = () => (opened = !opened);
const close = () => (opened = false);
let currentPath = $derived($page.url.pathname);
let isIndexPage = $derived(currentPath === '/');
function isActive(href: string): boolean {
return href === '/' ? currentPath === href : currentPath.startsWith(href);
}
const sourceLinks = links.filter((link) => !link.href.startsWith('/'));
</script>
<svelte:head>
<link rel="icon" href="/favicon.ico" />
<title>Pac-Man</title>
<meta name="description" content="A Pac-Man game built with Rust and React." />
</svelte:head>
<div class="bg-black text-yellow-400 h-screen flex flex-col overflow-hidden">
<header class="shrink-0 h-[60px] border-b border-yellow-400/25 bg-black z-20">
<div class="h-full px-4 flex items-center justify-center">
<button
aria-label="Open navigation"
onclick={toggle}
class="sm:hidden absolute left-4 inline-flex items-center justify-center w-9 h-9 rounded border border-yellow-400/30 text-yellow-400"
>
<span class="sr-only">Toggle menu</span>
<div class="w-5 h-0.5 bg-yellow-400"></div>
</button>
<div class="flex items-center gap-8">
<a
href="/leaderboard"
class="flex items-center gap-1.5 tracking-wide transition-colors duration-200 {isActive('/leaderboard')
? 'text-white'
: 'text-gray-500 hover:text-gray-300'}"
>
<IconTrophy size={18} />
<span>Leaderboard</span>
</a>
<a
href="/"
onclick={(e) => {
if (isIndexPage) {
e.preventDefault();
}
}}
>
<h1
class="text-3xl tracking-[0.3em] text-yellow-400 title-base {isIndexPage
? ''
: 'title-glimmer title-hover'}"
style="font-family: 'Russo One'"
data-text="PAC-MAN"
>
PAC-MAN
</h1>
</a>
<a
href="/download"
class="flex items-center gap-1.5 tracking-wide transition-colors duration-200 {isActive('/download')
? 'text-white'
: 'text-gray-500 hover:text-gray-300'}"
>
<IconDownload size={18} />
<span>Download</span>
</a>
</div>
<div class="absolute right-4 hidden sm:flex gap-4 items-center">
{#each sourceLinks as link}
<a
href={link.href}
title={link.label}
target="_blank"
class="text-gray-500 hover:text-gray-300 transition-colors duration-200"
>
<link.icon size={28} />
</a>
{/each}
</div>
</div>
</header>
{#if mounted}
<OverlayScrollbarsComponent
defer
options={{
scrollbars: {
theme: 'os-theme-light',
autoHide: 'scroll',
autoHideDelay: 1300
}
}}
class="flex-1"
>
<main>
{@render children()}
</main>
</OverlayScrollbarsComponent>
{:else}
<div class="flex-1 overflow-auto">
<main>{@render children()}</main>
</div>
{/if}
{#if opened}
<div class="fixed inset-0 z-30">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div role="button" tabindex="-1" class="absolute inset-0 bg-black/60" onclick={close}></div>
<div
class="absolute left-0 top-0 h-full w-72 max-w-[80vw] bg-black border-r border-yellow-400/25 p-4"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-bold">Navigation</h2>
<button
aria-label="Close navigation"
onclick={close}
class="inline-flex items-center justify-center w-8 h-8 rounded border border-yellow-400/30 text-yellow-400"
>
</button>
</div>
<div class="flex flex-col gap-3">
{#each links as link}
<a
href={link.href}
class="flex items-center gap-1.5 tracking-wide transition-colors duration-200 {isActive(link.href)
? 'text-white'
: 'text-gray-500 hover:text-gray-300'}"
>
<link.icon size={28} />
<span>{link.label}</span>
</a>
{/each}
</div>
</div>
</div>
{/if}
</div>
+5
View File
@@ -0,0 +1,5 @@
// Enable prerendering for all pages (SSG)
export const prerender = true;
// Disable SSR for the entire app (game requires browser)
export const ssr = false;
+265
View File
@@ -0,0 +1,265 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { beforeNavigate, afterNavigate } from '$app/navigation';
import { getPacmanWindow, type LoadingError } from '$lib/pacman';
const LOADING_FADE_DURATION = 300;
const LOADING_TIMEOUT_MS = 15000;
let gameReady = $state(false);
let gameStarted = $state(false);
let loadingVisible = $state(true);
let loadError = $state<LoadingError | null>(null);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
// Fade out loading overlay when game becomes ready
$effect(() => {
if (gameReady && loadingVisible) {
const timer = setTimeout(() => {
loadingVisible = false;
}, LOADING_FADE_DURATION);
return () => clearTimeout(timer);
}
});
// Clear timeout when game is ready or error occurs
$effect(() => {
if (gameReady || loadError) {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
}
});
function handleInteraction() {
if (gameReady && !gameStarted) {
const win = getPacmanWindow();
if (win.Module?._start_game) {
win.Module._start_game();
gameStarted = true;
}
}
}
function handleKeyDown(e: KeyboardEvent) {
if (!gameReady || gameStarted) return;
handleInteraction();
}
// Stop game when navigating away
beforeNavigate(({ to }) => {
if (to) {
const win = getPacmanWindow();
if (win.Module?._stop_game) {
try {
console.log('Stopping game loop for page transition');
win.Module._stop_game();
} catch (error) {
console.warn('Failed to stop game (game may have already crashed):', error);
}
}
}
});
// Restart game when returning to this page
afterNavigate(() => {
requestAnimationFrame(() => {
setTimeout(() => {
restartGame();
}, 0);
});
});
function restartGame() {
const win = getPacmanWindow();
const module = win.Module;
if (!module?._restart_game) {
console.warn('Game restart function not available (WASM may not be initialized)');
return;
}
const canvas = document.getElementById('canvas') as HTMLCanvasElement | null;
if (!canvas) {
console.error('Canvas element not found during game restart');
return;
}
module.canvas = canvas;
win.SDL_CANVAS_ID = '#canvas';
try {
console.log('Restarting game with fresh App instance');
module._restart_game();
} catch (error) {
console.error('Failed to restart game:', error);
}
}
onMount(() => {
const win = getPacmanWindow();
// Set up ready callback
win.pacmanReady = () => {
gameReady = true;
};
// Error callback for WASM runtime errors
win.pacmanError = (error: LoadingError) => {
console.error('Pacman error:', error);
loadError = error;
};
// Canvas is needed for both first-time init and return navigation
const canvas = document.getElementById('canvas') as HTMLCanvasElement | null;
if (!canvas) {
console.error('Canvas element not found');
loadError = { type: 'runtime', message: 'Canvas element not found' };
return;
}
// Click outside canvas to unfocus it
const handleClickOutside = (event: MouseEvent) => {
if (event.target !== canvas) {
canvas.blur();
}
};
document.addEventListener('click', handleClickOutside);
// Keyboard listener for click-to-start interaction
window.addEventListener('keydown', handleKeyDown);
// Cleanup function used by both paths (return navigation and first-time init)
const cleanup = () => {
document.removeEventListener('click', handleClickOutside);
window.removeEventListener('keydown', handleKeyDown);
};
const module = win.Module;
// If Module already exists (returning after navigation), restart it
if (module?._restart_game) {
gameStarted = false;
return cleanup;
}
// First time initialization
const version = import.meta.env.VITE_PACMAN_VERSION;
console.log(`Loading Pacman with version: ${version}`);
win.Module = {
canvas,
// Restrict keyboard capture to canvas only (not whole document)
// This allows Tab, F5, etc. to work when canvas isn't focused
keyboardListeningElement: canvas,
locateFile: (path: string) => {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${normalizedPath}?v=${version}`;
},
preRun: [
function () {
console.log('PreRun: Waiting for filesystem to be ready');
}
],
monitorRunDependencies: (left: number) => {
console.log(`Run dependencies remaining: ${left}`);
},
onRuntimeInitialized: () => {
console.log('Emscripten runtime initialized, filesystem ready');
},
onAbort: (what: unknown) => {
const message = typeof what === 'string' ? what : 'WebAssembly execution aborted';
console.error('WASM abort:', what);
loadError = { type: 'runtime', message };
}
};
const script = document.createElement('script');
script.src = `/pacman.js?v=${version}`;
script.async = false;
script.onerror = () => {
loadError = { type: 'script', message: 'Failed to load game script' };
};
document.body.appendChild(script);
// Set up loading timeout
timeoutId = setTimeout(() => {
if (!loadError) {
loadError = { type: 'timeout' };
}
}, LOADING_TIMEOUT_MS);
return cleanup;
});
onDestroy(() => {
const win = getPacmanWindow();
delete win.pacmanReady;
delete win.pacmanError;
if (timeoutId) {
clearTimeout(timeoutId);
}
});
function focusCanvas(e: MouseEvent) {
(e.currentTarget as HTMLCanvasElement).focus();
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="flex justify-center items-center h-full pt-4">
<div
role="button"
tabindex="-1"
class="relative block aspect-[5/6]"
style="height: min(calc(100vh - 96px), calc((100vw - 32px) * 6 / 5));"
onclick={handleInteraction}
>
<canvas id="canvas" tabindex="-1" class="w-full h-full" onclick={focusCanvas}></canvas>
<!-- Loading overlay -->
{#if loadingVisible}
<div
class="absolute inset-0 flex flex-col items-center justify-center bg-black/80 transition-opacity"
style="transition-duration: {LOADING_FADE_DURATION}ms; opacity: {gameReady ? 0 : 1};"
>
{#if loadError}
<div class="error-indicator"></div>
<span class="text-red-500 text-2xl mt-4 font-semibold">
{loadError.type === 'timeout'
? 'Loading timed out'
: loadError.type === 'script'
? 'Failed to load'
: 'Error occurred'}
</span>
<span class="text-gray-400 text-sm mt-2 max-w-xs text-center">
{loadError.type === 'timeout'
? 'The game took too long to load. Please refresh the page.'
: loadError.type === 'script'
? 'Could not load game files. Check your connection and refresh.'
: loadError.message}
</span>
<button
onclick={() => window.location.reload()}
class="mt-4 px-4 py-2 bg-yellow-400 text-black font-semibold rounded hover:bg-yellow-300 transition-colors"
>
Reload
</button>
{:else}
<div class="loading-spinner"></div>
<span class="text-yellow-400 text-2xl mt-4">Loading...</span>
{/if}
</div>
{/if}
<!-- Click to Start overlay -->
{#if gameReady && !gameStarted}
<div class="absolute inset-0 flex items-center justify-center bg-black/60 cursor-pointer">
<span class="text-yellow-400 text-5xl font-bold">Click to Start</span>
</div>
{/if}
</div>
</div>
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
</script>
<div class="page-container">
<div class="space-y-6">
<div class="card">
<h2 class="text-2xl font-bold mb-4">Download Pac-Man</h2>
<p class="text-gray-300 mb-4">Download instructions and releases will be available here soon.</p>
</div>
</div>
</div>
+72
View File
@@ -0,0 +1,72 @@
<script lang="ts">
import { IconTrophy, IconCalendar } from '@tabler/icons-svelte';
import { mockGlobalData, mockMonthlyData, type LeaderboardEntry } from '$lib/leaderboard';
let activeTab = $state<'global' | 'monthly'>('global');
function tabButtonClass(isActive: boolean): string {
return `inline-flex items-center gap-1 px-3 py-1 rounded border ${
isActive
? 'border-yellow-400/40 text-yellow-300'
: 'border-transparent text-gray-300 hover:text-yellow-200'
}`;
}
</script>
{#snippet leaderboardTable(data: LeaderboardEntry[])}
<table class="w-full border-separate border-spacing-y-2">
<tbody>
{#each data as entry (entry.id)}
<tr class="bg-black">
<td class="py-2">
<div class="flex items-center gap-2">
<img
src={entry.avatar}
alt={entry.name}
class="w-9 h-9 rounded-sm"
loading="lazy"
/>
<div class="flex flex-col">
<span class="text-yellow-400 font-semibold text-lg">{entry.name}</span>
<span class="text-xs text-gray-400">{entry.submittedAt}</span>
</div>
</div>
</td>
<td class="py-2">
<span class="text-yellow-300 font-[600] text-lg">{entry.score.toLocaleString()}</span>
</td>
<td class="py-2">
<span class="text-gray-300">{entry.duration}</span>
</td>
<td class="py-2">Level {entry.levelCount}</td>
</tr>
{/each}
</tbody>
</table>
{/snippet}
<div class="page-container">
<div class="space-y-6">
<div class="card">
<div class="flex gap-2 border-b border-yellow-400/20 pb-2 mb-4">
<button onclick={() => (activeTab = 'global')} class={tabButtonClass(activeTab === 'global')}>
<IconTrophy size={16} />
Global
</button>
<button
onclick={() => (activeTab = 'monthly')}
class={tabButtonClass(activeTab === 'monthly')}
>
<IconCalendar size={16} />
Monthly
</button>
</div>
{#if activeTab === 'global'}
{@render leaderboardTable(mockGlobalData)}
{:else}
{@render leaderboardTable(mockMonthlyData)}
{/if}
</div>
</div>
</div>