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
Vendored
+5 -5
View File
@@ -19,12 +19,12 @@ yarn-debug.log*
yarn-error.log*
# Emscripten build outputs (generated by cargo build)
web/public/pacman.data
web/public/pacman.js
web/public/pacman.wasm
web/public/pacman.wasm.map
web/static/pacman.data
web/static/pacman.js
web/static/pacman.wasm
web/static/pacman.wasm.map
# Site build f iles
# Site build files
tailwindcss-*
pacman/assets/site/build.css
+25 -15
View File
@@ -74,31 +74,41 @@ RUN cargo build --release --target wasm32-unknown-emscripten --bin pacman
FROM oven/bun:1 AS frontend-builder
WORKDIR /app
# Install binaryen for wasm-opt (WASM size optimization)
RUN apt-get update && \
apt-get install -y --no-install-recommends binaryen && \
rm -rf /var/lib/apt/lists/*
# Copy package files for dependency installation
COPY web/package.json web/bun.lock* ./
RUN bun install --frozen-lockfile
# Copy WASM artifacts from wasm-builder stage
# Copy frontend source first (so we have the static/ directory)
COPY web/ ./
# Copy WASM artifacts from wasm-builder stage to SvelteKit's static folder
# Note: .wasm and .js are in release/, but .data (preloaded assets) is in release/deps/
RUN mkdir -p ./public
COPY --from=wasm-builder /app/target/wasm32-unknown-emscripten/release/pacman.wasm ./public/pacman.wasm
COPY --from=wasm-builder /app/target/wasm32-unknown-emscripten/release/pacman.js ./public/pacman.js
COPY --from=wasm-builder /app/target/wasm32-unknown-emscripten/release/deps/pacman.data ./public/pacman.data
COPY --from=wasm-builder /app/target/wasm32-unknown-emscripten/release/pacman.wasm ./static/pacman.wasm
COPY --from=wasm-builder /app/target/wasm32-unknown-emscripten/release/pacman.js ./static/pacman.js
COPY --from=wasm-builder /app/target/wasm32-unknown-emscripten/release/deps/pacman.data ./static/pacman.data
# Optimize WASM binary for size (typically 5-15% reduction)
RUN ORIGINAL_SIZE=$(stat -c%s ./static/pacman.wasm) && \
wasm-opt -Oz --strip-debug ./static/pacman.wasm -o ./static/pacman.wasm && \
OPTIMIZED_SIZE=$(stat -c%s ./static/pacman.wasm) && \
echo "WASM optimized: ${ORIGINAL_SIZE} -> ${OPTIMIZED_SIZE} bytes ($(( (ORIGINAL_SIZE - OPTIMIZED_SIZE) * 100 / ORIGINAL_SIZE ))% reduction)"
# Verify WASM artifacts exist and have reasonable sizes
RUN test -f ./public/pacman.wasm && \
test -f ./public/pacman.js && \
test -f ./public/pacman.data && \
[ $(stat -c%s ./public/pacman.wasm) -gt $((1024 * 1024)) ] && \
[ $(stat -c%s ./public/pacman.js) -gt $((100 * 1024)) ] && \
[ $(stat -c%s ./public/pacman.data) -gt $((10 * 1024)) ] && \
RUN test -f ./static/pacman.wasm && \
test -f ./static/pacman.js && \
test -f ./static/pacman.data && \
[ $(stat -c%s ./static/pacman.wasm) -gt $((1024 * 1024)) ] && \
[ $(stat -c%s ./static/pacman.js) -gt $((100 * 1024)) ] && \
[ $(stat -c%s ./static/pacman.data) -gt $((10 * 1024)) ] && \
echo "WASM artifacts verified (wasm >1MiB, js >100KiB, data >10KiB)" || \
(echo "WASM artifacts missing or too small!" && exit 1)
# Copy frontend source
COPY web/ ./
# Build frontend (Vite bundles WASM files from public/)
# Build frontend (SvelteKit bundles WASM files from static/)
RUN bun run build
# ========== Stage 5: Backend Chef ==========
View File
+48 -2
View File
@@ -67,7 +67,7 @@ async function build(release: boolean, env: Record<string, string> | null) {
const buildType = release ? "release" : "debug";
const outputFolder = resolve(`target/wasm32-unknown-emscripten/${buildType}`);
const dist = resolve("web/public");
const dist = resolve("web/static");
// The files to copy into 'dist'
const files = [
@@ -98,7 +98,7 @@ async function build(release: boolean, env: Record<string, string> | null) {
);
// Copy the files to the dist folder
logger.debug("Copying Emscripten build artifacts into web/public");
logger.debug("Copying Emscripten build artifacts into web/static");
await Promise.all(
files.map(async ({ optional, src, dest }) => {
match({ optional, exists: await fs.exists(src) })
@@ -120,9 +120,55 @@ async function build(release: boolean, env: Record<string, string> | null) {
})
);
// Optimize WASM binary for size using wasm-opt (if available)
const wasmPath = join(dist, "pacman.wasm");
await optimizeWasm(wasmPath);
logger.info("Emscripten build complete");
}
/**
* Optimize a WASM binary using wasm-opt (from binaryen).
* Silently skips if wasm-opt is not installed.
*
* @param wasmPath - Path to the WASM file to optimize in-place.
*/
async function optimizeWasm(wasmPath: string): Promise<void> {
// Check if wasm-opt is available
const whichCmd = os.type === "windows" ? "where" : "which";
const checkResult = await $`${whichCmd} wasm-opt`.quiet().nothrow();
if (checkResult.exitCode !== 0) {
logger.debug(
"wasm-opt not found, skipping WASM optimization (install binaryen to enable)"
);
return;
}
const originalSize = (await fs.stat(wasmPath)).size;
logger.debug(`Optimizing WASM binary (original: ${formatBytes(originalSize)})`);
const result = await $`wasm-opt -Oz --strip-debug ${wasmPath} -o ${wasmPath}`.quiet().nothrow();
if (result.exitCode !== 0) {
logger.warn(`wasm-opt failed: ${result.stderr.toString()}`);
return;
}
const optimizedSize = (await fs.stat(wasmPath)).size;
const reduction = ((originalSize - optimizedSize) / originalSize) * 100;
logger.info(
`WASM optimized: ${formatBytes(originalSize)} -> ${formatBytes(optimizedSize)} (${reduction.toFixed(1)}% reduction)`
);
}
/**
* Format bytes as a human-readable string.
*/
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
}
// (Tailwind-related code removed; this script is now focused solely on the Emscripten build)
/**
-11
View File
@@ -1,11 +0,0 @@
# Frontend Environment Variables
# API URL (for production builds)
# In production with unified deployment, this should be "/api" (same-origin)
# For local development, this is handled by the Vite proxy
VITE_API_URL=/api
# API Proxy Target (for local development only)
# Point this to your local backend server
# Default: http://localhost:3001 (backend runs on 3001, frontend on 3000)
VITE_API_TARGET=http://localhost:3001
+20 -141
View File
@@ -1,145 +1,24 @@
# MacOS
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
/static/fonts
# OS
.DS_Store
Thumbs.db
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
# Env
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.env.*
!.env.example
!.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Cloudflare
.wrangler/
# Vercel
.vercel/
# Sentry Vite Plugin
.env.sentry-build-plugin
# aws-cdk
.cdk.staging
cdk.out
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
-145
View File
@@ -1,145 +0,0 @@
# MacOS
.DS_Store
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Cloudflare
.wrangler/
# Vercel
.vercel/
# Sentry Vite Plugin
.env.sentry-build-plugin
# aws-cdk
.cdk.staging
cdk.out
+38
View File
@@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+197 -480
View File
File diff suppressed because it is too large Load Diff
-64
View File
@@ -1,64 +0,0 @@
import eslint from "@eslint/js";
import prettier from "eslint-plugin-prettier/recommended";
import react from "eslint-plugin-react";
import globals from "globals";
import tseslint, { type ConfigArray } from "typescript-eslint";
export default tseslint.config(
{
ignores: [
"dist/*",
// Temporary compiled files
"**/*.ts.build-*.mjs",
// JS files at the root of the project
"*.js",
"*.cjs",
"*.mjs",
],
},
eslint.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
parserOptions: {
warnOnUnsupportedTypeScriptVersion: false,
sourceType: "module",
ecmaVersion: "latest",
},
},
},
{
rules: {
"@typescript-eslint/no-unused-vars": [
1,
{
argsIgnorePattern: "^_",
},
],
"@typescript-eslint/no-namespace": 0,
},
},
{
files: ["**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}"],
...react.configs.flat.recommended,
languageOptions: {
...react.configs.flat.recommended.languageOptions,
globals: {
...globals.serviceworker,
...globals.browser,
},
},
settings: {
react: {
version: "detect",
},
},
} as ConfigArray[number],
react.configs.flat["jsx-runtime"] as ConfigArray[number],
prettier as ConfigArray[number],
);
-179
View File
@@ -1,179 +0,0 @@
import "./tailwind.css";
import "@fontsource/pixelify-sans";
import "@fontsource/outfit/400.css";
import "@fontsource/outfit/500.css";
import "@fontsource/russo-one";
import "overlayscrollbars/overlayscrollbars.css";
import { useState, useEffect } from "react";
import { usePageContext } from "vike-react/usePageContext";
import { IconBrandGithub, IconDownload, IconDeviceGamepad3, IconTrophy } from "@tabler/icons-react";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { usePendingNavigation } from "@/lib/navigation";
function ClientOnlyScrollbars({ children, className }: { children: React.ReactNode; className?: string }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return <div className={`${className} overflow-auto`}>{children}</div>;
}
return (
<OverlayScrollbarsComponent
defer
options={{
scrollbars: {
theme: "os-theme-light",
autoHide: "scroll",
autoHideDelay: 1300,
},
}}
className={className}
>
{children}
</OverlayScrollbarsComponent>
);
}
const links = [
{
label: "Play",
href: "/",
icon: <IconDeviceGamepad3 size={28} />,
},
{
label: "Leaderboard",
href: "/leaderboard",
icon: <IconTrophy size={28} />,
},
{
label: "Download",
href: "/download",
icon: <IconDownload size={28} />,
},
{
label: "GitHub",
href: "https://github.com/Xevion/Pac-Man",
icon: <IconBrandGithub size={28} />,
},
];
export function Link({ href, label, icon }: { href: string; label: string; icon?: React.ReactNode }) {
const pageContext = usePageContext();
const { urlPathname } = pageContext;
const pendingUrl = usePendingNavigation();
const effectiveUrl = pendingUrl ?? urlPathname;
const isActive = href === "/" ? effectiveUrl === href : effectiveUrl.startsWith(href);
return (
<a
href={href}
className={`
flex items-center gap-1.5
tracking-wide
transition-colors duration-200
${isActive ? "text-white" : "text-gray-500 hover:text-gray-300"}
`}
>
{icon}
<span>{label}</span>
</a>
);
}
export default function LayoutDefault({ children }: { children: React.ReactNode }) {
const [opened, setOpened] = useState(false);
const toggle = () => setOpened((v) => !v);
const close = () => setOpened(false);
const pageContext = usePageContext();
const { urlPathname } = pageContext;
const pendingUrl = usePendingNavigation();
const effectiveUrl = pendingUrl ?? urlPathname;
const isIndexPage = effectiveUrl === "/";
const sourceLinks = links
.filter((link) => !link.href.startsWith("/"))
.map((link) => (
<a
href={link.href}
title={link.label}
key={link.label}
target="_blank"
className="text-gray-500 hover:text-gray-300 transition-colors duration-200"
>
{link.icon}
</a>
));
return (
<div className="bg-black text-yellow-400 h-screen flex flex-col overflow-hidden">
<header className="shrink-0 h-[60px] border-b border-yellow-400/25 bg-black z-20">
<div className="h-full px-4 flex items-center justify-center">
<button
aria-label="Open navigation"
onClick={toggle}
className="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 className="sr-only">Toggle menu</span>
<div className="w-5 h-0.5 bg-yellow-400" />
</button>
<div className="flex items-center gap-8">
<Link href="/leaderboard" label="Leaderboard" icon={<IconTrophy size={18} />} />
<a
href="/"
onClick={(e) => {
if (isIndexPage) {
e.preventDefault();
}
}}
>
<h1
className={`text-3xl tracking-[0.3em] text-yellow-400 title-base ${
isIndexPage ? "" : "title-glimmer title-hover"
}`}
style={{ fontFamily: "Russo One" }}
data-text="PAC-MAN"
>
PAC-MAN
</h1>
</a>
<Link href="/download" label="Download" icon={<IconDownload size={18} />} />
</div>
<div className="absolute right-4 hidden sm:flex gap-4 items-center">{sourceLinks}</div>
</div>
</header>
<ClientOnlyScrollbars className="flex-1">
<main>{children}</main>
</ClientOnlyScrollbars>
{opened && (
<div className="fixed inset-0 z-30">
<div className="absolute inset-0 bg-black/60" onClick={close} />
<div className="absolute left-0 top-0 h-full w-72 max-w-[80vw] bg-black border-r border-yellow-400/25 p-4">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold">Navigation</h2>
<button
aria-label="Close navigation"
onClick={close}
className="inline-flex items-center justify-center w-8 h-8 rounded border border-yellow-400/30 text-yellow-400"
>
</button>
</div>
<div className="flex flex-col gap-3">
{links.map((link) => (
<Link href={link.href} key={link.label} label={link.label} icon={link.icon} />
))}
</div>
</div>
</div>
)}
</div>
);
}
-172
View File
@@ -1,172 +0,0 @@
@import "tailwindcss";
@layer base {
/* Page transitions */
body {
--transition-duration: 200ms;
}
body main {
opacity: 1;
transform: translateY(0);
transition:
opacity var(--transition-duration) ease-out,
transform var(--transition-duration) ease-out;
}
body.page-is-transitioning main {
opacity: 0;
transform: translateY(8px);
}
: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;
}
.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 - CSS animation runs on compositor thread,
continues even during main thread blocking */
.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 - X mark with shake animation */
@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
@@ -1,13 +0,0 @@
// Get API base URL from environment variable, or default to /api for same-origin requests
export const API_BASE_URL = import.meta.env.VITE_API_URL || "/api";
/**
* Helper function to construct full API URLs
* @param path - API endpoint path (without leading slash, e.g., "leaderboard/global")
* @returns Full API URL
*/
export function getApiUrl(path: string): string {
// Remove leading slash if present to avoid double slashes
const cleanPath = path.startsWith("/") ? path.slice(1) : path;
return `${API_BASE_URL}/${cleanPath}`;
}
-24
View File
@@ -1,24 +0,0 @@
import { useSyncExternalStore } from "react";
type Listener = (pendingUrl: string | null) => void;
let pendingUrl: string | null = null;
const listeners = new Set<Listener>();
export function setPendingNavigation(url: string | null) {
pendingUrl = url;
listeners.forEach((listener) => listener(pendingUrl));
}
export function getPendingNavigation(): string | null {
return pendingUrl;
}
export function subscribeToPendingNavigation(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function usePendingNavigation(): string | null {
return useSyncExternalStore(subscribeToPendingNavigation, getPendingNavigation, () => null);
}
-25
View File
@@ -1,25 +0,0 @@
export interface PacmanModule {
canvas: HTMLCanvasElement;
_start_game?: () => void;
_stop_game?: () => void;
_restart_game?: () => void;
locateFile: (path: string) => string;
preRun: unknown[];
// Emscripten error hooks
onAbort?: (what: unknown) => void;
onRuntimeInitialized?: () => void;
}
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;
+25 -23
View File
@@ -1,6 +1,8 @@
{
"name": "pacman-web",
"description": "A web frontend for the Pac-Man game, including leaderboards and OAuth.",
"private": true,
"version": "0.0.1",
"type": "module",
"packageManager": "bun@^1.3.5",
"engines": {
@@ -8,41 +10,41 @@
},
"scripts": {
"preinstall": "npx only-allow bun",
"dev": "vike dev",
"build": "vike build",
"preview": "vike preview",
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint ."
},
"dependencies": {
"@fontsource/outfit": "^5.2.8",
"@fontsource/pixelify-sans": "^5.2.7",
"@fontsource/russo-one": "^5.2.7",
"@tabler/icons-react": "^3.35.0",
"@vitejs/plugin-react": "^5.0.2",
"@tabler/icons-svelte": "^3.35.0",
"overlayscrollbars": "^2.13.0",
"overlayscrollbars-react": "^0.5.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"vike": "^0.4.240",
"vike-react": "^0.6.5"
"overlayscrollbars-svelte": "^0.5.5"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
"@fontsource/outfit": "^5.2.8",
"@fontsource/russo-one": "^5.2.7",
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.13",
"@types/node": "^25.0.3",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@types/fontkit": "^2.0.8",
"@types/node": "^22.0.0",
"eslint": "^9.35.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-svelte": "^3.9.0",
"fontkit": "^2.0.4",
"globals": "^16.3.0",
"postcss": "^8.5.6",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"subset-font": "^2.4.0",
"svelte": "^5.45.6",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.13",
"typescript": "^5.9.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.42.0",
"vite": "^7.1.4"
"vite": "^7.2.6"
}
}
-9
View File
@@ -1,9 +0,0 @@
// https://vike.dev/Head
export default function HeadDefault() {
return (
<>
<link rel="icon" href="/favicon.ico" />
</>
);
}
-17
View File
@@ -1,17 +0,0 @@
import type { Config } from "vike/types";
import vikeReact from "vike-react/config";
import Layout from "../layouts/LayoutDefault.js";
// Default config (can be overridden by pages)
// https://vike.dev/config
export default {
// https://vike.dev/Layout
Layout,
// https://vike.dev/head-tags
title: "Pac-Man",
description: "A Pac-Man game built with Rust and React.",
prerender: true,
extends: vikeReact,
} satisfies Config;
-48
View File
@@ -1,48 +0,0 @@
import type { OnPageTransitionEndAsync } from "vike/types";
import { getPacmanWindow } from "@/lib/pacman";
import { setPendingNavigation } from "@/lib/navigation";
export const onPageTransitionEnd: OnPageTransitionEndAsync = async (pageContext) => {
console.log("Page transition end");
setPendingNavigation(null);
document.querySelector("body")?.classList.remove("page-is-transitioning");
// Restart the game loop when returning to the game page
if (pageContext.urlPathname === "/") {
// Defer game restart to allow fade-in animation to complete first
// This prevents the heavy WebGL initialization from blocking the UI
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;
}
// Update canvas reference BEFORE restart - App::new() will read from Module.canvas
module.canvas = canvas;
// SDL2's Emscripten backend reads this for canvas lookup
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);
}
}
-29
View File
@@ -1,29 +0,0 @@
import type { OnPageTransitionStartAsync } from "vike/types";
import { getPacmanWindow } from "@/lib/pacman";
import { setPendingNavigation } from "@/lib/navigation";
// Must match --transition-duration in layouts/tailwind.css
const TRANSITION_DURATION = 200;
export const onPageTransitionStart: OnPageTransitionStartAsync = async (pageContext) => {
console.log("Page transition start");
setPendingNavigation(pageContext.urlPathname);
document.querySelector("body")?.classList.add("page-is-transitioning");
// Only stop the game when navigating AWAY FROM the game page
// Don't stop when navigating between other pages (e.g., /leaderboard <-> /download)
if (window.location.pathname === "/") {
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);
}
}
}
// Wait for fade-out animation to complete before page content changes
await new Promise((resolve) => setTimeout(resolve, TRANSITION_DURATION));
};
-11
View File
@@ -1,11 +0,0 @@
import { usePageContext } from "vike-react/usePageContext";
export default function Page() {
const { is404 } = usePageContext();
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center px-4">
<h1 className="text-4xl font-bold mb-4">{is404 ? "Page Not Found" : "Internal Error"}</h1>
<p className="text-gray-400">{is404 ? "This page could not be found." : "Something went wrong."}</p>
</div>
);
}
-12
View File
@@ -1,12 +0,0 @@
export default function Page() {
return (
<div className="page-container">
<div className="space-y-6">
<div className="card">
<h2 className="text-2xl font-bold mb-4">Download Pac-Man</h2>
<p className="text-gray-300 mb-4">Download instructions and releases will be available here soon.</p>
</div>
</div>
</div>
);
}
-7
View File
@@ -1,7 +0,0 @@
export default function GameLayout({ children }: { children: React.ReactNode }) {
return (
<div className="bg-black text-yellow-400 h-full flex flex-col overflow-hidden">
<main className="flex-1 overflow-hidden">{children}</main>
</div>
);
}
-197
View File
@@ -1,197 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getPacmanWindow, LoadingError } from "@/lib/pacman";
const LOADING_FADE_DURATION = 300;
const LOADING_TIMEOUT_MS = 15000;
export default function Page() {
const [gameReady, setGameReady] = useState(false);
const [gameStarted, setGameStarted] = useState(false);
const [loadingVisible, setLoadingVisible] = useState(true);
const [loadError, setLoadError] = useState<LoadingError | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Fade out loading overlay when game becomes ready
useEffect(() => {
if (gameReady && loadingVisible) {
const timer = setTimeout(() => {
setLoadingVisible(false);
}, LOADING_FADE_DURATION);
return () => clearTimeout(timer);
}
}, [gameReady, loadingVisible]);
// Clear timeout when game is ready or error occurs
useEffect(() => {
if (gameReady || loadError) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}
}, [gameReady, loadError]);
useEffect(() => {
const win = getPacmanWindow();
// Always set up the ready callback (restart_game will call it too)
win.pacmanReady = () => {
setGameReady(true);
};
// Error callback for WASM runtime errors
win.pacmanError = (error: LoadingError) => {
console.error("Pacman error:", error);
setLoadError(error);
};
const module = win.Module;
// If Module already exists (returning after navigation),
// the onPageTransitionEnd hook handles calling restart_game
if (module?._restart_game) {
setGameStarted(false);
// Don't delete pacmanReady here - restart_game needs it
return;
}
// First time initialization
const canvas = document.getElementById("canvas") as HTMLCanvasElement | null;
if (!canvas) {
console.error("Canvas element not found");
setLoadError({ type: "runtime", message: "Canvas element not found" });
return;
}
// Get version from build-time injected environment variable
const version = import.meta.env.VITE_PACMAN_VERSION;
console.log(`Loading Pacman with version: ${version}`);
win.Module = {
canvas,
locateFile: (path: string) => {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return `${normalizedPath}?v=${version}`;
},
preRun: [],
onRuntimeInitialized: () => {
console.log("Emscripten runtime initialized, filesystem ready");
},
// Emscripten calls this on fatal errors (abort/trap/etc)
onAbort: (what: unknown) => {
const message = typeof what === "string" ? what : "WebAssembly execution aborted";
console.error("WASM abort:", what);
setLoadError({ type: "runtime", message });
},
};
const script = document.createElement("script");
script.src = `/pacman.js?v=${version}`;
script.async = false;
// Handle script load errors
script.onerror = () => {
setLoadError({ type: "script", message: "Failed to load game script" });
};
document.body.appendChild(script);
// Set up loading timeout - the separate effect clears this if game loads successfully
timeoutRef.current = setTimeout(() => {
setLoadError((prev) => prev ?? { type: "timeout" });
}, LOADING_TIMEOUT_MS);
return () => {
delete win.pacmanReady;
delete win.pacmanError;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const handleInteraction = useCallback(() => {
if (gameReady && !gameStarted) {
const win = getPacmanWindow();
if (win.Module?._start_game) {
win.Module._start_game();
setGameStarted(true);
}
}
}, [gameReady, gameStarted]);
// Handle keyboard interaction
useEffect(() => {
if (!gameReady || gameStarted) return;
const handleKeyDown = (e: KeyboardEvent) => {
handleInteraction();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [gameReady, gameStarted, handleInteraction]);
return (
<div className="flex justify-center items-center h-full pt-4">
<div
className="relative block aspect-[5/6]"
style={{
height: "min(calc(100vh - 96px), calc((100vw - 32px) * 6 / 5))",
}}
onClick={handleInteraction}
>
<canvas id="canvas" className="w-full h-full" />
{/* Loading overlay - CSS animation continues during main thread blocking */}
{loadingVisible && (
<div
className="absolute inset-0 flex flex-col items-center justify-center bg-black/80 transition-opacity"
style={{
transitionDuration: `${LOADING_FADE_DURATION}ms`,
opacity: gameReady ? 0 : 1,
}}
>
{loadError ? (
<>
<div className="error-indicator" />
<span className="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 className="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()}
className="mt-4 px-4 py-2 bg-yellow-400 text-black font-semibold rounded hover:bg-yellow-300 transition-colors"
>
Reload
</button>
</>
) : (
<>
<div className="loading-spinner" />
<span className="text-yellow-400 text-2xl mt-4">Loading...</span>
</>
)}
</div>
)}
{/* Click to Start overlay */}
{gameReady && !gameStarted && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60 cursor-pointer">
<span className="text-yellow-400 text-5xl font-bold">Click to Start</span>
</div>
)}
</div>
</div>
);
}
-8
View File
@@ -1,8 +0,0 @@
import type { Config } from "vike/types";
// Disable SSR for the game page since Emscripten requires a browser environment
// Prerender enabled to generate index.html for deployment, while ssr: false ensures client-side WASM loading
export default {
prerender: true, // Generate static HTML shell for deployment
ssr: false, // Force client-side only rendering (required for Emscripten/WASM)
} satisfies Config;
-66
View File
@@ -1,66 +0,0 @@
import { useState } from "react";
import { IconTrophy, IconCalendar } from "@tabler/icons-react";
import { mockGlobalData, mockMonthlyData, type LeaderboardEntry } from "./mockData";
function LeaderboardTable({ data }: { data: LeaderboardEntry[] }) {
return (
<table className="w-full border-separate border-spacing-y-2">
<tbody>
{data.map((entry) => (
<tr key={entry.id} className="bg-black">
<td className="py-2">
<div className="flex items-center gap-2">
<img src={entry.avatar} alt={entry.name} className="w-9 h-9 rounded-sm" loading="lazy" />
<div className="flex flex-col">
<span className="text-yellow-400 font-semibold text-lg">{entry.name}</span>
<span className="text-xs text-gray-400">{entry.submittedAt}</span>
</div>
</div>
</td>
<td className="py-2">
<span className="text-yellow-300 font-[600] text-lg">{entry.score.toLocaleString()}</span>
</td>
<td className="py-2">
<span className="text-gray-300">{entry.duration}</span>
</td>
<td className="py-2">Level {entry.levelCount}</td>
</tr>
))}
</tbody>
</table>
);
}
const tabButtonClass = (isActive: boolean) =>
`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"
}`;
export default function Page() {
const [activeTab, setActiveTab] = useState<"global" | "monthly">("global");
return (
<div className="page-container">
<div className="space-y-6">
<div className="card">
<div className="flex gap-2 border-b border-yellow-400/20 pb-2 mb-4">
<button onClick={() => setActiveTab("global")} className={tabButtonClass(activeTab === "global")}>
<IconTrophy size={16} />
Global
</button>
<button onClick={() => setActiveTab("monthly")} className={tabButtonClass(activeTab === "monthly")}>
<IconCalendar size={16} />
Monthly
</button>
</div>
{activeTab === "global" ? (
<LeaderboardTable data={mockGlobalData} />
) : (
<LeaderboardTable data={mockMonthlyData} />
)}
</div>
</div>
</div>
);
}
-6
View File
@@ -1,6 +0,0 @@
import type { Config } from "vike/types";
export default {
prerender: true, // Generate static HTML for deployment
ssr: false, // Force client-side only rendering
} satisfies Config;
-216
View File
@@ -1,216 +0,0 @@
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",
},
];
-3
View File
@@ -1,3 +0,0 @@
module.exports = {
plugins: {},
};
-9
View File
@@ -1,9 +0,0 @@
/**
* @see https://prettier.io/docs/configuration
* @type {import("prettier").Config}
*/
const config = {
printWidth: 120,
};
export default config;
+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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+24
View File
@@ -0,0 +1,24 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'dist/client',
assets: 'dist/client',
fallback: undefined,
precompress: false,
strict: true
}),
// Inline CSS below 5KB for fewer requests
inlineStyleThreshold: 5000,
alias: {
$lib: './src/lib'
}
}
};
export default config;
+18 -31
View File
@@ -1,33 +1,20 @@
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"module": "ESNext",
"noEmit": true,
"moduleResolution": "Bundler",
"target": "ES2022",
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"types": [
"vite/client",
"vike-react",
"node"
],
"jsx": "react-jsx",
"jsxImportSource": "react",
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"exclude": [
"dist"
]
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
-11
View File
@@ -1,11 +0,0 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_PACMAN_VERSION: string;
readonly VITE_API_URL: string;
readonly VITE_API_TARGET: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+671
View File
@@ -0,0 +1,671 @@
import type { Plugin, ResolvedConfig } from 'vite';
import type { Font, FontCollection } from 'fontkit';
import * as fontkit from 'fontkit';
// @ts-expect-error subset-font has no type definitions
import subsetFont from 'subset-font';
import { createHash } from 'node:crypto';
function isFont(font: Font | FontCollection): font is Font {
return 'glyphForCodePoint' in font;
}
import { readFile, writeFile, mkdir, copyFile, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { normalizePath } from 'vite';
// ============================================================================
// Types
// ============================================================================
export interface FontSubsetSource {
source: string;
whitelist: string;
weight?: number;
style?: 'normal' | 'italic' | 'oblique';
family?: string;
}
export interface FontSubsetConfig {
fonts: FontSubsetSource[];
outputDir?: string;
cssOutputPath?: string;
cacheDir?: string;
skipOnMissingDeps?: boolean;
}
interface FontMetadata {
family: string;
weight: number;
style: 'normal' | 'italic' | 'oblique';
postscriptName: string;
isVariable: boolean;
}
interface UnicodeRange {
cssRange: string;
comment: string;
}
interface CacheEntry {
sourceHash: string;
outputPath: string;
metadata: FontMetadata;
unicodeRange: UnicodeRange;
timestamp: number;
}
interface FontFaceDescriptor {
family: string;
weight: number;
style: string;
fontPath: string;
unicodeRange: UnicodeRange;
originalSource: string;
}
// ============================================================================
// Logging
// ============================================================================
function logInfo(message: string): void {
console.log(`[vite-plugin-font-subset] ${message}`);
}
function logWarning(message: string): void {
console.warn(`[vite-plugin-font-subset] WARNING: ${message}`);
}
function logError(context: string, error: Error, isProduction: boolean): void {
console.error(`\n[vite-plugin-font-subset] ERROR: ${context}`);
console.error(error.message);
if (!isProduction && error.stack) {
console.error('\nStack trace:');
console.error(error.stack);
}
}
// ============================================================================
// Path Resolution
// ============================================================================
class PathResolver {
constructor(private viteConfig: ResolvedConfig) {}
resolveSource(sourcePath: string): string {
if (sourcePath.startsWith('@fontsource/')) {
return normalizePath(path.resolve(this.viteConfig.root, 'node_modules', sourcePath));
}
if (path.isAbsolute(sourcePath)) {
return normalizePath(sourcePath);
}
return normalizePath(path.resolve(this.viteConfig.root, sourcePath));
}
resolveOutputDir(configuredPath: string): string {
return normalizePath(path.resolve(this.viteConfig.root, configuredPath));
}
resolveCssPath(configuredPath: string): string {
return normalizePath(path.resolve(this.viteConfig.root, configuredPath));
}
resolveCacheDir(configuredPath: string): string {
return normalizePath(path.resolve(this.viteConfig.root, configuredPath));
}
}
// ============================================================================
// Configuration Validation
// ============================================================================
function validateConfig(config: FontSubsetConfig): void {
if (!config.fonts || config.fonts.length === 0) {
throw new Error('Font subset config must have at least one font');
}
for (const [index, font] of config.fonts.entries()) {
if (!font.source) {
throw new Error(`Font config [${index}]: 'source' is required`);
}
if (!font.whitelist || font.whitelist.length === 0) {
throw new Error(`Font config [${index}]: 'whitelist' must contain at least one character`);
}
if (font.weight && (font.weight < 100 || font.weight > 900)) {
throw new Error(`Font config [${index}]: 'weight' must be between 100 and 900`);
}
}
}
// ============================================================================
// Dependency Checking
// ============================================================================
async function checkDependencies(): Promise<void> {
const required = ['fontkit', 'subset-font'];
const missing: string[] = [];
for (const dep of required) {
try {
await import(dep);
} catch {
missing.push(dep);
}
}
if (missing.length > 0) {
throw new Error(
`Missing required dependencies: ${missing.join(', ')}\n` +
`Install with: bun add -d fontkit subset-font @types/fontkit`
);
}
}
// ============================================================================
// Font Metadata Extraction
// ============================================================================
function inferStyle(
subfamilyName: string | undefined,
italicAngle: number
): 'normal' | 'italic' | 'oblique' {
const name = (subfamilyName || '').toLowerCase();
if (name.includes('italic')) return 'italic';
if (name.includes('oblique')) return 'oblique';
if (italicAngle !== 0) return 'italic';
return 'normal';
}
function inferWeight(subfamilyName: string | undefined): number {
const name = (subfamilyName || '').toLowerCase();
const weightMap: Record<string, number> = {
thin: 100,
hairline: 100,
'extra light': 200,
'ultra light': 200,
light: 300,
regular: 400,
normal: 400,
medium: 500,
'semi bold': 600,
'demi bold': 600,
bold: 700,
'extra bold': 800,
'ultra bold': 800,
black: 900,
heavy: 900
};
for (const [key, value] of Object.entries(weightMap)) {
if (name.includes(key)) {
return value;
}
}
return 400;
}
async function extractFontMetadata(
fontPath: string,
overrides?: { family?: string; weight?: number; style?: string }
): Promise<FontMetadata> {
const fontOrCollection = fontkit.openSync(fontPath);
if (!isFont(fontOrCollection)) {
throw new Error(`Font collections are not supported: ${fontPath}`);
}
const font = fontOrCollection;
const isVariable = font.variationAxes && Object.keys(font.variationAxes).length > 0;
// Extract family name using OpenType name table priority
let family: string;
let familySource: string;
if (overrides?.family) {
family = overrides.family;
familySource = 'config override';
} else {
// OpenType name table IDs:
// ID 16 = Typographic/Preferred Family (base family without weight/style)
// ID 1 = Font Family (may include weight/style for compatibility)
const nameTable = (font as any).name;
const preferredFamily = nameTable?.records?.preferredFamily?.en;
const fontFamily = nameTable?.records?.fontFamily?.en;
if (preferredFamily) {
family = preferredFamily;
familySource = 'Name ID 16 (Typographic Family)';
} else if (fontFamily) {
family = fontFamily;
familySource = 'Name ID 1 (Font Family)';
} else {
family = font.familyName;
familySource = 'familyName property';
}
}
const style =
(overrides?.style as 'normal' | 'italic' | 'oblique') ||
inferStyle(font.subfamilyName, font.italicAngle);
let weight: number;
if (overrides?.weight) {
weight = overrides.weight;
} else if (isVariable) {
throw new Error(
`Variable font detected: ${fontPath}\n` +
`Variable fonts require explicit weight override in config.\n` +
`Available axes: ${Object.keys(font.variationAxes).join(', ')}\n` +
`Add 'weight: <number>' to font config.`
);
} else {
weight = font['OS/2']?.usWeightClass || inferWeight(font.subfamilyName);
}
// Log extracted family name for debugging
logInfo(
` Font family: "${family}" (from ${familySource})`
);
return {
family,
weight,
style,
postscriptName: font.postscriptName,
isVariable
};
}
// ============================================================================
// Whitelist Validation
// ============================================================================
async function validateWhitelist(
fontBuffer: Buffer,
whitelist: string,
sourcePath: string
): Promise<string[]> {
const warnings: string[] = [];
const fontOrCollection = fontkit.create(fontBuffer);
if (!isFont(fontOrCollection)) {
throw new Error(`Font collections are not supported: ${sourcePath}`);
}
const font = fontOrCollection;
const uniqueChars = [...new Set(whitelist)];
const missingChars: string[] = [];
for (const char of uniqueChars) {
const codePoint = char.codePointAt(0);
if (!codePoint) continue;
const glyph = font.glyphForCodePoint(codePoint);
if (!glyph || glyph.id === 0) {
missingChars.push(char);
}
}
if (missingChars.length > 0) {
warnings.push(
`Font ${path.basename(sourcePath)} is missing ${missingChars.length} whitelisted characters: ` +
`"${missingChars.join('')}"`
);
}
return warnings;
}
// ============================================================================
// Font Subsetting
// ============================================================================
async function subsetFontFile(
sourcePath: string,
whitelist: string,
outputPath: string,
metadata: FontMetadata
): Promise<void> {
const fontBuffer = await readFile(sourcePath);
const warnings = await validateWhitelist(fontBuffer, whitelist, sourcePath);
for (const warning of warnings) {
logWarning(warning);
}
const normalizedWhitelist = [...new Set(whitelist.normalize('NFC'))].join('');
const subsetBuffer = await subsetFont(fontBuffer, normalizedWhitelist, {
targetFormat: 'woff2',
...(metadata.isVariable && metadata.weight
? {
variationAxes: {
wght: metadata.weight
}
}
: {})
});
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, subsetBuffer);
}
// ============================================================================
// Unicode Range Generation
// ============================================================================
function formatRange(start: number, end: number): string {
const startHex = start.toString(16).toUpperCase();
const endHex = end.toString(16).toUpperCase();
if (start === end) {
return `U+${startHex}`;
}
return `U+${startHex}-${endHex}`;
}
function generateRangeComment(whitelist: string, codePoints: number[]): string {
const categories: string[] = [];
const hasLowercase = codePoints.some((cp) => cp >= 0x61 && cp <= 0x7a);
const hasUppercase = codePoints.some((cp) => cp >= 0x41 && cp <= 0x5a);
const hasDigits = codePoints.some((cp) => cp >= 0x30 && cp <= 0x39);
const hasPunctuation = codePoints.some(
(cp) =>
(cp >= 0x20 && cp <= 0x2f) ||
(cp >= 0x3a && cp <= 0x40) ||
(cp >= 0x5b && cp <= 0x60) ||
(cp >= 0x7b && cp <= 0x7e)
);
if (hasUppercase && hasLowercase) {
categories.push('letters');
} else if (hasUppercase) {
categories.push('uppercase');
} else if (hasLowercase) {
categories.push('lowercase');
}
if (hasDigits) categories.push('numbers');
if (hasPunctuation) categories.push('punctuation');
if (whitelist.length <= 20) {
return `Only contains: ${whitelist}`;
}
return categories.length > 0 ? `${categories.join(', ')}` : `${codePoints.length} characters`;
}
function generateUnicodeRange(whitelist: string): UnicodeRange {
const codePoints = [...new Set(whitelist)]
.map((char) => char.codePointAt(0))
.filter((cp): cp is number => cp !== undefined)
.sort((a, b) => a - b);
const ranges: string[] = [];
let rangeStart = codePoints[0];
let rangeEnd = codePoints[0];
for (let i = 1; i < codePoints.length; i++) {
const current = codePoints[i];
if (current === rangeEnd + 1) {
rangeEnd = current;
} else {
ranges.push(formatRange(rangeStart, rangeEnd));
rangeStart = current;
rangeEnd = current;
}
}
ranges.push(formatRange(rangeStart, rangeEnd));
return {
cssRange: ranges.join(', '),
comment: generateRangeComment(whitelist, codePoints)
};
}
// ============================================================================
// CSS Generation
// ============================================================================
async function generateCssFile(
fonts: FontFaceDescriptor[],
cssOutputPath: string
): Promise<void> {
const lines = [
'/* Auto-generated by vite-plugin-font-subset */',
'/* Do not edit manually - changes will be overwritten */',
'',
'/* Subsetted fonts for optimal loading */',
''
];
for (const font of fonts) {
lines.push(
`/* ${font.family} ${font.weight} - ${font.unicodeRange.comment} */`,
'@font-face {',
`\tfont-family: '${font.family}';`,
`\tfont-weight: ${font.weight};`,
`\tfont-style: ${font.style};`,
`\tfont-display: swap;`,
`\tsrc: url('/fonts/${path.basename(font.fontPath)}') format('woff2');`,
`\tunicode-range: ${font.unicodeRange.cssRange};`,
'}',
''
);
}
await writeFile(cssOutputPath, lines.join('\n'), 'utf-8');
}
// ============================================================================
// Cache Management
// ============================================================================
async function generateCacheKey(sourcePath: string, whitelist: string): Promise<string> {
const sourceContent = await readFile(sourcePath);
const hash = createHash('sha256');
hash.update(sourceContent);
hash.update(whitelist);
return hash.digest('hex').substring(0, 16);
}
async function loadCacheManifest(cacheDir: string): Promise<Map<string, CacheEntry>> {
const manifestPath = path.join(cacheDir, 'manifest.json');
if (!existsSync(manifestPath)) {
return new Map();
}
try {
const content = await readFile(manifestPath, 'utf-8');
const data = JSON.parse(content);
return new Map(Object.entries(data));
} catch {
return new Map();
}
}
async function saveCacheManifest(
cacheDir: string,
manifest: Map<string, CacheEntry>
): Promise<void> {
const manifestPath = path.join(cacheDir, 'manifest.json');
await mkdir(cacheDir, { recursive: true });
const data = Object.fromEntries(manifest);
await writeFile(manifestPath, JSON.stringify(data, null, 2), 'utf-8');
}
async function isCacheValid(
entry: CacheEntry,
sourcePath: string,
whitelist: string
): Promise<boolean> {
if (!existsSync(entry.outputPath)) {
return false;
}
const currentHash = await generateCacheKey(sourcePath, whitelist);
return entry.sourceHash === currentHash;
}
// ============================================================================
// Output Filename Generation
// ============================================================================
function generateOutputFilename(metadata: FontMetadata, sourcePath: string): string {
const baseName = path.basename(sourcePath, path.extname(sourcePath));
if (baseName.includes('-subset')) {
return `${baseName}.woff2`;
}
const familySlug = metadata.family.toLowerCase().replace(/\s+/g, '-');
return `${familySlug}-${metadata.weight}-${metadata.style}-subset.woff2`;
}
// ============================================================================
// Main Processing
// ============================================================================
async function processFonts(
config: FontSubsetConfig,
viteConfig: ResolvedConfig,
isProduction: boolean
): Promise<void> {
validateConfig(config);
try {
await checkDependencies();
} catch (error) {
if (!isProduction && config.skipOnMissingDeps !== false) {
logWarning((error as Error).message);
logInfo('Skipping font subsetting in development mode');
return;
}
throw error;
}
const resolver = new PathResolver(viteConfig);
const outputDir = resolver.resolveOutputDir(config.outputDir || 'static/fonts');
const cssOutputPath = resolver.resolveCssPath(config.cssOutputPath || 'src/lib/fonts.css');
const cacheDir = resolver.resolveCacheDir(
config.cacheDir || 'node_modules/.vite-plugin-font-subset'
);
const cacheManifest = await loadCacheManifest(cacheDir);
const fontDescriptors: FontFaceDescriptor[] = [];
let subsettedCount = 0;
let cachedCount = 0;
for (const fontConfig of config.fonts) {
const sourcePath = resolver.resolveSource(fontConfig.source);
if (!existsSync(sourcePath)) {
throw new Error(`Source font not found: ${sourcePath}`);
}
const cacheKey = await generateCacheKey(sourcePath, fontConfig.whitelist);
const cacheEntry = cacheManifest.get(cacheKey);
const metadata = await extractFontMetadata(sourcePath, {
family: fontConfig.family,
weight: fontConfig.weight,
style: fontConfig.style
});
const outputFilename = generateOutputFilename(metadata, sourcePath);
const outputPath = path.join(outputDir, outputFilename);
if (cacheEntry && (await isCacheValid(cacheEntry, sourcePath, fontConfig.whitelist))) {
logInfo(`Using cached subset: ${outputFilename}`);
await copyFile(cacheEntry.outputPath, outputPath);
cachedCount++;
fontDescriptors.push({
family: metadata.family,
weight: metadata.weight,
style: metadata.style,
fontPath: outputPath,
unicodeRange: cacheEntry.unicodeRange,
originalSource: fontConfig.source
});
} else {
logInfo(`Subsetting font: ${path.basename(sourcePath)} -> ${outputFilename}`);
await subsetFontFile(sourcePath, fontConfig.whitelist, outputPath, metadata);
subsettedCount++;
const unicodeRange = generateUnicodeRange(fontConfig.whitelist);
const cachedPath = path.join(cacheDir, `${cacheKey}-${outputFilename}`);
await mkdir(cacheDir, { recursive: true });
await copyFile(outputPath, cachedPath);
cacheManifest.set(cacheKey, {
sourceHash: cacheKey,
outputPath: cachedPath,
metadata,
unicodeRange,
timestamp: Date.now()
});
fontDescriptors.push({
family: metadata.family,
weight: metadata.weight,
style: metadata.style,
fontPath: outputPath,
unicodeRange,
originalSource: fontConfig.source
});
}
}
await saveCacheManifest(cacheDir, cacheManifest);
await generateCssFile(fontDescriptors, cssOutputPath);
logInfo(
`Processed ${config.fonts.length} fonts (${subsettedCount} subsetted, ${cachedCount} cached)`
);
logInfo(`Generated: ${cssOutputPath}`);
}
// ============================================================================
// Plugin Export
// ============================================================================
export function fontSubsetPlugin(config: FontSubsetConfig): Plugin {
let viteConfig: ResolvedConfig;
let isProduction: boolean;
return {
name: 'vite-plugin-font-subset',
configResolved(resolvedConfig) {
viteConfig = resolvedConfig;
isProduction = resolvedConfig.mode === 'production';
},
async buildStart() {
try {
await processFonts(config, viteConfig, isProduction);
} catch (error) {
if (isProduction) {
this.error(`Font subsetting failed: ${(error as Error).message}`);
} else if (!config.skipOnMissingDeps) {
this.error(`Font subsetting failed: ${(error as Error).message}`);
} else {
logWarning(`Font subsetting skipped: ${(error as Error).message}`);
}
}
}
};
}
+70 -60
View File
@@ -1,75 +1,85 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import vike from "vike/plugin";
import { defineConfig, Plugin } from "vite";
import path from "path";
import { execSync } from "child_process";
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig, type Plugin } from 'vite';
import { execSync } from 'child_process';
import { fontSubsetPlugin, type FontSubsetConfig } from './vite-plugin-font-subset';
// Character sets for font subsetting
const TITLE_CHARS = 'PACMN-';
const COMMON_CHARS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,!?':;-_()\/@#&*+=%<>";
const fontConfig: FontSubsetConfig = {
fonts: [
{
source: '@fontsource/russo-one/files/russo-one-latin-400-normal.woff2',
whitelist: TITLE_CHARS
},
{
source: '@fontsource/outfit/files/outfit-latin-400-normal.woff2',
whitelist: COMMON_CHARS,
family: 'Outfit'
},
{
source: '@fontsource/outfit/files/outfit-latin-500-normal.woff2',
whitelist: COMMON_CHARS,
family: 'Outfit'
}
]
};
/**
* Vite plugin that injects the Pacman version hash at build time.
* Uses git commit hash in production/dev, falls back to timestamp if git unavailable.
*/
function pacmanVersionPlugin(): Plugin {
let version: string;
function getVersion(mode: string): string {
if (mode === 'development') {
return 'dev';
}
function getVersion(mode: string): string {
// Development mode uses fixed "dev" string
if (mode === "development") {
return "dev";
}
try {
const hash = execSync('git rev-parse --short HEAD', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
}).trim();
// Try to get git commit hash
try {
const hash = execSync("git rev-parse --short HEAD", {
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
if (hash) {
return hash;
}
} catch {
// Git not available or command failed
}
if (hash) {
return hash;
}
} catch {
// Git not available or command failed
}
// Fallback to timestamp
return Date.now().toString(36);
}
return Date.now().toString(36);
}
return {
name: "pacman-version",
config(_, { mode }) {
version = getVersion(mode);
console.log(`[pacman-version] Using version: ${version}`);
return {
name: 'pacman-version',
config(_, { mode }) {
const version = getVersion(mode);
console.log(`[pacman-version] Using version: ${version}`);
return {
define: {
"import.meta.env.VITE_PACMAN_VERSION": JSON.stringify(version),
},
};
},
};
return {
define: {
'import.meta.env.VITE_PACMAN_VERSION': JSON.stringify(version)
}
};
}
};
}
export default defineConfig({
plugins: [pacmanVersionPlugin(), vike(), react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
},
dedupe: ["react", "react-dom"],
},
build: {
target: "es2022",
},
server: {
// Proxy API requests to the backend server during local development
// In production, both frontend and API are served from the same origin
proxy: {
"/api": {
target: process.env.VITE_API_TARGET || "http://localhost:3001",
changeOrigin: true,
},
},
},
plugins: [fontSubsetPlugin(fontConfig), pacmanVersionPlugin(), sveltekit(), tailwindcss()],
build: {
target: 'es2022'
},
server: {
proxy: {
'/api': {
target: process.env.VITE_API_TARGET || 'http://localhost:3001',
changeOrigin: true
}
}
}
});