mirror of
https://github.com/Xevion/Pac-Man.git
synced 2026-01-31 04:25:07 -06:00
feat(web): add smooth page transitions and WASM loading states
- Implement navigation state tracking with optimistic UI updates - Add loading spinner and error handling for WASM initialization - Insert browser yield points during game initialization to prevent freezing - Redesign leaderboard with tabbed navigation and mock data structure - Add utility CSS classes for consistent page layouts
This commit is contained in:
@@ -32,11 +32,16 @@ impl App {
|
||||
pub fn new() -> GameResult<Self> {
|
||||
info!("Initializing SDL2 application");
|
||||
let sdl_context = sdl2::init().map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
trace!("Yielding after SDL init");
|
||||
platform::yield_to_browser();
|
||||
|
||||
debug!("Initializing SDL2 subsystems");
|
||||
let ttf_context = sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
let video_subsystem = sdl_context.video().map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
let audio_subsystem = sdl_context.audio().map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
let event_pump = sdl_context.event_pump().map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
trace!("Yielding after subsystem init");
|
||||
platform::yield_to_browser();
|
||||
|
||||
trace!(
|
||||
width = (CANVAS_SIZE.x as f32 * SCALE).round() as u32,
|
||||
@@ -96,6 +101,8 @@ impl App {
|
||||
// .index(index)
|
||||
.build()
|
||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
trace!("Yielding after canvas creation");
|
||||
platform::yield_to_browser();
|
||||
|
||||
trace!(
|
||||
logical_width = CANVAS_SIZE.x,
|
||||
@@ -106,12 +113,16 @@ impl App {
|
||||
.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)
|
||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
debug!(renderer_info = ?canvas.info(), "Canvas renderer initialized");
|
||||
trace!("Yielding after logical size");
|
||||
platform::yield_to_browser();
|
||||
|
||||
trace!("Creating texture factory");
|
||||
let texture_creator = canvas.texture_creator();
|
||||
|
||||
info!("Starting game initialization");
|
||||
let game = Game::new(canvas, ttf_context, texture_creator, event_pump)?;
|
||||
trace!("Yielding after game init");
|
||||
platform::yield_to_browser();
|
||||
|
||||
info!("Application initialization completed successfully");
|
||||
Ok(App {
|
||||
|
||||
@@ -48,6 +48,7 @@ use crate::{
|
||||
asset::Asset,
|
||||
events::GameCommand,
|
||||
map::render::MapRenderer,
|
||||
platform,
|
||||
systems::{BatchedLinesResource, Bindings, CursorPosition, TtfAtlasResource},
|
||||
texture::sprite::{AtlasMapper, SpriteAtlas},
|
||||
};
|
||||
@@ -116,18 +117,27 @@ impl Game {
|
||||
debug!("Setting up textures and fonts");
|
||||
let (backbuffer, mut map_texture, debug_texture, ttf_atlas) =
|
||||
Self::setup_textures_and_fonts(&mut canvas, &texture_creator, ttf_context)?;
|
||||
trace!("Yielding after texture setup");
|
||||
platform::yield_to_browser();
|
||||
|
||||
debug!("Initializing audio subsystem");
|
||||
let audio = crate::audio::Audio::new();
|
||||
trace!("Yielding after audio init");
|
||||
platform::yield_to_browser();
|
||||
|
||||
debug!("Loading sprite atlas and map tiles");
|
||||
let (mut atlas, map_tiles) = Self::load_atlas_and_map_tiles(&texture_creator)?;
|
||||
trace!("Yielding after atlas load");
|
||||
platform::yield_to_browser();
|
||||
|
||||
debug!("Rendering static map to texture cache");
|
||||
canvas
|
||||
.with_texture_canvas(&mut map_texture, |map_canvas| {
|
||||
MapRenderer::render_map(map_canvas, &mut atlas, &map_tiles);
|
||||
})
|
||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
trace!("Yielding after map render");
|
||||
platform::yield_to_browser();
|
||||
|
||||
debug!("Building navigation graph from map layout");
|
||||
let map = Map::new(constants::RAW_BOARD)?;
|
||||
@@ -235,30 +245,41 @@ impl Game {
|
||||
sdl2::render::Texture,
|
||||
crate::texture::ttf::TtfAtlas,
|
||||
)> {
|
||||
trace!("Creating backbuffer texture");
|
||||
let mut backbuffer = texture_creator
|
||||
.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
|
||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
backbuffer.set_scale_mode(ScaleMode::Nearest);
|
||||
platform::yield_to_browser();
|
||||
|
||||
trace!("Creating map texture");
|
||||
let mut map_texture = texture_creator
|
||||
.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
|
||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
map_texture.set_scale_mode(ScaleMode::Nearest);
|
||||
platform::yield_to_browser();
|
||||
|
||||
trace!("Creating debug texture");
|
||||
let output_size = constants::LARGE_CANVAS_SIZE;
|
||||
let mut debug_texture = texture_creator
|
||||
.create_texture_target(Some(sdl2::pixels::PixelFormatEnum::ARGB8888), output_size.x, output_size.y)
|
||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
debug_texture.set_blend_mode(BlendMode::Blend);
|
||||
debug_texture.set_scale_mode(ScaleMode::Nearest);
|
||||
platform::yield_to_browser();
|
||||
|
||||
trace!("Loading font");
|
||||
let font_data: &'static [u8] = Asset::Font.get_bytes()?.to_vec().leak();
|
||||
let font_asset = RWops::from_bytes(font_data).map_err(|_| GameError::Sdl("Failed to load font".to_string()))?;
|
||||
let debug_font = ttf_context
|
||||
.load_font_from_rwops(font_asset, constants::ui::DEBUG_FONT_SIZE)
|
||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
|
||||
trace!("Creating TTF atlas");
|
||||
let mut ttf_atlas = crate::texture::ttf::TtfAtlas::new(texture_creator, &debug_font)?;
|
||||
platform::yield_to_browser();
|
||||
|
||||
trace!("Populating TTF atlas");
|
||||
ttf_atlas.populate_atlas(canvas, texture_creator, &debug_font)?;
|
||||
|
||||
Ok((backbuffer, map_texture, debug_texture, ttf_atlas))
|
||||
|
||||
@@ -20,6 +20,10 @@ pub fn sleep(duration: Duration, focused: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// No-op on desktop - only needed for browser event loop yielding.
|
||||
#[inline]
|
||||
pub fn yield_to_browser() {}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub fn init_console(force_console: bool) -> Result<(), PlatformError> {
|
||||
use crate::formatter::CustomFormatter;
|
||||
|
||||
@@ -49,6 +49,15 @@ pub fn sleep(duration: Duration, _focused: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Yields control to browser event loop without delay.
|
||||
/// Allows page transitions, animations, and events to process during initialization.
|
||||
/// Uses ASYNCIFY to pause/resume WASM execution.
|
||||
pub fn yield_to_browser() {
|
||||
unsafe {
|
||||
emscripten_sleep(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_console(_force_console: bool) -> Result<(), PlatformError> {
|
||||
use tracing_subscriber::{fmt, layer::SubscriberExt, EnvFilter};
|
||||
|
||||
|
||||
+13
-2
@@ -5,11 +5,13 @@
|
||||
"": {
|
||||
"name": "pacman-web",
|
||||
"dependencies": {
|
||||
"@fontsource/nunito": "^5.2.7",
|
||||
"@fontsource/outfit": "^5.2.8",
|
||||
"@fontsource/pixelify-sans": "^5.2.7",
|
||||
"@fontsource/russo-one": "^5.2.7",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"overlayscrollbars": "^2.13.0",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"vike": "^0.4.240",
|
||||
@@ -18,6 +20,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"eslint": "^9.35.0",
|
||||
@@ -154,7 +157,7 @@
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||
|
||||
"@fontsource/nunito": ["@fontsource/nunito@5.2.7", "", {}, "sha512-pmtBq0H9ex9nk+RtJYEJOD9pag393iHETnl/PVKleF4i06cd0ttngK5ZCTgYb5eOqR3Xdlrjtev8m7bmgYprew=="],
|
||||
"@fontsource/outfit": ["@fontsource/outfit@5.2.8", "", {}, "sha512-rXC6g0MqD7cOBjht0bMqc43qM6lRqDLML9KXsmg9uykz0wLQhy8Z/ajrMG6iyoT3NJR+MYgU+OEHp7uHoTb+Yw=="],
|
||||
|
||||
"@fontsource/pixelify-sans": ["@fontsource/pixelify-sans@5.2.7", "", {}, "sha512-F/UuV2M9poAj/BJ5/6u95mOy6ptp8/+dpfnxh4TFzKeAB+vdanAjQ8fLJcS0q+WHYgesRRxPwNFr2Pqm18CVGg=="],
|
||||
|
||||
@@ -280,6 +283,8 @@
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="],
|
||||
@@ -668,6 +673,10 @@
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"overlayscrollbars": ["overlayscrollbars@2.13.0", "", {}, "sha512-uQGpLESrbFDLTWucWAKX9ceIANj7detMwH/2yJ315Llt72ZcWN3P6ckMotoqVv2Mk29R/pnhDtgYjy4K+kwAyQ=="],
|
||||
|
||||
"overlayscrollbars-react": ["overlayscrollbars-react@0.5.6", "", { "peerDependencies": { "overlayscrollbars": "^2.0.0", "react": ">=16.8.0" } }, "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw=="],
|
||||
|
||||
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
@@ -812,6 +821,8 @@
|
||||
|
||||
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
@@ -9,6 +9,32 @@ 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 = [
|
||||
{
|
||||
@@ -36,7 +62,9 @@ const links = [
|
||||
export function Link({ href, label, icon }: { href: string; label: string; icon?: React.ReactNode }) {
|
||||
const pageContext = usePageContext();
|
||||
const { urlPathname } = pageContext;
|
||||
const isActive = href === "/" ? urlPathname === href : urlPathname.startsWith(href);
|
||||
const pendingUrl = usePendingNavigation();
|
||||
const effectiveUrl = pendingUrl ?? urlPathname;
|
||||
const isActive = href === "/" ? effectiveUrl === href : effectiveUrl.startsWith(href);
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
@@ -44,10 +72,7 @@ export function Link({ href, label, icon }: { href: string; label: string; icon?
|
||||
flex items-center gap-1.5
|
||||
tracking-wide
|
||||
transition-colors duration-200
|
||||
${isActive
|
||||
? "text-white"
|
||||
: "text-gray-500 hover:text-gray-300"
|
||||
}
|
||||
${isActive ? "text-white" : "text-gray-500 hover:text-gray-300"}
|
||||
`}
|
||||
>
|
||||
{icon}
|
||||
@@ -63,9 +88,9 @@ export default function LayoutDefault({ children }: { children: React.ReactNode
|
||||
|
||||
const pageContext = usePageContext();
|
||||
const { urlPathname } = pageContext;
|
||||
const isIndexPage = urlPathname === "/";
|
||||
const isLeaderboardPage = urlPathname.startsWith("/leaderboard");
|
||||
const isDownloadPage = urlPathname.startsWith("/download");
|
||||
const pendingUrl = usePendingNavigation();
|
||||
const effectiveUrl = pendingUrl ?? urlPathname;
|
||||
const isIndexPage = effectiveUrl === "/";
|
||||
|
||||
const sourceLinks = links
|
||||
.filter((link) => !link.href.startsWith("/"))
|
||||
@@ -106,12 +131,11 @@ export default function LayoutDefault({ children }: { children: React.ReactNode
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
className={`text-3xl tracking-[0.3em] title-hover ${
|
||||
isIndexPage
|
||||
? 'text-yellow-400'
|
||||
: 'glimmer-text'
|
||||
className={`text-3xl tracking-[0.3em] text-yellow-400 title-base ${
|
||||
isIndexPage ? "" : "title-glimmer title-hover"
|
||||
}`}
|
||||
style={{ fontFamily: 'Russo One' }}
|
||||
style={{ fontFamily: "Russo One" }}
|
||||
data-text="PAC-MAN"
|
||||
>
|
||||
PAC-MAN
|
||||
</h1>
|
||||
@@ -124,19 +148,9 @@ export default function LayoutDefault({ children }: { children: React.ReactNode
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<OverlayScrollbarsComponent
|
||||
defer
|
||||
options={{
|
||||
scrollbars: {
|
||||
theme: 'os-theme-light',
|
||||
autoHide: 'scroll',
|
||||
autoHideDelay: 1300,
|
||||
},
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<ClientOnlyScrollbars className="flex-1">
|
||||
<main>{children}</main>
|
||||
</OverlayScrollbarsComponent>
|
||||
</ClientOnlyScrollbars>
|
||||
|
||||
{opened && (
|
||||
<div className="fixed inset-0 z-30">
|
||||
|
||||
@@ -66,9 +66,71 @@
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
@@ -78,7 +140,14 @@
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.glimmer-text {
|
||||
.title-base {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.title-base::before {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgb(156 163 175) 0%,
|
||||
@@ -93,5 +162,11 @@
|
||||
-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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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);
|
||||
}
|
||||
@@ -5,11 +5,20 @@ export interface PacmanModule {
|
||||
_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;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,6 @@
|
||||
"name": "pacman-web",
|
||||
"description": "A web frontend for the Pac-Man game, including leaderboards and OAuth.",
|
||||
"dependencies": {
|
||||
"@fontsource/nunito": "^5.2.7",
|
||||
"@fontsource/outfit": "^5.2.8",
|
||||
"@fontsource/pixelify-sans": "^5.2.7",
|
||||
"@fontsource/russo-one": "^5.2.7",
|
||||
@@ -25,6 +24,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"eslint": "^9.35.0",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { OnPageTransitionEndAsync } from "vike/types";
|
||||
import { getPacmanWindow } from "@/lib/pacman";
|
||||
import { setPendingNavigation } from "@/lib/navigation";
|
||||
|
||||
export const onPageTransitionEnd: OnPageTransitionEndAsync = async (
|
||||
pageContext
|
||||
) => {
|
||||
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
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
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 () => {
|
||||
export const onPageTransitionStart: OnPageTransitionStartAsync = async (pageContext) => {
|
||||
console.log("Page transition start");
|
||||
setPendingNavigation(pageContext.urlPathname);
|
||||
document.querySelector("body")?.classList.add("page-is-transitioning");
|
||||
|
||||
// Stop the game loop when navigating away from the game page
|
||||
|
||||
@@ -2,18 +2,10 @@ import { usePageContext } from "vike-react/usePageContext";
|
||||
|
||||
export default function Page() {
|
||||
const { is404 } = usePageContext();
|
||||
if (is404) {
|
||||
return (
|
||||
<>
|
||||
<h1>Page Not Found</h1>
|
||||
<p>This page could not be found.</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h1>Internal Error</h1>
|
||||
<p>Something went wrong.</p>
|
||||
</>
|
||||
<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 +1,10 @@
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl py-8 px-4">
|
||||
<div className="page-container">
|
||||
<div className="space-y-6">
|
||||
<div className="border border-yellow-400/20 rounded-md bg-transparent p-6 shadow-[0_4px_20px_rgba(250,204,21,0.08)]">
|
||||
<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>
|
||||
<p className="text-gray-300 mb-4">Download instructions and releases will be available here soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import "../../layouts/tailwind.css";
|
||||
|
||||
export default function GameLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-black text-yellow-400 h-full flex flex-col overflow-hidden">
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { getPacmanWindow } from "@/lib/pacman";
|
||||
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();
|
||||
@@ -13,6 +39,12 @@ export default function Page() {
|
||||
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),
|
||||
@@ -27,6 +59,7 @@ export default function Page() {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -36,15 +69,36 @@ export default function Page() {
|
||||
return path.startsWith("/") ? path : `/${path}`;
|
||||
},
|
||||
preRun: [],
|
||||
// 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";
|
||||
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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -81,12 +135,52 @@ export default function Page() {
|
||||
>
|
||||
<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>
|
||||
<span className="text-yellow-400 text-5xl font-bold">Click to Start</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+12
-239
@@ -1,228 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { IconTrophy, IconCalendar } from "@tabler/icons-react";
|
||||
|
||||
interface LeaderboardEntry {
|
||||
id: number;
|
||||
rank: number;
|
||||
name: string;
|
||||
score: number;
|
||||
duration: string;
|
||||
levelCount: number;
|
||||
submittedAt: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
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",
|
||||
},
|
||||
];
|
||||
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, entryIndex) => (
|
||||
{data.map((entry) => (
|
||||
<tr key={entry.id} className="bg-black">
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -234,9 +18,7 @@ function LeaderboardTable({ data }: { data: LeaderboardEntry[] }) {
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<span className="text-yellow-300 font-[600] text-lg">
|
||||
{entry.score.toLocaleString()}
|
||||
</span>
|
||||
<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>
|
||||
@@ -249,33 +31,24 @@ function LeaderboardTable({ data }: { data: LeaderboardEntry[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
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="mx-auto max-w-3xl py-8 px-4">
|
||||
<div className="page-container">
|
||||
<div className="space-y-6">
|
||||
<div className="border border-yellow-400/20 rounded-md bg-transparent p-6 shadow-[0_4px_20px_rgba(250,204,21,0.08)]">
|
||||
<div className="card">
|
||||
<div className="flex gap-2 border-b border-yellow-400/20 pb-2 mb-4">
|
||||
<button
|
||||
onClick={() => setActiveTab("global")}
|
||||
className={
|
||||
activeTab === "global"
|
||||
? "inline-flex items-center gap-1 px-3 py-1 rounded border border-yellow-400/40 text-yellow-300"
|
||||
: "inline-flex items-center gap-1 px-3 py-1 rounded border border-transparent text-gray-300 hover:text-yellow-200"
|
||||
}
|
||||
>
|
||||
<button onClick={() => setActiveTab("global")} className={tabButtonClass(activeTab === "global")}>
|
||||
<IconTrophy size={16} />
|
||||
Global
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("monthly")}
|
||||
className={
|
||||
activeTab === "monthly"
|
||||
? "inline-flex items-center gap-1 px-3 py-1 rounded border border-yellow-400/40 text-yellow-300"
|
||||
: "inline-flex items-center gap-1 px-3 py-1 rounded border border-transparent text-gray-300 hover:text-yellow-200"
|
||||
}
|
||||
>
|
||||
<button onClick={() => setActiveTab("monthly")} className={tabButtonClass(activeTab === "monthly")}>
|
||||
<IconCalendar size={16} />
|
||||
Monthly
|
||||
</button>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
Generated
+30
-14
@@ -25,7 +25,7 @@ importers:
|
||||
version: 3.36.0(react@19.2.3)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.0.2
|
||||
version: 5.1.2(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
version: 5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
overlayscrollbars:
|
||||
specifier: ^2.13.0
|
||||
version: 2.13.0
|
||||
@@ -40,17 +40,20 @@ importers:
|
||||
version: 19.2.3(react@19.2.3)
|
||||
vike:
|
||||
specifier: ^0.4.240
|
||||
version: 0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
version: 0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
vike-react:
|
||||
specifier: ^0.6.5
|
||||
version: 0.6.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vike@0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2)))
|
||||
version: 0.6.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vike@0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)))
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.35.0
|
||||
version: 9.39.2
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.13
|
||||
version: 4.1.18(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
version: 4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
'@types/node':
|
||||
specifier: ^25.0.3
|
||||
version: 25.0.3
|
||||
'@types/react':
|
||||
specifier: ^19.1.12
|
||||
version: 19.2.7
|
||||
@@ -92,7 +95,7 @@ importers:
|
||||
version: 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
vite:
|
||||
specifier: ^7.1.4
|
||||
version: 7.3.0(jiti@2.6.1)(lightningcss@1.30.2)
|
||||
version: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -668,6 +671,9 @@ packages:
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/node@25.0.3':
|
||||
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
|
||||
|
||||
'@types/react-dom@19.2.3':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
peerDependencies:
|
||||
@@ -1767,6 +1773,9 @@ packages:
|
||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
|
||||
update-browserslist-db@1.2.3:
|
||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||
hasBin: true
|
||||
@@ -2297,12 +2306,12 @@ snapshots:
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.1.18
|
||||
|
||||
'@tailwindcss/vite@4.1.18(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2))':
|
||||
'@tailwindcss/vite@4.1.18(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.1.18
|
||||
'@tailwindcss/oxide': 4.1.18
|
||||
tailwindcss: 4.1.18
|
||||
vite: 7.3.0(jiti@2.6.1)(lightningcss@1.30.2)
|
||||
vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
@@ -2329,6 +2338,10 @@ snapshots:
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/node@25.0.3':
|
||||
dependencies:
|
||||
undici-types: 7.16.0
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.7)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.7
|
||||
@@ -2428,7 +2441,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.50.1
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
||||
'@vitejs/plugin-react@5.1.2(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2))':
|
||||
'@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5)
|
||||
@@ -2436,7 +2449,7 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.53
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.18.0
|
||||
vite: 7.3.0(jiti@2.6.1)(lightningcss@1.30.2)
|
||||
vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -3672,6 +3685,8 @@ snapshots:
|
||||
has-symbols: 1.1.0
|
||||
which-boxed-primitive: 1.1.1
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
update-browserslist-db@1.2.3(browserslist@4.28.1):
|
||||
dependencies:
|
||||
browserslist: 4.28.1
|
||||
@@ -3682,14 +3697,14 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
vike-react@0.6.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vike@0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2))):
|
||||
vike-react@0.6.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vike@0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
react-streaming: 0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
vike: 0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
vike: 0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
|
||||
vike@0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2)):
|
||||
vike@0.4.250(react-streaming@0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)):
|
||||
dependencies:
|
||||
'@babel/core': 7.28.5
|
||||
'@babel/types': 7.28.5
|
||||
@@ -3710,11 +3725,11 @@ snapshots:
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
react-streaming: 0.4.15(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
vite: 7.3.0(jiti@2.6.1)(lightningcss@1.30.2)
|
||||
vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vite@7.3.0(jiti@2.6.1)(lightningcss@1.30.2):
|
||||
vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2):
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -3723,6 +3738,7 @@ snapshots:
|
||||
rollup: 4.54.0
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 25.0.3
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
lightningcss: 1.30.2
|
||||
|
||||
+7
-2
@@ -17,10 +17,15 @@
|
||||
],
|
||||
"types": [
|
||||
"vite/client",
|
||||
"vike-react"
|
||||
"vike-react",
|
||||
"node"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
"jsxImportSource": "react",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"dist"
|
||||
|
||||
+3
-2
@@ -10,6 +10,7 @@ export default defineConfig({
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "."),
|
||||
},
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
build: {
|
||||
target: "es2022",
|
||||
@@ -18,8 +19,8 @@ export default defineConfig({
|
||||
// Proxy API requests to the backend server during local development
|
||||
// In production, both frontend and API are served from the same origin
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_TARGET || 'http://localhost:3001',
|
||||
"/api": {
|
||||
target: process.env.VITE_API_TARGET || "http://localhost:3001",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user