mirror of
https://github.com/Xevion/Pac-Man.git
synced 2026-01-31 04:25:07 -06:00
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:
+194
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+13
@@ -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 {};
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -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'
|
||||
}
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user