mirror of
https://github.com/Xevion/Pac-Man.git
synced 2026-01-30 18:24:56 -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:
Vendored
+5
-5
@@ -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
@@ -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 ==========
|
||||
|
||||
+48
-2
@@ -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)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
Vendored
+20
-141
@@ -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-*
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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],
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
// https://vike.dev/Head
|
||||
|
||||
export default function HeadDefault() {
|
||||
return (
|
||||
<>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {},
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* @see https://prettier.io/docs/configuration
|
||||
* @type {import("prettier").Config}
|
||||
*/
|
||||
const config = {
|
||||
printWidth: 120,
|
||||
};
|
||||
|
||||
export default config;
|
||||
+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>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Vendored
-11
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user