mirror of
https://github.com/Xevion/Pac-Man.git
synced 2026-01-31 00:24:59 -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*
|
yarn-error.log*
|
||||||
|
|
||||||
# Emscripten build outputs (generated by cargo build)
|
# Emscripten build outputs (generated by cargo build)
|
||||||
web/public/pacman.data
|
web/static/pacman.data
|
||||||
web/public/pacman.js
|
web/static/pacman.js
|
||||||
web/public/pacman.wasm
|
web/static/pacman.wasm
|
||||||
web/public/pacman.wasm.map
|
web/static/pacman.wasm.map
|
||||||
|
|
||||||
# Site build f iles
|
# Site build files
|
||||||
tailwindcss-*
|
tailwindcss-*
|
||||||
pacman/assets/site/build.css
|
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
|
FROM oven/bun:1 AS frontend-builder
|
||||||
WORKDIR /app
|
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 package files for dependency installation
|
||||||
COPY web/package.json web/bun.lock* ./
|
COPY web/package.json web/bun.lock* ./
|
||||||
RUN bun install --frozen-lockfile
|
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/
|
# 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 ./static/pacman.wasm
|
||||||
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 ./static/pacman.js
|
||||||
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 ./static/pacman.data
|
||||||
COPY --from=wasm-builder /app/target/wasm32-unknown-emscripten/release/deps/pacman.data ./public/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
|
# Verify WASM artifacts exist and have reasonable sizes
|
||||||
RUN test -f ./public/pacman.wasm && \
|
RUN test -f ./static/pacman.wasm && \
|
||||||
test -f ./public/pacman.js && \
|
test -f ./static/pacman.js && \
|
||||||
test -f ./public/pacman.data && \
|
test -f ./static/pacman.data && \
|
||||||
[ $(stat -c%s ./public/pacman.wasm) -gt $((1024 * 1024)) ] && \
|
[ $(stat -c%s ./static/pacman.wasm) -gt $((1024 * 1024)) ] && \
|
||||||
[ $(stat -c%s ./public/pacman.js) -gt $((100 * 1024)) ] && \
|
[ $(stat -c%s ./static/pacman.js) -gt $((100 * 1024)) ] && \
|
||||||
[ $(stat -c%s ./public/pacman.data) -gt $((10 * 1024)) ] && \
|
[ $(stat -c%s ./static/pacman.data) -gt $((10 * 1024)) ] && \
|
||||||
echo "WASM artifacts verified (wasm >1MiB, js >100KiB, data >10KiB)" || \
|
echo "WASM artifacts verified (wasm >1MiB, js >100KiB, data >10KiB)" || \
|
||||||
(echo "WASM artifacts missing or too small!" && exit 1)
|
(echo "WASM artifacts missing or too small!" && exit 1)
|
||||||
|
|
||||||
# Copy frontend source
|
# Build frontend (SvelteKit bundles WASM files from static/)
|
||||||
COPY web/ ./
|
|
||||||
|
|
||||||
# Build frontend (Vite bundles WASM files from public/)
|
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
# ========== Stage 5: Backend Chef ==========
|
# ========== 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 buildType = release ? "release" : "debug";
|
||||||
const outputFolder = resolve(`target/wasm32-unknown-emscripten/${buildType}`);
|
const outputFolder = resolve(`target/wasm32-unknown-emscripten/${buildType}`);
|
||||||
const dist = resolve("web/public");
|
const dist = resolve("web/static");
|
||||||
|
|
||||||
// The files to copy into 'dist'
|
// The files to copy into 'dist'
|
||||||
const files = [
|
const files = [
|
||||||
@@ -98,7 +98,7 @@ async function build(release: boolean, env: Record<string, string> | null) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Copy the files to the dist folder
|
// 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(
|
await Promise.all(
|
||||||
files.map(async ({ optional, src, dest }) => {
|
files.map(async ({ optional, src, dest }) => {
|
||||||
match({ optional, exists: await fs.exists(src) })
|
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");
|
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)
|
// (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
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
# Logs
|
# Env
|
||||||
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.*
|
||||||
.env.test.local
|
!.env.example
|
||||||
.env.production.local
|
!.env.test
|
||||||
.env.local
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
# Vite
|
||||||
.cache
|
vite.config.js.timestamp-*
|
||||||
.parcel-cache
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
# 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 @@
|
|||||||
|
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",
|
"name": "pacman-web",
|
||||||
"description": "A web frontend for the Pac-Man game, including leaderboards and OAuth.",
|
"description": "A web frontend for the Pac-Man game, including leaderboards and OAuth.",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@^1.3.5",
|
"packageManager": "bun@^1.3.5",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -8,41 +10,41 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow bun",
|
"preinstall": "npx only-allow bun",
|
||||||
"dev": "vike dev",
|
"dev": "vite dev",
|
||||||
"build": "vike build",
|
"build": "vite build",
|
||||||
"preview": "vike preview",
|
"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 ."
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/outfit": "^5.2.8",
|
"@tabler/icons-svelte": "^3.35.0",
|
||||||
"@fontsource/pixelify-sans": "^5.2.7",
|
|
||||||
"@fontsource/russo-one": "^5.2.7",
|
|
||||||
"@tabler/icons-react": "^3.35.0",
|
|
||||||
"@vitejs/plugin-react": "^5.0.2",
|
|
||||||
"overlayscrollbars": "^2.13.0",
|
"overlayscrollbars": "^2.13.0",
|
||||||
"overlayscrollbars-react": "^0.5.6",
|
"overlayscrollbars-svelte": "^0.5.5"
|
||||||
"react": "^19.1.1",
|
|
||||||
"react-dom": "^19.1.1",
|
|
||||||
"vike": "^0.4.240",
|
|
||||||
"vike-react": "^0.6.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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",
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
"@types/node": "^25.0.3",
|
"@types/fontkit": "^2.0.8",
|
||||||
"@types/react": "^19.1.12",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react-dom": "^19.1.9",
|
|
||||||
"eslint": "^9.35.0",
|
"eslint": "^9.35.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"eslint-plugin-svelte": "^3.9.0",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"fontkit": "^2.0.4",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
"postcss": "^8.5.6",
|
|
||||||
"postcss-simple-vars": "^7.0.1",
|
|
||||||
"prettier": "^3.6.2",
|
"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",
|
"tailwindcss": "^4.1.13",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.42.0",
|
"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;
|
||||||
+11
-24
@@ -1,33 +1,20 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"module": "ESNext",
|
"strict": true,
|
||||||
"noEmit": true,
|
"moduleResolution": "bundler"
|
||||||
"moduleResolution": "Bundler",
|
|
||||||
"target": "ES2022",
|
|
||||||
"lib": [
|
|
||||||
"DOM",
|
|
||||||
"DOM.Iterable",
|
|
||||||
"ESNext"
|
|
||||||
],
|
|
||||||
"types": [
|
|
||||||
"vite/client",
|
|
||||||
"vike-react",
|
|
||||||
"node"
|
|
||||||
],
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"jsxImportSource": "react",
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./*"]
|
|
||||||
}
|
}
|
||||||
},
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
"exclude": [
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
"dist"
|
//
|
||||||
]
|
// 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+47
-37
@@ -1,28 +1,47 @@
|
|||||||
import tailwindcss from "@tailwindcss/vite";
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import react from "@vitejs/plugin-react";
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import vike from "vike/plugin";
|
import { defineConfig, type Plugin } from 'vite';
|
||||||
import { defineConfig, Plugin } from "vite";
|
import { execSync } from 'child_process';
|
||||||
import path from "path";
|
import { fontSubsetPlugin, type FontSubsetConfig } from './vite-plugin-font-subset';
|
||||||
import { execSync } from "child_process";
|
|
||||||
|
// 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.
|
* 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.
|
* Uses git commit hash in production/dev, falls back to timestamp if git unavailable.
|
||||||
*/
|
*/
|
||||||
function pacmanVersionPlugin(): Plugin {
|
function pacmanVersionPlugin(): Plugin {
|
||||||
let version: string;
|
|
||||||
|
|
||||||
function getVersion(mode: string): string {
|
function getVersion(mode: string): string {
|
||||||
// Development mode uses fixed "dev" string
|
if (mode === 'development') {
|
||||||
if (mode === "development") {
|
return 'dev';
|
||||||
return "dev";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get git commit hash
|
|
||||||
try {
|
try {
|
||||||
const hash = execSync("git rev-parse --short HEAD", {
|
const hash = execSync('git rev-parse --short HEAD', {
|
||||||
encoding: "utf8",
|
encoding: 'utf8',
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
}).trim();
|
}).trim();
|
||||||
|
|
||||||
if (hash) {
|
if (hash) {
|
||||||
@@ -32,44 +51,35 @@ function pacmanVersionPlugin(): Plugin {
|
|||||||
// Git not available or command failed
|
// Git not available or command failed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to timestamp
|
|
||||||
return Date.now().toString(36);
|
return Date.now().toString(36);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "pacman-version",
|
name: 'pacman-version',
|
||||||
config(_, { mode }) {
|
config(_, { mode }) {
|
||||||
version = getVersion(mode);
|
const version = getVersion(mode);
|
||||||
console.log(`[pacman-version] Using version: ${version}`);
|
console.log(`[pacman-version] Using version: ${version}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
define: {
|
define: {
|
||||||
"import.meta.env.VITE_PACMAN_VERSION": JSON.stringify(version),
|
'import.meta.env.VITE_PACMAN_VERSION': JSON.stringify(version)
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [pacmanVersionPlugin(), vike(), react(), tailwindcss()],
|
plugins: [fontSubsetPlugin(fontConfig), pacmanVersionPlugin(), sveltekit(), tailwindcss()],
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@": path.resolve(__dirname, "."),
|
|
||||||
},
|
|
||||||
dedupe: ["react", "react-dom"],
|
|
||||||
},
|
|
||||||
build: {
|
build: {
|
||||||
target: "es2022",
|
target: 'es2022'
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
// Proxy API requests to the backend server during local development
|
|
||||||
// In production, both frontend and API are served from the same origin
|
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
'/api': {
|
||||||
target: process.env.VITE_API_TARGET || "http://localhost:3001",
|
target: process.env.VITE_API_TARGET || 'http://localhost:3001',
|
||||||
changeOrigin: true,
|
changeOrigin: true
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user