diff --git a/client/assets/symbols/ANTE_ACTIVE-removebg-preview.webp b/client/assets/symbols/ANTE_ACTIVE-removebg-preview.webp new file mode 100644 index 0000000..263d001 Binary files /dev/null and b/client/assets/symbols/ANTE_ACTIVE-removebg-preview.webp differ diff --git a/client/assets/symbols/ANTE_INACTIVE-removebg-preview.webp b/client/assets/symbols/ANTE_INACTIVE-removebg-preview.webp new file mode 100644 index 0000000..dc3e316 Binary files /dev/null and b/client/assets/symbols/ANTE_INACTIVE-removebg-preview.webp differ diff --git a/client/assets/symbols/BET-removebg-preview.webp b/client/assets/symbols/BET-removebg-preview.webp new file mode 100644 index 0000000..4c99f37 Binary files /dev/null and b/client/assets/symbols/BET-removebg-preview.webp differ diff --git a/client/assets/symbols/BUY_FEATURE-removebg-preview.webp b/client/assets/symbols/BUY_FEATURE-removebg-preview.webp new file mode 100644 index 0000000..facc96b Binary files /dev/null and b/client/assets/symbols/BUY_FEATURE-removebg-preview.webp differ diff --git a/client/assets/symbols/Background.webp b/client/assets/symbols/Background.webp new file mode 100644 index 0000000..d100eca Binary files /dev/null and b/client/assets/symbols/Background.webp differ diff --git a/client/assets/symbols/BlueGem.webp b/client/assets/symbols/BlueGem.webp new file mode 100644 index 0000000..258ed3f Binary files /dev/null and b/client/assets/symbols/BlueGem.webp differ diff --git a/client/assets/symbols/Chaile.webp b/client/assets/symbols/Chaile.webp new file mode 100644 index 0000000..28ec5c6 Binary files /dev/null and b/client/assets/symbols/Chaile.webp differ diff --git a/client/assets/symbols/Common.webp b/client/assets/symbols/Common.webp new file mode 100644 index 0000000..bd96cce Binary files /dev/null and b/client/assets/symbols/Common.webp differ diff --git a/client/assets/symbols/Crown.webp b/client/assets/symbols/Crown.webp new file mode 100644 index 0000000..a5e7f84 Binary files /dev/null and b/client/assets/symbols/Crown.webp differ diff --git a/client/assets/symbols/Epic.webp b/client/assets/symbols/Epic.webp new file mode 100644 index 0000000..eff4415 Binary files /dev/null and b/client/assets/symbols/Epic.webp differ diff --git a/client/assets/symbols/FREESPINS_INFO-removebg-preview.webp b/client/assets/symbols/FREESPINS_INFO-removebg-preview.webp new file mode 100644 index 0000000..0c37ee9 Binary files /dev/null and b/client/assets/symbols/FREESPINS_INFO-removebg-preview.webp differ diff --git a/client/assets/symbols/GreenGem.webp b/client/assets/symbols/GreenGem.webp new file mode 100644 index 0000000..aebbe6b Binary files /dev/null and b/client/assets/symbols/GreenGem.webp differ diff --git a/client/assets/symbols/HourGlass.webp b/client/assets/symbols/HourGlass.webp new file mode 100644 index 0000000..f99e244 Binary files /dev/null and b/client/assets/symbols/HourGlass.webp differ diff --git a/client/assets/symbols/Legendary.webp b/client/assets/symbols/Legendary.webp new file mode 100644 index 0000000..3831e76 Binary files /dev/null and b/client/assets/symbols/Legendary.webp differ diff --git a/client/assets/symbols/Mythic.webp b/client/assets/symbols/Mythic.webp new file mode 100644 index 0000000..8ddfffd Binary files /dev/null and b/client/assets/symbols/Mythic.webp differ diff --git a/client/assets/symbols/PurpleGem.webp b/client/assets/symbols/PurpleGem.webp new file mode 100644 index 0000000..0634b85 Binary files /dev/null and b/client/assets/symbols/PurpleGem.webp differ diff --git a/client/assets/symbols/REEL.webp b/client/assets/symbols/REEL.webp new file mode 100644 index 0000000..9ad2827 Binary files /dev/null and b/client/assets/symbols/REEL.webp differ diff --git a/client/assets/symbols/Rare.webp b/client/assets/symbols/Rare.webp new file mode 100644 index 0000000..0c07543 Binary files /dev/null and b/client/assets/symbols/Rare.webp differ diff --git a/client/assets/symbols/RedGem.webp b/client/assets/symbols/RedGem.webp new file mode 100644 index 0000000..fbc3b63 Binary files /dev/null and b/client/assets/symbols/RedGem.webp differ diff --git a/client/assets/symbols/Ring.webp b/client/assets/symbols/Ring.webp new file mode 100644 index 0000000..8cfee9e Binary files /dev/null and b/client/assets/symbols/Ring.webp differ diff --git a/client/assets/symbols/SPIN.webp b/client/assets/symbols/SPIN.webp new file mode 100644 index 0000000..229ad19 Binary files /dev/null and b/client/assets/symbols/SPIN.webp differ diff --git a/client/assets/symbols/Scatter.webp b/client/assets/symbols/Scatter.webp new file mode 100644 index 0000000..7916a56 Binary files /dev/null and b/client/assets/symbols/Scatter.webp differ diff --git a/client/assets/symbols/YellowGem.webp b/client/assets/symbols/YellowGem.webp new file mode 100644 index 0000000..8dbfd89 Binary files /dev/null and b/client/assets/symbols/YellowGem.webp differ diff --git a/client/engine/ambiance.js b/client/engine/ambiance.js index ab6dee9..efb2a60 100644 --- a/client/engine/ambiance.js +++ b/client/engine/ambiance.js @@ -23,7 +23,7 @@ // Optional real-character art. Leave null to use the CSS idol. const MASCOT_IMG = null; // e.g. "assets/mascot/zeus.png" - const CROWN_IMG = "assets/symbols/Crown.png"; + const CROWN_IMG = "assets/symbols/Crown.webp"; const reduceMotion = root.matchMedia && root.matchMedia("(prefers-reduced-motion: reduce)").matches; @@ -139,7 +139,7 @@ node.style.setProperty("--delay", `${-Math.random() * 16}s`); node.style.setProperty("--spin", `${Math.random() < 0.5 ? -1 : 1}`); if (useImg) { - node.src = `assets/symbols/${gems[i % gems.length]}.png`; + node.src = `assets/symbols/${gems[i % gems.length]}.webp`; node.alt = ""; node.classList.add("fx-deco-gem"); } else { diff --git a/client/engine/audio.js b/client/engine/audio.js index da66a86..d0587e7 100644 --- a/client/engine/audio.js +++ b/client/engine/audio.js @@ -54,7 +54,9 @@ loading: new Map(), // name -> Promise lastPlay: new Map(), // name -> timestamp (debounce identical rapid triggers) listeners: new Set(), // change subscribers (UI sync) - warmed: false // whether the audio pipeline has been pre-warmed + warmed: false, // whether the audio pipeline has been pre-warmed + suspended: false, // parked because the tab/app is in the background + wasPlaying: false // music state to restore when we come back }; function ensureContext() { @@ -192,7 +194,11 @@ } }, win_tick: { - minGap: 8, + // The win-meter count-up drives this every ~16ms, so a minGap below that + // meant a fresh oscillator + gain node ~60x a second for the whole + // count-up. 45ms still reads as a continuous ticking run but builds a + // third of the audio graph. + minGap: 45, synth: (c, d, o = {}) => { const f = clamp(520 + (o.progress || 0) * 900, 520, 1700); tone(c, d, { type: "triangle", freq: f, dur: 0.04, gain: 0.12 }); @@ -458,6 +464,11 @@ const def = SFX[name]; if (!def) return; if (prefs.muted) return; + // Parked in the background: drop the sound rather than resuming the context. + // Timer-driven game state (autoplay, win count-ups) keeps advancing while + // hidden, and without this every one of those steps would un-suspend the + // audio hardware we just parked. + if (state.suspended) return; const ctx = ensureContext(); if (!ctx) return; if (ctx.state === "suspended") ctx.resume(); @@ -514,6 +525,38 @@ return () => state.listeners.delete(fn); } + // --- background suspend ---------------------------------------------------- + // The music scheduler re-arms a setTimeout every 130ms for the entire session + // and the AudioContext is never suspended, so a backgrounded game kept the + // audio render thread and the hardware clock alive indefinitely. Worse, a + // hidden tab clamps setTimeout to ~1s while the scheduler only looks 0.35s + // ahead, so it under-schedules and then burst-catches-up on every tick. + // + // suspendAudio() parks all of it; resumeAudio() puts it back exactly as it + // was. `wasPlaying` remembers whether music was running so we don't start it + // for a player who had muted or never triggered the first gesture. + function suspendAudio() { + if (state.suspended) return; + state.suspended = true; + state.wasPlaying = Boolean(music); + stopMusic(); + const ctx = state.ctx; + if (ctx && ctx.state === "running") { + try { ctx.suspend(); } catch (e) { /* ignore */ } + } + } + + function resumeAudio() { + if (!state.suspended) return; + state.suspended = false; + const ctx = state.ctx; + if (ctx && ctx.state === "suspended") { + try { ctx.resume(); } catch (e) { /* ignore */ } + } + if (state.wasPlaying && !prefs.muted && state.unlocked) startMusic(); + state.wasPlaying = false; + } + // Self-install a one-shot unlock on the first user gesture as a safety net, // even if main.js forgets to call unlock(). function installAutoUnlock() { @@ -535,6 +578,10 @@ onChange, startMusic, stopMusic, + suspendAudio, + resumeAudio, + isSuspended: () => Boolean(state.suspended), + contextState: () => (state.ctx ? state.ctx.state : "none"), isMuted: () => prefs.muted, getVolume: () => prefs.volume, names: () => Object.keys(SFX) diff --git a/client/index.html b/client/index.html index 40f0bd3..2e8934c 100644 --- a/client/index.html +++ b/client/index.html @@ -19,7 +19,16 @@ })(); - + + + + +
@@ -151,15 +160,15 @@

You won 15 Free Spins

@@ -346,8 +355,8 @@

Game Rules

- - - + + + diff --git a/client/main.js b/client/main.js index 5443b7d..212e538 100644 --- a/client/main.js +++ b/client/main.js @@ -160,27 +160,49 @@ const payouts = { }; const scatterPayouts = { 4: 6, 5: 10, 6: 200 }; +// All art is WebP, generated from the source PNGs by `npm run assets:optimize` +// (tools/optimize-assets.js). The PNGs stay in the repo as the authoring +// originals; only the .webp files are ever fetched by the game. const symbolAssets = { - TOP_CROWN: "assets/symbols/Crown.png", - HOURGLASS: "assets/symbols/HourGlass.png", - RING: "assets/symbols/Ring.png", - CHALICE: "assets/symbols/Chaile.png", - RED_GEM: "assets/symbols/RedGem.png", - PURPLE_TRIANGLE: "assets/symbols/PurpleGem.png", - YELLOW_HEX: "assets/symbols/YellowGem.png", - GREEN_TRIANGLE: "assets/symbols/GreenGem.png", - BLUE_DIAMOND: "assets/symbols/BlueGem.png", - REEL: "assets/symbols/REEL.png", + TOP_CROWN: "assets/symbols/Crown.webp", + HOURGLASS: "assets/symbols/HourGlass.webp", + RING: "assets/symbols/Ring.webp", + CHALICE: "assets/symbols/Chaile.webp", + RED_GEM: "assets/symbols/RedGem.webp", + PURPLE_TRIANGLE: "assets/symbols/PurpleGem.webp", + YELLOW_HEX: "assets/symbols/YellowGem.webp", + GREEN_TRIANGLE: "assets/symbols/GreenGem.webp", + BLUE_DIAMOND: "assets/symbols/BlueGem.webp", + REEL: "assets/symbols/REEL.webp", // Multiplier art is now ONE token per rarity tier (numbers are drawn in code // onto the blank center plate — see the MULTI draw block below). The generic // MULTI key falls back to the Common token if a tier image fails to load. - MULTI: "assets/symbols/Common.png", - MULTI_COMMON: "assets/symbols/Common.png", - MULTI_RARE: "assets/symbols/Rare.png", - MULTI_EPIC: "assets/symbols/Epic.png", - MULTI_LEGENDARY: "assets/symbols/Legendary.png", - MULTI_MYTHIC: "assets/symbols/Mythic.png", - SCATTER: "assets/symbols/Scatter.png" + MULTI: "assets/symbols/Common.webp", + MULTI_COMMON: "assets/symbols/Common.webp", + MULTI_RARE: "assets/symbols/Rare.webp", + MULTI_EPIC: "assets/symbols/Epic.webp", + MULTI_LEGENDARY: "assets/symbols/Legendary.webp", + MULTI_MYTHIC: "assets/symbols/Mythic.webp", + SCATTER: "assets/symbols/Scatter.webp" +}; + +// Art that is NOT drawn on the canvas: buttons and CSS backgrounds. None +// of it used to be preloaded, so the free-spins panel and the active-ante plate +// were fetched at the moment they first appeared and painted in progressively +// mid-game. Routing them through the same loader as the symbols means the boot +// bar waits for them too and nothing is ever fetched after the game reveals. +// +// IMPORTANT: these strings must byte-match the URLs in index.html/styles.css, +// otherwise the browser treats them as different resources and the preload is +// wasted. See the note on in index.html. +const uiAssets = { + UI_SPIN: "assets/symbols/SPIN.webp", + UI_BACKGROUND: "assets/symbols/Background.webp", + UI_BET: "assets/symbols/BET-removebg-preview.webp", + UI_ANTE_ACTIVE: "assets/symbols/ANTE_ACTIVE-removebg-preview.webp", + UI_ANTE_INACTIVE: "assets/symbols/ANTE_INACTIVE-removebg-preview.webp", + UI_BUY_FEATURE: "assets/symbols/BUY_FEATURE-removebg-preview.webp", + UI_FREESPINS_INFO: "assets/symbols/FREESPINS_INFO-removebg-preview.webp" }; const stats = { spins: 0, wins: 0, losses: 0, wagered: 0, won: 0, maxWinX: 0, bonusHits: 0 }; @@ -368,13 +390,35 @@ const prefersReducedMotion = () => // trade visual richness for framerate on weaker hardware. Each tier maps to the // three knobs that actually move the needle: backing-store resolution (maxDpr), // a ceiling on particle density (fxCeil), and how hard idle frames are throttled. +// Quality tiers. "high" is the full desktop experience; "mid"/"low" progressively +// trade visual richness for framerate on weaker hardware. Four knobs: +// maxDpr — backing-store resolution cap (fill rate). +// fxCeil — ceiling on particle density. +// idleThrottleMs — minimum gap between frames when nothing is animating. +// maxFrameMs — minimum gap between frames when something IS animating. +// idleThrottleMs is now non-zero on every tier: the board is never truly static +// (symbols float, the fog gradients drift), so an untricked "high" tier meant a +// full redraw at 60fps forever — the single biggest source of phone heat. ~20fps +// idle keeps the ambient motion readable at a third of the work. const TIER_PROFILES = { - low: { maxDpr: 1, fxCeil: 0.5, idleThrottleMs: 50 }, - mid: { maxDpr: 1.5, fxCeil: 0.8, idleThrottleMs: 33 }, - high: { maxDpr: 2, fxCeil: 1, idleThrottleMs: 0 } + low: { maxDpr: 1, fxCeil: 0.5, idleThrottleMs: 66, maxFrameMs: 33 }, + mid: { maxDpr: 1.5, fxCeil: 0.8, idleThrottleMs: 50, maxFrameMs: 33 }, + high: { maxDpr: 2, fxCeil: 1, idleThrottleMs: 50, maxFrameMs: 0 } }; const TIER_RANK = { low: 0, mid: 1, high: 2 }; +// True on phones/tablets: a coarse primary pointer with no hover is the reliable +// signal, with touch-point count and viewport size as corroboration for browsers +// that report the media queries oddly. Deliberately NOT user-agent sniffing. +function isHandheld() { + const mm = window.matchMedia; + const coarse = Boolean(mm && mm("(pointer: coarse)").matches); + const noHover = Boolean(mm && mm("(hover: none)").matches); + const touch = Number(navigator.maxTouchPoints) > 0; + const smallScreen = Math.min(window.screen?.width || 0, window.screen?.height || 0) <= 900; + return (coarse && noHover) || (touch && smallScreen); +} + // Coarse device-capability guess, used only as the STARTING tier. The runtime FPS // governor (see ReelCanvasRenderer.monitorPerformance) can drop it further if real // frames come in slow, but never raises it above this — so a phone that lies about @@ -398,7 +442,7 @@ function tierOverride() { function detectDeviceTier() { const forced = tierOverride(); - if (forced) return forced; + if (forced) return forced; // QA override wins over every heuristic below. const nav = window.navigator || {}; // NaN when the API isn't exposed (deviceMemory is Chromium-only). We compare // with <= so an *absent* signal never demotes — NaN <= n is always false — which @@ -407,9 +451,17 @@ function detectDeviceTier() { const cores = Number(nav.hardwareConcurrency); const conn = nav.connection || {}; const et = conn.effectiveType || ""; - if (conn.saveData || /(^|\b)(slow-2g|2g)$/.test(et) || mem <= 2 || cores <= 2) return "low"; - if (/(^|\b)3g$/.test(et) || mem <= 4 || cores <= 4) return "mid"; - return "high"; + let tier = "high"; + if (conn.saveData || /(^|\b)(slow-2g|2g)$/.test(et) || mem <= 2 || cores <= 2) tier = "low"; + else if (/(^|\b)3g$/.test(et) || mem <= 4 || cores <= 4) tier = "mid"; + // Cap handhelds at "mid" regardless of reported specs. The checks above are + // all desktop-shaped: a modern phone reports 8 cores, and iOS never exposes + // deviceMemory at all (NaN, which never demotes), so phones were landing on + // "high" and getting the full desktop budget — uncapped framerate, DPR 2 and + // 100% FX density. That is why they ran hot. A phone with a genuinely weak + // signal still falls through to "low". + if (tier === "high" && isHandheld()) tier = "mid"; + return tier; } class ReelCanvasRenderer { @@ -437,6 +489,10 @@ class ReelCanvasRenderer { this._frameSamples = []; this._governorCheckedAt = performance.now(); this._lastDrawAt = 0; + // rAF bookkeeping: the handle so the loop can be cancelled when the tab is + // hidden, and the flag that keeps it cancelled until we resume. + this._rafId = 0; + this._paused = false; this.images = new Map(); this.board = { matrix: Array.from({ length: this.rows }, () => Array.from({ length: this.cols }, () => "BLUE_DIAMOND")), @@ -474,12 +530,25 @@ class ReelCanvasRenderer { // the hot particle loop blits instead of building a gradient every frame. // • stageCacheBack/Front: the fully-static stage (background + table frame + // lanes) baked to offscreen canvases and blitted once per frame. + // • symbolSprites: each symbol bitmap pre-scaled to ~2x its on-screen cell + // size, so the 30 per-frame cell blits are near-1:1 instead of resampling + // the full-resolution source art every time. this.glowSprites = new Map(); + this.symbolSprites = new Map(); this.stageCacheBack = null; this.stageCacheFront = null; this.time = 0; this.lastTick = performance.now(); - this.resizeObserver = new ResizeObserver(() => this.resize()); + // Debounced: resize() throws away the stage cache, the glow sprites and the + // pre-scaled symbol bitmaps, all of which then get re-baked. On mobile the + // URL bar collapsing/expanding fires this observer repeatedly, so running it + // per-callback meant rebuilding several full-size offscreen canvases many + // times during a single scroll gesture. + this._resizeTimer = 0; + this.resizeObserver = new ResizeObserver(() => { + clearTimeout(this._resizeTimer); + this._resizeTimer = setTimeout(() => this.resize(), 150); + }); const parent = this.canvas.parentElement; if (parent) this.resizeObserver.observe(parent); this.preloadSymbols(); @@ -488,17 +557,46 @@ class ReelCanvasRenderer { } preloadSymbols() { - // Load every symbol image exactly once and remember a per-asset load promise - // so the boot loader can drive its progress bar from THESE loads instead of - // re-requesting the same URLs with a second `new Image()` (which it used to - // do purely to track completion). Handlers are attached before `.src` so a - // synchronously-cached image still resolves. + // Load every image the game will ever need — canvas symbols AND the + // buttons / CSS backgrounds in uiAssets — exactly once, and remember a + // per-asset promise so the boot loader can drive its progress bar from + // THESE loads instead of re-requesting the same URLs with a second + // `new Image()`. Handlers are attached before `.src` so a synchronously + // cached image still resolves. + // + // The uiAssets entries are deliberately loaded here even though nothing on + // the canvas draws them: warming the HTTP cache under the identical URL is + // what stops the free-spins panel and the ante plate from streaming in the + // first time they are shown. Their HTMLImageElements go into `this.images` + // too, so they stay referenced and cannot be evicted mid-session. this.imageLoads = new Map(); - Object.entries(symbolAssets).forEach(([name, src]) => { + Object.entries({ ...symbolAssets, ...uiAssets }).forEach(([name, src]) => { const image = new Image(); image.decoding = "async"; this.imageLoads.set(name, new Promise((resolve) => { - image.onload = image.onerror = () => resolve(); + const settle = () => resolve(); + // `decode()` is the important one: onload only means the bytes arrived, + // while decode() resolves once the bitmap is rasterized and ready to + // paint. Waiting on onload alone is what let a fully "loaded" image + // still paint in progressively. + // + // Two escape hatches, both load-bearing: + // • document.hidden — Chrome defers image decoding entirely while the + // document is hidden, so awaiting decode() there never resolves and + // the boot gate hangs until its safety timeout. That is a real case: + // a game opened in a background tab, or a casino iframe that starts + // hidden. Nothing is painting, so there is nothing to pop in either. + // • the cap — decode() also rejects on a broken image, and a decode + // slow enough to hit 2s is not worth holding the player behind the + // splash for. + // resolve() is idempotent, so a late decode after either is harmless. + image.onload = () => { + if (typeof image.decode !== "function" || document.hidden) return settle(); + const cap = setTimeout(settle, 2000); + const done = () => { clearTimeout(cap); settle(); }; + image.decode().then(done, done); + }; + image.onerror = settle; })); image.src = src; this.images.set(name, image); @@ -512,10 +610,15 @@ class ReelCanvasRenderer { const p = TIER_PROFILES[this.tier]; this.maxDpr = p.maxDpr; this.idleThrottleMs = p.idleThrottleMs; + this.maxFrameMs = p.maxFrameMs; const body = document.body; if (body) { body.classList.toggle("perf-low", this.tier === "low"); body.classList.toggle("perf-mid", this.tier === "mid"); + // Published separately from the tier so CSS can drop the expensive + // always-on effects (fixed background, backdrop blur) on any handheld, + // not only when the device has been demoted all the way to perf-low. + body.classList.toggle("perf-handheld", isHandheld()); } } @@ -565,11 +668,50 @@ class ReelCanvasRenderer { // downgrade (and low-end devices) actually cash in for a steadier framerate. scale = Math.min(scale, TIER_PROFILES[this.tier].fxCeil); this.fxScale = scale; - // Layout/DPR changed → rebuild the static stage cache and colour sprites - // (they were baked at the old size/resolution). + // Layout/DPR changed → rebuild everything that was baked at the old + // size/resolution: the static stage, the colour glow sprites, the pre-scaled + // symbol bitmaps, and the memoized ambient gradients. this.stageCacheBack = null; this.stageCacheFront = null; this.glowSprites.clear(); + this.symbolSprites.clear(); + this._fogKey = null; + this._meshKey = null; + this.publishBoardBox(); + } + + /** Publish the board's real on-screen box as CSS custom properties so overlays + * can anchor to the GRID rather than to the surrounding panel. + * + * The board is letterboxed/pillarboxed inside .vault-window (it holds a 6/5 + * aspect while the window is whatever shape the layout leaves), so the two + * boxes rarely match. Overlays used to be positioned in raw pixels against + * the window — `top: 12px` on the multiplier badge — which put them in the + * dead space above the board on any viewport where the letterbox was tall. + * With these vars, CSS can express "the top edge of the actual grid". + * + * Written on .vault-window so the values are scoped to the board's own + * stacking context, and only when they actually change — this runs from + * resize(), which a ResizeObserver drives. */ + publishBoardBox() { + const stage = this.canvas.closest(".reels-stage") || this.canvas; + const host = this.canvas.closest(".vault-window"); + if (!host) return; + const s = stage.getBoundingClientRect(); + const h = host.getBoundingClientRect(); + if (!s.width || !h.width) return; + // Offsets of the board inside the window, as a fraction of the window — so + // the CSS stays resolution independent and needs no second unit conversion. + const next = { + "--board-w": `${(100 * s.width / h.width).toFixed(3)}%`, + "--board-h": `${(100 * s.height / h.height).toFixed(3)}%`, + "--board-x": `${(100 * (s.left - h.left) / h.width).toFixed(3)}%`, + "--board-y": `${(100 * (s.top - h.top) / h.height).toFixed(3)}%` + }; + for (const [k, v] of Object.entries(next)) { + if (this._boardBox?.[k] !== v) host.style.setProperty(k, v); + } + this._boardBox = next; } /** Scale a particle/effect count by fxScale, never below `min` so an effect @@ -590,6 +732,37 @@ class ReelCanvasRenderer { return { cvs, ctx: c, w, h }; } + /** A symbol bitmap pre-scaled to roughly the size it is actually drawn at. + * + * The source art is 256px square (and the reel/scatter plates larger still) + * but a grid cell is only ~60-100 CSS px, so drawing straight from the source + * made the browser resample the full bitmap on every one of the 30 cell blits, + * every frame. Baking each symbol once per layout into a right-sized offscreen + * canvas turns that into a near-1:1 blit. + * + * Cached by symbol key and invalidated with the stage caches on resize, so the + * bake size always tracks the current cell size and DPR. Returns the original + * image if it is not yet decoded or is already small enough to be free. + */ + getSymbolSprite(key, img, drawW, drawH) { + if (!img?.complete || !img.naturalWidth) return img; + // Bake at 2x the draw size so the result still holds up during the scale-up + // that pulse/slam effects apply, then clamp to the source's own resolution — + // upscaling here would cost memory and buy nothing. + const targetW = Math.min(img.naturalWidth, Math.max(1, Math.round(drawW * 2))); + const targetH = Math.min(img.naturalHeight, Math.max(1, Math.round(drawH * 2))); + // Already close to 1:1 — the bake would not pay for itself. + if (img.naturalWidth <= targetW * 1.25) return img; + + let sprite = this.symbolSprites.get(key); + if (sprite && sprite.w === targetW && sprite.h === targetH) return sprite.cvs; + + const { cvs, ctx } = this.makeOffscreen(targetW, targetH); + ctx.drawImage(img, 0, 0, targetW, targetH); + this.symbolSprites.set(key, { cvs, w: targetW, h: targetH }); + return cvs; + } + /** Pre-rendered soft radial glow for `color`, cached by colour string. The * particle loop blits this (cheap) instead of allocating a gradient/particle/ * frame. Drawn at a fixed sprite radius; callers scale via drawImage. */ @@ -1567,15 +1740,21 @@ class ReelCanvasRenderer { } loop() { + this._rafId = 0; const now = performance.now(); - // Idle throttle: when nothing is animating the stage is just the (cached) - // static board, so on mid/low tiers we redraw it at a reduced cadence instead - // of a full 60fps to save CPU/battery. High tier and any busy frame always - // render. We still schedule every rAF so we react instantly when work starts. + // Two throttles, both expressed as a minimum gap since the last real draw: + // • idleThrottleMs — nothing is animating, so the stage is essentially the + // cached static board plus slow ambient drift. ~20fps is indistinguishable + // here and costs a third of the CPU. Applies on EVERY tier now. + // • maxFrameMs — a cap on busy frames too, used on handhelds/low-end. The + // spin animation reads fine at 30fps and the halved frame count is the + // difference between a warm phone and a hot one. + // We always re-arm the rAF, so we still react on the very next frame when + // work starts — the throttle only skips the expensive draw(). const busy = this.isVisuallyBusy(); - const throttle = this.idleThrottleMs; - if (!busy && throttle && now - this._lastDrawAt < throttle) { - requestAnimationFrame(() => this.loop()); + const throttle = busy ? this.maxFrameMs : this.idleThrottleMs; + if (throttle && now - this._lastDrawAt < throttle) { + this._scheduleFrame(); return; } const rawDelta = now - this.lastTick; @@ -1593,7 +1772,40 @@ class ReelCanvasRenderer { console.error("[reelRenderer.draw] exception (continuing loop):", err); } // Schedule the next frame UNCONDITIONALLY, even if draw threw. - requestAnimationFrame(() => this.loop()); + this._scheduleFrame(); + } + + /** Arm the next frame, keeping the handle so it can actually be cancelled. + * Guarded against double-arming: two live rAF chains would fork the loop + * permanently and there would be no way to get back to one. */ + _scheduleFrame() { + if (this._rafId || this._paused) return; + this._rafId = requestAnimationFrame(() => this.loop()); + } + + /** Stop rendering entirely. Used when the tab/app is hidden — a backgrounded + * game has no reason to burn battery, and while most browsers stop firing rAF + * on hidden documents, Android WebViews and casino iframe wrappers do not + * reliably do so. Explicit beats incidental. */ + pauseRendering() { + this._paused = true; + if (this._rafId) { + cancelAnimationFrame(this._rafId); + this._rafId = 0; + } + } + + /** Resume after pauseRendering(). Resets the tick clock first: `lastTick` is + * minutes stale after a long background, and feeding that delta in would jump + * every time-driven animation forward in a single frame. */ + resumeRendering() { + if (!this._paused) return; + this._paused = false; + this.lastTick = performance.now(); + this._lastDrawAt = 0; + this._frameSamples = []; + this._governorCheckedAt = performance.now(); + this._scheduleFrame(); } getLayout() { @@ -1789,10 +2001,17 @@ class ReelCanvasRenderer { const { width, height, padX, top, laneW, rowStep, radius } = this.getLayout(); ctx.clearRect(0, 0, width, height); - const driftA = Math.sin(this.time * 0.00017); - const driftB = Math.cos(this.time * 0.00013); - const idleWave = Math.sin(this.time * 0.0012); - const idleWave2 = Math.cos(this.time * 0.00092); + // The two ambient gradients below are pure functions of these four waves, and + // they used to be rebuilt from scratch on EVERY frame — two gradient objects + // plus two toFixed() string allocations, forever, even on a fully idle board. + // Quantizing the waves to 1/12 steps makes the inputs repeat for ~20 frames + // at a time so the gradient objects can be memoized below. The motion is a + // very slow drift across a huge soft glow; the steps are invisible. + const q12 = (v) => Math.round(v * 12) / 12; + const driftA = q12(Math.sin(this.time * 0.00017)); + const driftB = q12(Math.cos(this.time * 0.00013)); + const idleWave = q12(Math.sin(this.time * 0.0012)); + const idleWave2 = q12(Math.cos(this.time * 0.00092)); const glowX = width * (0.5 + driftA * 0.08); const glowY = height * (0.2 + driftB * 0.04); @@ -1804,18 +2023,28 @@ class ReelCanvasRenderer { } ctx.drawImage(this.stageCacheBack.cvs, 0, 0, width, height); - const amberFog = ctx.createRadialGradient(glowX, glowY, 24, glowX, glowY, height * 0.84); - amberFog.addColorStop(0, "rgba(120, 198, 255, 0.24)"); - amberFog.addColorStop(0.5, "rgba(86, 124, 255, 0.16)"); - amberFog.addColorStop(1, "rgba(0, 0, 0, 0)"); - ctx.fillStyle = amberFog; + const fogKey = `${driftA}|${driftB}|${width}|${height}`; + if (this._fogKey !== fogKey) { + const amberFog = ctx.createRadialGradient(glowX, glowY, 24, glowX, glowY, height * 0.84); + amberFog.addColorStop(0, "rgba(120, 198, 255, 0.24)"); + amberFog.addColorStop(0.5, "rgba(86, 124, 255, 0.16)"); + amberFog.addColorStop(1, "rgba(0, 0, 0, 0)"); + this._fogGrad = amberFog; + this._fogKey = fogKey; + } + ctx.fillStyle = this._fogGrad; ctx.fillRect(0, 0, width, height); - const meshGlow = ctx.createLinearGradient(0, 0, width, height); - meshGlow.addColorStop(0, `rgba(129, 198, 255, ${(0.06 + (idleWave + 1) * 0.015).toFixed(3)})`); - meshGlow.addColorStop(0.5, "rgba(128, 171, 255, 0.02)"); - meshGlow.addColorStop(1, `rgba(111, 227, 210, ${(0.05 + (idleWave2 + 1) * 0.012).toFixed(3)})`); - ctx.fillStyle = meshGlow; + const meshKey = `${idleWave}|${idleWave2}|${width}|${height}`; + if (this._meshKey !== meshKey) { + const meshGlow = ctx.createLinearGradient(0, 0, width, height); + meshGlow.addColorStop(0, `rgba(129, 198, 255, ${(0.06 + (idleWave + 1) * 0.015).toFixed(3)})`); + meshGlow.addColorStop(0.5, "rgba(128, 171, 255, 0.02)"); + meshGlow.addColorStop(1, `rgba(111, 227, 210, ${(0.05 + (idleWave2 + 1) * 0.012).toFixed(3)})`); + this._meshGrad = meshGlow; + this._meshKey = meshKey; + } + ctx.fillStyle = this._meshGrad; ctx.fillRect(0, 0, width, height); // Static vignette + table frame/core + inner grid/ember + lane structure @@ -2289,7 +2518,8 @@ class ReelCanvasRenderer { const decoyImg = reveal?.decoy ? this.images.get(reveal.decoy) : null; if (decoyImg?.complete && decoyImg.naturalWidth) { ctx.globalAlpha = blastFade; - ctx.drawImage(decoyImg, x - iconW / 2, yFloat - iconH / 2, iconW, iconH); + const decoySprite = this.getSymbolSprite(reveal.decoy, decoyImg, iconW, iconH); + ctx.drawImage(decoySprite, x - iconW / 2, yFloat - iconH / 2, iconW, iconH); } } else if (img?.complete && img.naturalWidth) { ctx.globalAlpha = blastFade * revealAlpha; @@ -2303,7 +2533,11 @@ class ReelCanvasRenderer { if (iconW / iconH > ar) drawW = iconH * ar; else drawH = iconW / ar; } - ctx.drawImage(img, x - drawW / 2, yFloat - drawH / 2, drawW, drawH); + // Blit the pre-scaled sprite rather than resampling the source art on + // every cell, every frame. Keyed by the image key actually resolved + // above (a multiplier tier image, or the symbol itself). + const sprite = this.getSymbolSprite(multiImageKey || symbol, img, drawW, drawH); + ctx.drawImage(sprite, x - drawW / 2, yFloat - drawH / 2, drawW, drawH); } else { ctx.fillStyle = "rgba(255, 238, 200, 0.9)"; ctx.font = `${Math.max(10, coreR * 0.22)}px Trebuchet MS, sans-serif`; @@ -3130,6 +3364,9 @@ function animateSoul({ sx, sy, tx, ty, value, tier, delay, duration, layer, mete const trails = []; const startTime = performance.now() + delay; + // Framerate-independent trail emission — see the budget check in tick(). + const TRAIL_INTERVAL_MS = 55; + let lastTrailAt = 0; function tick(now) { const elapsed = now - startTime; @@ -3150,7 +3387,13 @@ function animateSoul({ sx, sy, tx, ty, value, tier, delay, duration, layer, mete orb.style.opacity = String(fadeIn * fadeOut); orb.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale.toFixed(3)})`; - if (tRaw < 0.95 && Math.random() < 0.55) { + // Emit trail nodes on a time budget rather than a per-frame probability. + // At ~55% per frame this created and destroyed up to ~33 DOM nodes per + // second PER orb, and several orbs fly at once — a lot of style/layout + // churn for a decorative streak. One every ~55ms looks the same and is + // independent of framerate, so it no longer emits more on a fast device. + if (tRaw < 0.95 && now - lastTrailAt >= TRAIL_INTERVAL_MS) { + lastTrailAt = now; const trail = document.createElement("div"); trail.className = "soul-trail"; trail.style.setProperty("--soul-color", tier.accent); @@ -4564,10 +4807,13 @@ if (el.betUpBtn) el.betUpBtn.addEventListener("click", () => stepBet(1)); const anteToggleImg = $("anteToggleImg"); if (el.anteToggle && anteToggleImg) { + // Read from uiAssets so this URL can never drift from the one the boot loader + // preloads — the active plate used to be fetched on the first toggle, which + // made it pop in mid-session. const updateAnteImg = () => { anteToggleImg.src = el.anteToggle.checked - ? "assets/symbols/ANTE_ACTIVE-removebg-preview.png" - : "assets/symbols/ANTE_INACTIVE-removebg-preview.png"; + ? uiAssets.UI_ANTE_ACTIVE + : uiAssets.UI_ANTE_INACTIVE; }; el.anteToggle.addEventListener("change", updateAnteImg); updateAnteImg(); @@ -4681,31 +4927,27 @@ el.symbolLegend.textContent = "Top Crown, Hourglass, Ring, Chalice, Red Gem, Pur el.payoutRuleText.textContent = "High-volatility pays-anywhere model. Wild multipliers and scatter-triggered bonus spins drive peak wins."; el.multiplierInfo.textContent = "Loading..."; el.activeMultiplier.textContent = "1x"; -// Boot loader: gate the reveal on the assets the first frame actually needs, show -// real progress, then pull the heavy non-critical art in the background. The -// renderer already loaded the symbol art once in preloadSymbols(), so we WATCH -// its existing load promises rather than re-requesting the URLs — every asset is +// Boot loader: gate the reveal on EVERY image the game can ever show, so nothing +// is fetched once the player is in. It used to wait on a hand-picked list of 12 +// symbols, which left the free-spins panel, the active-ante plate and the page +// backgrounds to be fetched on first use — they visibly streamed in mid-session. +// The renderer already loaded all of it once in preloadSymbols(), so we WATCH its +// existing load promises rather than re-requesting the URLs; every asset is // fetched exactly once. function runBootLoader() { const bar = document.getElementById("bootLoaderBar"); const pctEl = document.getElementById("bootLoaderPct"); const loader = document.getElementById("bootLoader"); - // Symbol art owned by the renderer — reuse its per-asset load promises. - const criticalKeys = [ - "BLUE_DIAMOND", "GREEN_TRIANGLE", "YELLOW_HEX", "PURPLE_TRIANGLE", "RED_GEM", - "CHALICE", "RING", "HOURGLASS", "TOP_CROWN", "REEL", "MULTI_COMMON", "SCATTER" - ]; - const waits = criticalKeys - .map((key) => reelRenderer.imageLoads?.get(key)) - .filter(Boolean); - // The on-screen spin control is a DOM owned by the document (not the - // renderer), so wait on that element's own load instead of a fresh Image. - const spinImg = el.spinBtn?.querySelector("img"); - if (spinImg) { - waits.push(spinImg.complete + // Everything in symbolAssets + uiAssets, decoded and ready to paint. + const waits = Array.from(reelRenderer.imageLoads?.values() || []); + // The on-screen buttons are owned by the document, not the renderer. + // Their URLs are already in uiAssets (so the bytes are shared), but wait on + // the elements too so the reveal cannot beat their own decode. + document.querySelectorAll(".img-btn img, .ante-toggle-img").forEach((img) => { + waits.push(img.complete ? Promise.resolve() - : new Promise((resolve) => { spinImg.onload = spinImg.onerror = () => resolve(); })); - } + : new Promise((resolve) => { img.onload = img.onerror = () => resolve(); })); + }); const total = waits.length || 1; let done = 0; let revealed = false; @@ -4730,7 +4972,11 @@ function runBootLoader() { // Advance the bar as each existing load settles. waits.forEach((p) => p.then(bump)); // Safety valve: a stalled asset must never trap the player behind the splash. - const safety = setTimeout(reveal, 8000); + // Generous now that the bar waits on the FULL manifest — the whole payload is + // ~500KB of WebP, so anything past this is a genuinely broken connection, not + // a slow one. Firing early would reveal a half-loaded game, which is the exact + // bug this gating exists to prevent. + const safety = setTimeout(reveal, 20000); Promise.all(waits).then(() => { clearTimeout(safety); reveal(); @@ -4748,6 +4994,37 @@ function loadDeferredAssets() { } } +// Stop burning battery while the player is not looking. Nothing in the client +// listened for this before: the render loop ran until unload and the Web Audio +// scheduler kept re-arming, so a game left open in a background tab (or a phone +// with the screen locked) kept the CPU and the audio hardware busy indefinitely. +// +// Browsers usually stop firing rAF on a hidden document, but "usually" is not a +// guarantee — Android WebViews and the casino iframe wrappers the platform +// launches into do not reliably do it — so we cancel the frame explicitly. +// `pagehide` covers the iOS case where the tab is frozen without ever going +// through visibilitychange. +function installVisibilityGuards() { + const park = () => { + reelRenderer.pauseRendering(); + window.Sound?.suspendAudio?.(); + }; + const wake = () => { + reelRenderer.resumeRendering(); + window.Sound?.resumeAudio?.(); + }; + document.addEventListener("visibilitychange", () => { + if (document.hidden) park(); + else wake(); + }); + window.addEventListener("pagehide", park); + // bfcache restore re-shows the page without a visibilitychange event. + window.addEventListener("pageshow", () => { + if (!document.hidden) wake(); + }); +} +installVisibilityGuards(); + runBootLoader(); initSession().catch((err) => { el.resultDump.textContent = `Init failed: ${err.message}`; diff --git a/client/styles.css b/client/styles.css index 10cf9b0..25a2af0 100644 --- a/client/styles.css +++ b/client/styles.css @@ -82,7 +82,7 @@ body::after { temple shows above, below and around it. */ background: linear-gradient(180deg, rgba(20, 13, 11, 0.66), rgba(10, 7, 6, 0.82)), - url("assets/symbols/Background.png") center / cover no-repeat, + url("assets/symbols/Background.webp") center / cover no-repeat, var(--bg-0); box-shadow: var(--shadow-hard); display: grid; @@ -322,51 +322,78 @@ button:disabled { and a pop on each increase. */ .bonus-multi-badge { position: absolute; - top: 12px; - left: 50%; + /* Anchored to the TOP EDGE OF THE BOARD, not the panel. --board-y/--board-h + are published by ReelCanvasRenderer.publishBoardBox() as percentages of + .vault-window, so this tracks the letterbox at every viewport size. The + fallbacks keep it sane for the one frame before the first resize() lands. + Previously this was `top: 12px`, which floated the badge in the dead space + above the grid whenever the board was letterboxed. */ + top: calc(var(--board-y, 0%) + var(--board-h, 100%) * 0.02); + left: calc(var(--board-x, 0%) + var(--board-w, 100%) * 0.5); transform: translateX(-50%); z-index: 7; display: flex; flex-direction: column; align-items: center; gap: 1px; - padding: 6px 20px; - border-radius: 14px; + /* Scales with the board instead of sitting at a fixed 6px/20px. */ + padding: clamp(3px, 0.7vmin, 8px) clamp(10px, 2.4vmin, 24px); + border-radius: clamp(8px, 1.4vmin, 16px); text-align: center; pointer-events: none; --badge-glow: rgba(255, 226, 138, 0.4); background: linear-gradient(180deg, rgba(26, 20, 46, 0.92), rgba(12, 10, 26, 0.96)); border: 1px solid rgba(255, 226, 138, 0.5); box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45), 0 0 16px var(--badge-glow); +} + +/* The pulse used to animate `box-shadow` on the badge itself, which is not a + composited property — it repainted the badge every frame for the WHOLE free + spins round. Same look, but the wide-glow state now lives on a pseudo-element + that only cross-fades its opacity, which the compositor handles on the GPU. */ +.bonus-multi-badge::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + box-shadow: 0 0 32px var(--badge-glow); animation: bonusBadgePulse 2.2s ease-in-out infinite; } .bonus-multi-badge-label { - font: 800 0.56rem/1 "SF Pro Display", "Segoe UI", sans-serif; + font: 800 clamp(0.42rem, 1.3vmin, 0.6rem)/1 "SF Pro Display", "Segoe UI", sans-serif; letter-spacing: 0.2em; text-transform: uppercase; color: #cbb985; } .bonus-multi-badge-value { - font: 900 1.55rem/1 "SF Pro Display", "Segoe UI", sans-serif; + font: 900 clamp(1rem, 3.6vmin, 1.7rem)/1 "SF Pro Display", "Segoe UI", sans-serif; font-variant-numeric: tabular-nums; color: #ffe28a; text-shadow: 0 0 14px rgba(255, 226, 138, 0.6); will-change: transform; } -.bonus-multi-badge[data-tier="rare"] { --badge-glow: rgba(155, 231, 255, 0.5); border-color: rgba(155, 231, 255, 0.55); animation-duration: 1.9s; } +.bonus-multi-badge[data-tier="rare"] { --badge-glow: rgba(155, 231, 255, 0.5); border-color: rgba(155, 231, 255, 0.55); } .bonus-multi-badge[data-tier="rare"] .bonus-multi-badge-value { color: #bfeaff; text-shadow: 0 0 16px rgba(155, 231, 255, 0.65); } -.bonus-multi-badge[data-tier="epic"] { --badge-glow: rgba(190, 214, 255, 0.55); border-color: rgba(190, 214, 255, 0.62); animation-duration: 1.6s; } +.bonus-multi-badge[data-tier="epic"] { --badge-glow: rgba(190, 214, 255, 0.55); border-color: rgba(190, 214, 255, 0.62); } .bonus-multi-badge[data-tier="epic"] .bonus-multi-badge-value { color: #d8e4ff; text-shadow: 0 0 18px rgba(190, 214, 255, 0.7); } -.bonus-multi-badge[data-tier="legendary"] { --badge-glow: rgba(255, 165, 224, 0.6); border-color: rgba(255, 165, 224, 0.68); animation-duration: 1.35s; } +.bonus-multi-badge[data-tier="legendary"] { --badge-glow: rgba(255, 165, 224, 0.6); border-color: rgba(255, 165, 224, 0.68); } .bonus-multi-badge[data-tier="legendary"] .bonus-multi-badge-value { color: #ffc6ec; text-shadow: 0 0 20px rgba(255, 165, 224, 0.78); } -.bonus-multi-badge[data-tier="mythic"] { --badge-glow: rgba(255, 205, 130, 0.7); border-color: rgba(255, 210, 140, 0.78); animation-duration: 1.1s; } +.bonus-multi-badge[data-tier="mythic"] { --badge-glow: rgba(255, 205, 130, 0.7); border-color: rgba(255, 210, 140, 0.78); } .bonus-multi-badge[data-tier="mythic"] .bonus-multi-badge-value { color: #ffd98c; text-shadow: 0 0 24px rgba(255, 210, 140, 0.88); } +/* Higher tiers pulse faster. Targets ::after, which is where the animation now + lives (see the box-shadow → opacity note above). */ +.bonus-multi-badge[data-tier="rare"]::after { animation-duration: 1.9s; } +.bonus-multi-badge[data-tier="epic"]::after { animation-duration: 1.6s; } +.bonus-multi-badge[data-tier="legendary"]::after { animation-duration: 1.35s; } +.bonus-multi-badge[data-tier="mythic"]::after { animation-duration: 1.1s; } + @keyframes bonusBadgePulse { - 0%, 100% { box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45), 0 0 14px var(--badge-glow); } - 50% { box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45), 0 0 32px var(--badge-glow); } + 0%, 100% { opacity: 0; } + 50% { opacity: 1; } } /* Pop the value (not the badge) on each increase so it never collides with the @@ -387,16 +414,16 @@ button:disabled { @media (max-width: 720px) { body.public-mode .ribbon-detail { display: none; } body.public-mode .ribbon-compact { display: inline-flex; } - body.public-mode .status-ribbon { justify-content: flex-end; gap: 6px; } + body.public-mode .status-ribbon { justify-content: flex-end; gap: clamp(4px, 1.4vw, 8px); } /* Branded header on the left, info icon on the right, in one thin row. */ - body.public-mode .altar-top { align-items: center; justify-content: space-between; padding: 2px 0; } + body.public-mode .altar-top { align-items: center; justify-content: space-between; padding: 0; } body.public-mode .title-cluster .title-kicker { display: none; } - body.public-mode .title-cluster h1 { font-size: 1.1rem; } - body.public-mode .title-tagline { font-size: 0.56rem; } + body.public-mode .title-cluster h1 { font-size: calc(1.1rem * var(--ui)); } - body.public-mode .bonus-multi-badge { top: 6px; padding: 4px 16px; border-radius: 12px; } - body.public-mode .bonus-multi-badge-value { font-size: 1.3rem; } - body.public-mode .bonus-multi-badge-label { font-size: 0.52rem; } + /* No `top` here — the badge is anchored to the board box (see .bonus-multi-badge). + A fixed `top: 6px` pinned it to the panel, so on a portrait phone, where the + 6/5 board is letterboxed inside a taller window, the badge sat ~104px ABOVE + the grid. Sizing stays proportional via the clamps on the base rule. */ } /* ============================================================================ @@ -588,57 +615,137 @@ body.public-mode .sanctum { overflow: hidden; } +/* ── ONE SCALING CONTAINER ───────────────────────────────────────────────── + .altar is the single layout root: it owns the whole viewport and every + dimension below it is derived from the scale tokens declared here, so the + header, HUD, board and bottom bar grow and shrink together as one unit. + + The tokens are viewport-relative on purpose. vmin drives anything that must + stay usable in BOTH orientations (tap targets), vh drives the vertical + chrome budget, and every clamp() carries a floor so nothing collapses on a + 320px phone and a ceiling so nothing sprawls on a 4K monitor. + + There is deliberately NO max-width in px here. The old rule capped the game + at 1040px, which on a 1920px screen left ~440px of dead backdrop down each + side while the board itself stayed small. */ body.public-mode .altar { - width: min(100vw, 1040px); - max-width: 1040px; + /* Vertical rhythm between the stacked rows. */ + --gap: clamp(3px, 0.9vh, 10px); + /* Chrome scale: one number every control derives from. */ + --ui: clamp(0.72, min(100vw / 480, 100vh / 900), 1.25); + /* Tap-target sizing. Driven by vh, not vmin: these are stacked vertically, so + the height budget is what decides whether the board or the chrome wins. On + a 1920x1080 desktop vmin is 1080, which made every control hit its ceiling + and left the chrome eating ~430px — the board got only 650px of 1080. + vw is folded in via min() so ultra-wide short windows stay sane. */ + --btn: clamp(30px, min(6.4vh, 9vw), 54px); + --spin: clamp(52px, min(11.5vh, 17vw), 104px); + --radius: clamp(8px, 1.4vmin, 18px); + + width: 100vw; + max-width: 100vw; height: 100vh; height: 100dvh; max-height: 100vh; max-height: 100dvh; - margin: 0 auto; + margin: 0; border-radius: 0; - display: grid; - grid-template-rows: auto 1fr; - gap: 8px; - /* Pad for notches / home indicator on phones; falls back to 0 on desktop. */ - padding: max(8px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) - max(10px, env(safe-area-inset-bottom)) max(18px, env(safe-area-inset-left)); + /* FLEX, not grid-with-fixed-rows. `grid-template-rows: auto minmax(0,1fr)` + assigns rows by child ORDER, and .altar-top is display:none in landscape — + which promoted .altar-core into the `auto` row and left it content-sized + (190px of a 390px viewport), collapsing the board to 2px. Flex assigns the + free space by flex-grow, so a hidden header simply drops out. */ + display: flex; + flex-direction: column; + gap: var(--gap); + /* Pad for notches / home indicator on phones; falls back to the clamp floor. */ + padding: + max(clamp(2px, 0.6vh, 8px), env(safe-area-inset-top)) + max(clamp(4px, 1.2vw, 18px), env(safe-area-inset-right)) + max(clamp(2px, 0.6vh, 8px), env(safe-area-inset-bottom)) + max(clamp(4px, 1.2vw, 18px), env(safe-area-inset-left)); overflow: hidden; } +/* The admin three-column shell (320px | 1fr | 340px, align-items:start) is + meaningless in player view — the side panels are display:none, so the fixed + 320px column would have swallowed the game. Flatten it to a single stretching + column that takes all the height the header leaves. */ +body.public-mode .altar-top { + flex: 0 0 auto; +} +body.public-mode .altar-core { + flex: 1 1 auto; + min-height: 0; + height: auto; + display: block; + grid-template-columns: none; + gap: 0; +} + +/* The vertical stack. FLEX, not grid-with-counted-rows: the previous + `grid-template-rows: auto auto minmax(0,1fr) auto` had to match the number of + VISIBLE children, and several rows here toggle `hidden` at runtime (round-id + bar, bonus nodes). When the count drifted, the 1fr landed on the wrong row and + the board was left with only its content height. Flex assigns the free space + by flex-grow, so hidden children simply drop out and the board still wins. */ body.public-mode .vault-shell { - width: min(100%, 920px); - margin: 0 auto; + width: 100%; + max-width: none; + height: 100%; + min-height: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--gap); } +/* THE BOARD ROW — the only row that grows. Everything else is content-sized, so + whatever the chrome does not use belongs to the grid. `min-height: 0` is what + lets it shrink on short viewports rather than pushing the deck off-screen. */ body.public-mode .vault-window { + flex: 1 1 auto; min-height: 0; - height: 100%; + height: auto; width: 100%; display: flex; - align-items: stretch; + align-items: center; justify-content: center; - padding: 8px; - margin: 0 auto; - /* Size container so the board below can fit itself to the AVAILABLE box - (cqw/cqh) and keep its aspect ratio, letterboxing/pillarboxing into - whatever space the surrounding chrome leaves. */ + padding: clamp(2px, 0.5vmin, 8px); + margin: 0; + /* Now that flex gives this a DEFINITE resolved height, cqh below is real. + Previously this sat in an auto row, so cqh resolved against an indefinite + height — which is what collapsed the board to ~2px in landscape. */ container-type: size; } +/* Everything that is not the board holds its natural height. */ +body.public-mode .hud-bar, +body.public-mode .round-id-bar, +body.public-mode .hud-message-rail, +body.public-mode .ritual-deck, +body.public-mode .control-toolbar, +body.public-mode .game-footer { + flex: 0 0 auto; +} + body.public-mode .img-btn-spin { justify-self: center; align-self: center; } body.public-mode .reels-stage { - /* Keep the intended 6/5 board shape on EVERY viewport, not just the ones the - specialized media queries below cover. Setting both width:100% and - height:100% (the old rule) makes the browser ignore aspect-ratio, so the - board stretched to the container's shape on tablets, landscape phones, and - short desktop windows. Bind whichever axis is limiting instead: 120cqh is - 6/5 * 100cqh, so min() picks the width that fits both dimensions → - pillarbox on wide-short containers, letterbox on tall-narrow ones. */ + /* Keep the engine's true 6/5 board shape on EVERY viewport. Setting both + width:100% and height:100% makes the browser ignore aspect-ratio, so the + board would stretch to the container's shape. Bind whichever axis is + limiting instead: 120cqh is 6/5 * 100cqh, so min() picks the width that + fits both dimensions → pillarbox on wide-short containers, letterbox on + tall-narrow ones. Because .vault-window now has a definite height, both + cq units are meaningful in every orientation. + + This is the ONLY place the board's size is decided. The per-breakpoint + overrides that used to re-declare width/height/aspect (and disagreed with + each other — desktop said 3/2 while the canvas draws 6/5) are gone. */ aspect-ratio: 6 / 5; width: min(100cqw, 120cqh); height: auto; @@ -658,14 +765,14 @@ body.public-mode .altar-top { flex-wrap: nowrap; align-items: center; justify-content: space-between; - gap: 10px; - padding: 4px 0 2px; + gap: clamp(6px, 1.6vw, 12px); + padding: 0; min-height: 0; } body.public-mode .title-cluster .title-kicker { display: none; } body.public-mode .title-cluster h1 { - font-size: 1.05rem; + /* font-size is set proportionally in the chrome block (1.05rem * --ui). */ letter-spacing: 0.18em; margin: 0; } @@ -677,7 +784,7 @@ body.public-mode .status-ribbon { width: auto; align-items: center; justify-content: flex-end; - gap: 8px; + gap: clamp(4px, 1.2vw, 10px); } body.public-mode .title-cluster { min-width: 0; } @@ -719,82 +826,29 @@ body.public-mode #betNode { display: none; } body.public-mode .status-ribbon-item { - padding: 4px 10px; - font-size: 0.74rem; -} - -body.public-mode .altar-core { - grid-template-columns: 1fr; - height: 100%; - min-height: 0; - align-items: stretch; + font-size: calc(0.74rem * var(--ui)); } -body.public-mode .vault-shell { - height: 100%; - min-height: 0; - /* Flex column so any number of stacked rows (banner, HUD, rail, reels, - controls, toolbar, footer) lay out cleanly and the reels absorb all the - leftover vertical space. */ - display: flex; - flex-direction: column; - gap: 8px; -} - -body.public-mode .vault-window { - flex: 1 1 auto; - height: auto; - min-height: 0; -} +/* NOTE: .altar-core / .vault-shell / .vault-window used to be re-declared here + with a second, slightly different set of values. Two sources of truth for the + same boxes is what let the board's sizing drift out of sync in the first + place, so they now live once, in the ONE SCALING CONTAINER block above. */ body.public-mode .reels-canvas { width: 100% !important; height: 100% !important; } -/* ---------------------------------------------------------------------------- - Desktop / wide-landscape player view. Give the reel board the lion's share of - the vertical space and a reference-style (Pragmatic) 3:2 shape so cells are - large and slightly wider than tall — much bigger symbols than the default - full-width/short board. Scoped to desktop so the mobile stack is untouched. ----------------------------------------------------------------------------- */ +/* On any roomy viewport the board is height-bound, so let the temple backdrop + show through the side margins instead of a grey box around the reel frame. + Purely cosmetic — no sizing here. All dimensions come from the scale tokens + on .altar, so there is nothing per-breakpoint left to declare. */ @media (min-width: 1000px) and (min-height: 700px) { - /* Board sized by HEIGHT (fills the reclaimed vertical space) with a 3:2 shape; - width derives from that, capped at the container and centred. */ - body.public-mode .reels-stage { - height: 100%; - width: auto; - max-width: 100%; - aspect-ratio: 3 / 2; - margin: 0 auto; - } - - /* The board no longer fills the full window width, so drop the window's own - dark fill and let the temple backdrop show in the side margins around the - reel frame (the reels-stage keeps its own framed background). */ body.public-mode .vault-window { background: transparent; border-color: transparent; box-shadow: none; } - - /* Compact the surrounding chrome so .vault-window (flex:1) absorbs more height. */ - body.public-mode .vault-shell { gap: 6px; } - body.public-mode .hud-message-rail { min-height: 16px; } - body.public-mode .hud-bar { padding: 6px 14px; } - body.public-mode .ritual-deck { gap: 10px; padding-bottom: 0; } - body.public-mode .img-btn-spin { - max-width: 104px; - max-height: 104px; - } - body.public-mode .img-btn-buy, - body.public-mode .ante-toggle { - height: 56px; - } - body.public-mode .control-toolbar { - padding-top: 2px; - padding-bottom: 2px; - } } /* ---------------------------------------------------------------------------- @@ -802,104 +856,184 @@ body.public-mode .reels-canvas { already a vertical stack (title → HUD → reels → controls); here we size the controls for one-thumb play and keep them in the bottom thumb zone. ---------------------------------------------------------------------------- */ -@media (max-width: 720px) and (orientation: portrait) { - /* Symbol size on a 6-wide grid is capped by boardWidth/6, so reclaim every - spare horizontal pixel: trim the side padding to the safe-area minimum. */ - body.public-mode .altar { - gap: 6px; - padding-left: max(4px, env(safe-area-inset-left)); - padding-right: max(4px, env(safe-area-inset-right)); - } +/* ── PROPORTIONAL CHROME ─────────────────────────────────────────────────── + Every row that is not the board, sized from the .altar scale tokens. These + replace ~10 per-breakpoint blocks that each hard-coded px heights and often + disagreed with each other. Nothing here is a raw pixel dimension, so the + whole stack reflows proportionally instead of stepping at breakpoints. */ - /* The reels take all the free vertical space between the HUD and controls. */ - body.public-mode .vault-shell { - grid-template-rows: auto auto minmax(0, 1fr) auto; - gap: 6px; - } +body.public-mode .hud-bar { + padding: clamp(3px, 0.7vh, 9px) clamp(6px, 2vw, 16px); + gap: clamp(4px, 1.4vw, 10px); + border-radius: var(--radius); +} +body.public-mode .hud-node span { + font-size: calc(0.58rem * var(--ui)); +} +body.public-mode .hud-node strong { + font-size: calc(1rem * var(--ui)); +} - /* Full-bleed board: drop the window's inner padding + dark fill so the 6/5 board - spans the full screen width (biggest possible symbols) and the space around it - shows the temple backdrop rather than a grey box. Centre the board in the - window (grid place-items) so the reclaimed vertical space sits evenly. */ - body.public-mode .vault-window { - padding: 2px; - background: transparent; - border-color: transparent; - box-shadow: none; - display: grid; - place-items: center; - } +body.public-mode .hud-message-rail { + min-height: clamp(8px, 1.6vh, 20px); +} - /* Portrait is always width-bound (tall, narrow window), so bind WIDTH and let - height derive from the aspect — this yields an exact 6/5 box with square, - evenly-spaced cells (no stretched grid), and no max-* clamp to break the - ratio. max-height is only a safety net for unusually short viewports. */ - body.public-mode .reels-stage { - width: 100%; - height: auto; - max-width: 100%; - max-height: 100%; - aspect-ratio: 6 / 5; - margin: 0; - } +/* The header is pure branding — on any short viewport it is the first thing to + give up height to the board. */ +body.public-mode .altar-top { + padding: 0; + min-height: 0; +} +body.public-mode .title-tagline { display: none; } +/* Header controls scale with the height budget too — on a 768px-tall desktop + the header was 58px, and because the board is height-bound there, every + header pixel costs 1.2px of board width. */ +body.public-mode .status-ribbon .ribbon-detail { + padding: clamp(3px, 0.7vh, 8px) clamp(8px, 1.6vw, 14px); + font-size: calc(0.7rem * var(--ui)); + min-height: 0; +} +body.public-mode .status-ribbon-item { + padding: clamp(2px, 0.5vh, 6px) clamp(6px, 1.4vw, 12px); +} +body.public-mode .icon-btn { + width: clamp(26px, 4.6vh, 40px); + height: clamp(26px, 4.6vh, 40px); + min-width: 0; + min-height: 0; + padding: 0; +} - /* Trim the chrome above the board so the board + spin button dominate. */ - body.public-mode .hud-message-rail { min-height: 20px; } - body.public-mode .hud-bar { padding: 6px 10px; } +/* THE BOTTOM CONTROL BAR — deck + toolbar + footer are docked to the bottom of + the stack (they are the last flex children) and every height is viewport + relative, so the cluster keeps the same visual weight from a 320px phone to a + 4K monitor instead of eating 35% of a small screen. */ +body.public-mode .ritual-deck { + /* Three columns ALWAYS in player view: bet+ante | spin | buy. The unscoped + admin rules at max-width:980/520px collapse this to 2 columns and then 1, + which stacked the controls into a ~235px tower on a 375px phone and left + the board with barely a sixth of the screen. */ + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: clamp(4px, 1.6vw, 14px); + padding: 0; +} - /* Bigger, thumb-friendly spin button anchored at the bottom. */ - body.public-mode .img-btn-spin { - max-width: clamp(108px, 32vw, 148px); - max-height: clamp(108px, 32vw, 148px); - } +body.public-mode .img-btn-spin { + width: var(--spin); + height: var(--spin); + max-width: var(--spin); + max-height: var(--spin); + padding: 0; +} +body.public-mode .img-btn-spin img { + width: 100%; + height: 100%; + object-fit: contain; +} - /* Keep flanking controls comfortably tappable (>= 44px) and roomy. */ - body.public-mode .ritual-deck { - gap: 8px; - padding-bottom: 2px; - } - body.public-mode .img-btn-buy, - body.public-mode .ante-toggle { - height: 56px; - } - body.public-mode .hud-node strong { - font-size: 1rem; - } +body.public-mode .img-btn-buy, +body.public-mode .ante-toggle { + height: var(--btn); + max-height: var(--btn); +} +body.public-mode .img-btn-buy img, +body.public-mode .ante-toggle-img { + height: 100%; + width: 100%; + object-fit: contain; +} + +body.public-mode .bet-stepper { + height: var(--btn); + max-height: var(--btn); + min-width: 0; +} +body.public-mode .bet-display { + font-size: calc(0.95rem * var(--ui)); } -/* Very small phones — trim chrome so the reels stay as large as possible. */ -@media (max-width: 380px) and (orientation: portrait) { - body.public-mode .hud-bar { - padding: 6px 8px; - gap: 6px; +body.public-mode .control-toolbar { + gap: clamp(6px, 2vw, 14px); + padding: 0; +} +body.public-mode .tool-btn { + height: clamp(26px, 4.4vh, 40px); + min-width: clamp(26px, 4.4vh, 40px); + min-height: 0; + padding: 0 clamp(6px, 1.8vw, 14px); +} +body.public-mode .tool-btn .tool-glyph { font-size: calc(1.1rem * var(--ui)); } +body.public-mode .tool-btn .tool-label { font-size: calc(0.68rem * var(--ui)); } + +body.public-mode .game-footer { + min-height: clamp(22px, 3.6vh, 38px); + padding: clamp(2px, 0.4vh, 6px) clamp(8px, 2.4vw, 18px); + gap: clamp(10px, 5vw, 30px); + border-radius: var(--radius); +} +body.public-mode .footer-cell span { font-size: calc(0.58rem * var(--ui)); } +body.public-mode .footer-cell strong { font-size: calc(0.94rem * var(--ui)); } + +body.public-mode .title-cluster h1 { + font-size: calc(1.05rem * var(--ui)); +} + +/* Portrait phones/tablets. The board is width-bound here, so the only job left + is to reclaim horizontal pixels — symbol size is boardWidth/6, so every pixel + of side padding costs symbol size. No sizing overrides: the board rule and the + scale tokens already handle it. */ +@media (orientation: portrait) { + body.public-mode .altar { + padding-left: max(2px, env(safe-area-inset-left)); + padding-right: max(2px, env(safe-area-inset-right)); + /* A 6/5 board on a tall phone is WIDTH-bound: at 390px wide it can only be + 325px tall, so a 844px viewport has ~230px of slack the board physically + cannot use. Rather than let that pool as empty backdrop above and below + the grid (the "large empty areas" this refactor is fixing), spend it on + the bottom controls — which is also where a thumb wants them. Still no + fixed pixels: both tokens are bound by width AND height so they cannot + overflow a short or narrow phone. */ + --spin: clamp(72px, min(30vw, 15vh), 148px); + --btn: clamp(40px, min(15vw, 8vh), 68px); + } + + /* On a narrow phone the deck's side columns are ~130px, which is not enough + for the bet stepper and the ante pill SIDE BY SIDE — the stepper collapsed + to a 53px square and clipped its −/+ controls. Stack them instead so each + gets the full column width. Still three columns overall, so the spin button + stays centred and the deck never becomes the tall tower it was before. */ + body.public-mode .deck-left { + flex-direction: column; + align-items: stretch; + gap: clamp(3px, 0.9vh, 8px); + } + body.public-mode .deck-left .bet-stepper, + body.public-mode .deck-left .ante-toggle { + width: 100%; + max-width: none; } - body.public-mode .img-btn-spin { - max-width: clamp(92px, 28vw, 120px); - max-height: clamp(92px, 28vw, 120px); + + /* Full-bleed board: drop the window's inner padding + dark fill so the 6/5 + board spans the full screen width and the space around it shows the temple + backdrop rather than a grey box. */ + body.public-mode .vault-window { + padding: 0; + background: transparent; + border-color: transparent; + box-shadow: none; } } -/* Landscape on phones (short viewport): keep it usable but secondary. Shrink - vertical chrome so the reels don't get crushed; controls stay at the bottom. */ +/* Landscape on short viewports: the header is the first thing to go, since the + board is height-bound and every vertical pixel is board size. */ @media (orientation: landscape) and (max-height: 560px) { - body.public-mode .altar { - gap: 4px; - padding-top: max(4px, env(safe-area-inset-top)); - padding-bottom: max(4px, env(safe-area-inset-bottom)); - } body.public-mode .altar-top { display: none; } - body.public-mode .hud-bar { - padding: 4px 10px; - } - body.public-mode .img-btn-spin { - max-width: clamp(72px, 12vh, 104px); - max-height: clamp(72px, 12vh, 104px); - } - body.public-mode .img-btn-buy, - body.public-mode .ante-toggle { - height: 48px; + body.public-mode .hud-message-rail { + display: none; } } @@ -1635,10 +1769,12 @@ body.crazy-mode-on .vault-window { .vault-aura { position: absolute; - left: 50%; - top: 14px; - width: min(520px, 86%); - height: 180px; + /* Glow sits over the top of the BOARD, tracking the same box as the badge + (was a fixed 520x180px block offset 14px from the panel). */ + left: calc(var(--board-x, 0%) + var(--board-w, 100%) * 0.5); + top: var(--board-y, 0%); + width: calc(var(--board-w, 100%) * 0.86); + height: calc(var(--board-h, 100%) * 0.3); transform: translateX(-50%); border-radius: 50%; background: @@ -1849,13 +1985,16 @@ body.crazy-mode-on .vault-window { .win-callout { position: absolute; - left: 50%; - top: 46%; + /* Centred on the BOARD, not the panel — so it stays over the reels when the + board is letterboxed. min-width was a fixed 320px, which overflowed a + 320-375px phone; it is now a fraction of the board itself. */ + left: calc(var(--board-x, 0%) + var(--board-w, 100%) * 0.5); + top: calc(var(--board-y, 0%) + var(--board-h, 100%) * 0.46); transform: translate(-50%, -50%); - min-width: 320px; - max-width: calc(100% - 40px); - border-radius: 14px; - padding: 22px 36px 24px; + min-width: min(320px, 80%); + max-width: calc(var(--board-w, 100%) - 6%); + border-radius: clamp(8px, 1.4vmin, 16px); + padding: clamp(10px, 2.6vmin, 24px) clamp(16px, 4.5vmin, 38px); background: linear-gradient(180deg, rgba(20, 28, 50, 0.92), rgba(8, 12, 24, 0.96)); border: 1px solid rgba(170, 210, 255, 0.26); text-align: center; @@ -2130,7 +2269,10 @@ button.img-btn, land dead-on the circles. */ width: min(100%, 260px); aspect-ratio: 776 / 183; - background: url("/assets/symbols/BET-removebg-preview.png") center / 100% 100% no-repeat; + /* Relative, not root-absolute: index.html injects a so the game + works under a sub-path, and a leading "/" would resolve to a different URL + than the one main.js preloads — defeating the preload. */ + background: url("assets/symbols/BET-removebg-preview.webp") center / 100% 100% no-repeat; user-select: none; margin: 0 auto; } @@ -2389,7 +2531,9 @@ pre { .feature-screen-panel { position: relative; - width: min(560px, 92%); + /* Sized off the board so the panel keeps the same proportion to the reels at + every viewport (was a flat 560px cap). */ + width: min(560px, var(--board-w, 100%) * 0.94, 92%); aspect-ratio: 2 / 1; display: flex; flex-direction: column; @@ -2397,8 +2541,12 @@ pre { justify-content: center; padding: 8% 12% 10%; border: 0; + /* Relative path (see .bet-stepper) so this matches the URL main.js preloads. + This panel lives inside a display:none subtree until it is shown, so the + browser will not fetch this background on its own — the preload is the + only reason it is warm by the time the panel appears. */ background: - url("/assets/symbols/FREESPINS_INFO-removebg-preview.png") center / contain no-repeat; + url("assets/symbols/FREESPINS_INFO-removebg-preview.webp") center / contain no-repeat; text-align: center; animation: hushCurtain 0.38s cubic-bezier(0.18, 0.84, 0.22, 1); } @@ -2535,7 +2683,10 @@ pre { grid-template-columns: 1fr; } - .ritual-deck { + /* Admin dev view only — the player view keeps its 3-column deck at every + width (see the proportional-chrome block). Unscoped, this collapsed the + player's bet/spin/buy row into a tall stack on phones. */ + body:not(.public-mode) .ritual-deck { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -2555,7 +2706,8 @@ pre { } @media (max-width: 520px) { - .ritual-deck { + /* Admin dev view only — see the note on the 980px rule above. */ + body:not(.public-mode) .ritual-deck { grid-template-columns: 1fr; } } @@ -2842,7 +2994,7 @@ body { /* Clean, static temple backdrop: the image is shown FULLY and clearly — no dark scrim, no colour wash, no animated overlays. One fixed, viewport-covering image fills the whole game; the reel table and controls float on top. */ - background: url("assets/symbols/Background.png") center / cover no-repeat fixed; + background: url("assets/symbols/Background.webp") center / cover no-repeat fixed; background-color: #050a14; } @@ -2909,18 +3061,30 @@ body.app-ready .boot-loader { } /* ── Graceful degradation on weak devices ────────────────────────────────── - ReelCanvasRenderer publishes its quality tier onto ; these rules cash - that in for the cheapest CSS wins. `background-attachment: fixed` is a notable - mobile repaint cost, so mid/low scroll the backdrop with the page instead. Low - tier also drops the expensive backdrop-blur/heavy hero art entirely. */ + ReelCanvasRenderer publishes its quality tier onto (perf-mid/perf-low) + plus a separate perf-handheld flag for any touch device; these rules cash that + in for the cheapest CSS wins. + + `background-attachment: fixed` forces the backdrop to be re-rasterized against + the viewport and is a well-known mobile repaint cost, so anything that is not + a desktop scrolls it with the page instead. */ body.perf-mid, -body.perf-low { +body.perf-low, +body.perf-handheld { background-attachment: scroll; } body.perf-low .stage-character { display: none; } + +/* backdrop-filter is a live resample of everything behind the element. `.hud-bar` + carries one permanently and sits directly over the reels, so on a phone it is + re-blurring a moving canvas every single frame. Dropped on ALL handhelds, not + just devices that have already been demoted to perf-low. */ body.perf-low *, body.perf-low *::before, -body.perf-low *::after { +body.perf-low *::after, +body.perf-handheld *, +body.perf-handheld *::before, +body.perf-handheld *::after { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; } @@ -3520,308 +3684,17 @@ pre, .confirm-btn:active { transform: scale(0.97); } /* =========================================================================== - LIVING AMBIANCE — moving backdrop, deity mascot, lightning, god-rays, coins. - All built by client/engine/ambiance.js. Transform/opacity only (cheap). - =========================================================================== */ - -/* Keep the actual game above the fixed backdrop. */ -.sanctum { position: relative; z-index: 1; } - -.fx-backdrop { - position: fixed; - inset: 0; - z-index: 0; - overflow: hidden; - pointer-events: none; - --fx-px: 0; - --fx-py: 0; -} + AMBIANCE STACKING CONTEXT -.fx-nebula { - position: absolute; - border-radius: 50%; - filter: blur(60px); - opacity: 0.5; - mix-blend-mode: screen; - will-change: transform; -} -.fx-nebula-1 { - width: 60vw; height: 60vw; left: -12vw; top: -16vh; - background: radial-gradient(circle, rgba(99, 162, 255, 0.5), transparent 62%); - animation: fxDrift1 26s ease-in-out infinite; - transform: translate(calc(var(--fx-px) * 26px), calc(var(--fx-py) * 26px)); -} -.fx-nebula-2 { - width: 64vw; height: 64vw; right: -16vw; top: -8vh; - background: radial-gradient(circle, rgba(120, 240, 220, 0.4), transparent 62%); - animation: fxDrift2 32s ease-in-out infinite; - transform: translate(calc(var(--fx-px) * -34px), calc(var(--fx-py) * 22px)); -} -.fx-nebula-3 { - width: 70vw; height: 70vw; left: 18vw; bottom: -34vh; - background: radial-gradient(circle, rgba(180, 120, 255, 0.34), transparent 64%); - animation: fxDrift1 38s ease-in-out infinite reverse; - transform: translate(calc(var(--fx-px) * 18px), calc(var(--fx-py) * -18px)); -} -@keyframes fxDrift1 { 0%,100% { margin-left: 0; margin-top: 0; } 50% { margin-left: 4vw; margin-top: 3vh; } } -@keyframes fxDrift2 { 0%,100% { margin-left: 0; margin-top: 0; } 50% { margin-left: -5vw; margin-top: 4vh; } } + The ~300 lines of ambiance styling that used to live here (moving nebula + backdrop, drifting deco gems, deity mascot, lightning, god-rays, coin + shower) were removed. Ambiance.init() in client/engine/ambiance.js returns + unconditionally, so none of those elements are ever built — but the rules + were still parsed on every load, and several of the infinite keyframes + animated margin-left/top, which force layout rather than compositing. -.fx-stars { - position: absolute; - inset: 0; - background-image: - radial-gradient(1.4px 1.4px at 12% 22%, rgba(220,235,255,0.8), transparent), - radial-gradient(1.6px 1.6px at 68% 12%, rgba(200,230,255,0.7), transparent), - radial-gradient(1.2px 1.2px at 42% 64%, rgba(255,240,210,0.7), transparent), - radial-gradient(1.5px 1.5px at 84% 48%, rgba(210,225,255,0.65), transparent), - radial-gradient(1.3px 1.3px at 26% 82%, rgba(230,240,255,0.7), transparent), - radial-gradient(1.2px 1.2px at 92% 78%, rgba(255,235,215,0.6), transparent), - radial-gradient(1.4px 1.4px at 56% 36%, rgba(215,232,255,0.6), transparent); - animation: fxTwinkle 6s ease-in-out infinite; - transform: translate(calc(var(--fx-px) * 8px), calc(var(--fx-py) * 8px)); -} -@keyframes fxTwinkle { 0%,100% { opacity: 0.5; } 50% { opacity: 0.9; } } - -.fx-floaters { position: absolute; inset: 0; } -.fx-floater { - position: absolute; - border-radius: 50%; - background: radial-gradient(circle at 35% 30%, #fff, var(--c) 55%, transparent 75%); - box-shadow: 0 0 12px var(--c); - transform: translate(calc(var(--fx-px) * 16px), calc(var(--fx-py) * 16px)); - animation: fxFloat var(--dur, 14s) ease-in-out var(--delay, 0s) infinite; - will-change: transform, opacity; -} -@keyframes fxFloat { - 0% { transform: translate(calc(var(--fx-px) * 16px), 0) scale(0.9); } - 50% { transform: translate(calc(var(--fx-px) * 16px + var(--drift, 20px)), -38px) scale(1.1); } - 100% { transform: translate(calc(var(--fx-px) * 16px), 0) scale(0.9); } -} - -.fx-spark { - position: fixed; - width: 10px; height: 10px; - margin: -5px 0 0 -5px; - border-radius: 50%; - pointer-events: none; - background: radial-gradient(circle, #fff, rgba(155,231,255,0.8) 45%, transparent 70%); - animation: fxSpark 0.7s ease-out forwards; - z-index: 2; -} -@keyframes fxSpark { - 0% { transform: scale(0.4); opacity: 1; box-shadow: 0 0 0 0 rgba(155,231,255,0.6); } - 100% { transform: scale(3.4); opacity: 0; box-shadow: 0 0 0 18px rgba(155,231,255,0); } -} - -/* The ambiance stage fills the game frame but sits UNDER the reel window so - nothing ever paints over the reels (the reel window gets a higher z-index). */ -.fx-stage { - position: absolute; - inset: 0; - z-index: 0; - pointer-events: none; - overflow: visible; -} -/* Keep the reel window (and its callouts) above the ambiance stage. */ + These two rules are kept because they apply to REAL elements and define the + stacking order of the game itself, not the ambiance layer. + =========================================================================== */ +.sanctum { position: relative; z-index: 1; } .vault-window { position: relative; z-index: 2; } - -/* Fun decoration ON the altar frame — drifting gem images + glowing orbs, - biased to the edges so they frame the reels (and sit behind them). */ -.fx-deco { position: absolute; inset: 0; pointer-events: none; overflow: hidden; } -.fx-deco-item { - position: absolute; - will-change: transform, opacity; - animation: decoFloat var(--dur, 14s) ease-in-out var(--delay, 0s) infinite; -} -.fx-deco-gem { - object-fit: contain; - opacity: 0.42; - filter: drop-shadow(0 0 7px rgba(150, 200, 255, 0.45)) saturate(1.1); -} -.fx-deco-orb { - border-radius: 50%; - opacity: 0.5; - background: radial-gradient(circle at 35% 30%, #fff, var(--c) 55%, transparent 76%); - box-shadow: 0 0 12px var(--c); -} -@keyframes decoFloat { - 0%, 100% { transform: translateY(0) rotate(0deg); opacity: 0.32; } - 50% { transform: translateY(-22px) rotate(calc(var(--spin, 1) * 18deg)); opacity: 0.6; } -} - -/* ---- Deity mascot — positioned beside/above the reels by ambiance.js ---- */ -.mascot { - position: absolute; - left: 0; - top: 0; - width: clamp(64px, 13vw, 140px); - height: clamp(78px, 16vw, 168px); - z-index: 3; - pointer-events: none; - --fx-px: 0; --fx-py: 0; - transform: translate(calc(var(--fx-px) * 10px), calc(var(--fx-py) * 8px)); - animation: mascotFloat 5.5s ease-in-out infinite; -} -.mascot-figure { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; -} -.mascot-orb { - position: absolute; - bottom: 6%; - width: 64%; - height: 64%; - border-radius: 50%; - background: - radial-gradient(circle at 38% 30%, rgba(255,255,255,0.95), rgba(155,210,255,0.7) 38%, rgba(70,110,220,0.5) 66%, transparent 78%); - box-shadow: 0 0 26px rgba(120,190,255,0.7), inset 0 0 18px rgba(255,255,255,0.5); - animation: mascotPulse 3.4s ease-in-out infinite; -} -.mascot-crown { - position: absolute; - top: 0; - width: 70%; - height: auto; - filter: drop-shadow(0 0 10px rgba(255,216,111,0.8)) drop-shadow(0 2px 4px rgba(0,0,0,0.5)); - animation: mascotFloat 4.5s ease-in-out infinite reverse; -} -.mascot-art { - width: 100%; height: 100%; - object-fit: contain; - filter: drop-shadow(0 6px 14px rgba(0,0,0,0.5)); -} -.mascot-aura { - position: absolute; - inset: -22%; - border-radius: 50%; - background: radial-gradient(circle, rgba(150,205,255,0.34), rgba(120,180,255,0.12) 50%, transparent 70%); - filter: blur(6px); - animation: mascotAura 4s ease-in-out infinite; -} -.mascot-rays { - position: absolute; - inset: -45%; - border-radius: 50%; - background: repeating-conic-gradient(from 0deg, rgba(255,236,180,0.16) 0deg 8deg, transparent 8deg 22deg); - opacity: 0.35; - animation: mascotSpin 22s linear infinite; - mix-blend-mode: screen; -} -.mascot-orbit { - position: absolute; - inset: 0; - animation: mascotSpin 12s linear infinite; -} -.orbit-gem { - position: absolute; - width: 12%; - height: 12%; - border-radius: 50%; - top: 44%; left: 44%; - background: radial-gradient(circle at 35% 30%, #fff, #9be7ff 55%, transparent 76%); - box-shadow: 0 0 10px #9be7ff; -} -.orbit-gem-1 { transform: translate(0, -340%); } -.orbit-gem-2 { transform: rotate(120deg) translate(0, -340%); background: radial-gradient(circle at 35% 30%, #fff, #ffd86f 55%, transparent 76%); box-shadow: 0 0 10px #ffd86f; } -.orbit-gem-3 { transform: rotate(240deg) translate(0, -340%); background: radial-gradient(circle at 35% 30%, #fff, #ff7fa8 55%, transparent 76%); box-shadow: 0 0 10px #ff7fa8; } - -@keyframes mascotFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-7px); } } -@keyframes mascotPulse { 0%,100% { box-shadow: 0 0 22px rgba(120,190,255,0.6), inset 0 0 16px rgba(255,255,255,0.45); } 50% { box-shadow: 0 0 40px rgba(150,205,255,0.95), inset 0 0 22px rgba(255,255,255,0.65); } } -@keyframes mascotAura { 0%,100% { opacity: 0.6; transform: scale(0.96); } 50% { opacity: 1; transform: scale(1.06); } } -@keyframes mascotSpin { to { transform: rotate(360deg); } } - -/* Mascot reaction states */ -.mascot.is-charging .mascot-orb { animation: mascotCharge 0.6s ease-out; } -@keyframes mascotCharge { 0% { transform: scale(1); } 40% { transform: scale(0.86); } 100% { transform: scale(1); } } -.mascot.is-casting { animation: mascotShake 0.55s ease-in-out; } -.mascot.is-casting .mascot-orb { box-shadow: 0 0 60px rgba(180,225,255,1), inset 0 0 28px rgba(255,255,255,0.9); } -@keyframes mascotShake { 0%,100% { transform: translate(0,0); } 20% { transform: translate(-3px,1px) rotate(-2deg); } 60% { transform: translate(3px,-1px) rotate(2deg); } } -.mascot.is-celebrating { animation: mascotCheer 1.4s ease-in-out; } -.mascot.is-celebrating .mascot-rays { opacity: 0.9; } -.mascot.is-celebrating .mascot-aura { background: radial-gradient(circle, rgba(255,228,150,0.6), rgba(255,200,120,0.2) 50%, transparent 72%); } -@keyframes mascotCheer { 0%,100% { transform: scale(1) translateY(0); } 30% { transform: scale(1.16) translateY(-10px); } 60% { transform: scale(1.08) translateY(-4px); } } - -/* ---- Transient spectacle overlay (inside the reel window) --------------- */ -.fx-overlay { - position: absolute; - inset: 0; - z-index: 6; - overflow: hidden; - pointer-events: none; - border-radius: inherit; -} -.fx-overlay.fx-flash::after { - content: ""; - position: absolute; inset: 0; - background: radial-gradient(circle at 50% 30%, rgba(220,240,255,0.6), rgba(180,210,255,0.18) 45%, transparent 70%); - animation: fxFlash 0.32s ease-out forwards; -} -@keyframes fxFlash { 0% { opacity: 0; } 25% { opacity: 1; } 100% { opacity: 0; } } - -.fx-bolt { - position: absolute; - top: -4%; - left: var(--x, 50%); - width: 4px; - height: 108%; - background: linear-gradient(180deg, rgba(255,255,255,0.95), rgba(170,220,255,0.9) 40%, rgba(120,180,255,0.2)); - filter: drop-shadow(0 0 8px rgba(170,220,255,0.95)); - clip-path: polygon(40% 0, 60% 0, 48% 28%, 70% 30%, 40% 62%, 56% 60%, 30% 100%, 46% 56%, 28% 58%, 52% 26%, 34% 28%); - transform: translateX(-50%) scaleY(0); - transform-origin: top; - animation: fxBolt 0.42s ease-out var(--delay, 0ms) forwards; -} -.fx-bolt-2 { width: 5px; opacity: 0.9; } -.fx-bolt-3 { width: 3px; opacity: 0.85; } -@keyframes fxBolt { - 0% { transform: translateX(-50%) scaleY(0); opacity: 0; } - 18% { transform: translateX(-50%) scaleY(1); opacity: 1; } - 40% { opacity: 0.5; } - 55% { opacity: 1; } - 100% { opacity: 0; } -} - -.fx-overlay.fx-rays::before { - content: ""; - position: absolute; - left: 50%; top: -40%; - width: 180%; height: 180%; - transform: translateX(-50%); - background: repeating-conic-gradient(from 0deg at 50% 0%, rgba(255,232,160,0.0) 0deg 6deg, rgba(255,232,160,0.22) 6deg 9deg, rgba(255,232,160,0.0) 9deg 16deg); - animation: fxRays 1.5s ease-out forwards; - mix-blend-mode: screen; -} -@keyframes fxRays { 0% { opacity: 0; transform: translateX(-50%) rotate(-6deg); } 30% { opacity: 1; } 100% { opacity: 0; transform: translateX(-50%) rotate(6deg); } } - -.fx-coin { - position: absolute; - top: -8%; - width: 18px; height: 18px; - border-radius: 50%; - transform: scale(var(--scale, 1)); - background: radial-gradient(circle at 36% 30%, #fff6cf, #ffcf52 46%, #b87a1e 80%); - box-shadow: 0 0 8px rgba(255,206,82,0.7), inset 0 -3px 4px rgba(150,90,20,0.6); - animation: fxCoinFall var(--dur, 1.6s) ease-in var(--delay, 0s) forwards; -} -.fx-coin-violet { background: radial-gradient(circle at 36% 30%, #fff, #c79bff 46%, #6a3bd0 80%); box-shadow: 0 0 8px rgba(180,140,255,0.8), inset 0 -3px 4px rgba(70,40,140,0.6); } -@keyframes fxCoinFall { - 0% { top: -8%; opacity: 0; transform: translateX(0) scale(var(--scale,1)) rotate(0); } - 10% { opacity: 1; } - 100% { top: 108%; opacity: 0.9; transform: translateX(var(--sway, 0)) scale(var(--scale,1)) rotate(var(--rot, 360deg)); } -} - -/* In the player view the mascot tucks into the top-left corner of the reels; - on wider screens it can sit a touch larger. Admin keeps it modest. */ -@media (max-width: 720px) { - .mascot { width: clamp(54px, 16vw, 100px); height: clamp(66px, 19vw, 120px); opacity: 0.95; } -} - -/* Respect reduced motion: hold everything still (JS already skips spawning - floaters/coins/lightning, but freeze the ambient loops too). */ -@media (prefers-reduced-motion: reduce) { - .fx-nebula, .fx-stars, .fx-floater, .mascot, .mascot-crown, - .mascot-orb, .mascot-aura, .mascot-rays, .mascot-orbit { animation: none !important; } -} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5e022f6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,685 @@ +{ + "name": "provider-slot-mvp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "provider-slot-mvp", + "version": "0.1.0", + "devDependencies": { + "sharp": "^0.35.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + } + } +} diff --git a/package.json b/package.json index 75ab94d..ac86128 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "npx --yes http-server client -p 3000 -c-1 -o", "start:dev-server": "node backend/server.js", "simulate": "node math/simulate.js", + "assets:optimize": "node tools/optimize-assets.js", "test:api": "node tools/api-test.js", "test:rtp-parity": "node tools/rtp-parity.js", "test:spin-lock": "node tools/spin-lock-test.js", @@ -15,5 +16,8 @@ }, "engines": { "node": ">=18" + }, + "devDependencies": { + "sharp": "^0.35.3" } } diff --git a/tools/optimize-assets.js b/tools/optimize-assets.js new file mode 100644 index 0000000..de8c10d --- /dev/null +++ b/tools/optimize-assets.js @@ -0,0 +1,135 @@ +/* eslint-disable */ +// --------------------------------------------------------------------------- +// Art optimizer for Banana X. +// +// The source PNGs in client/assets/symbols/ are authoring-resolution exports: +// 500x500 tiles at ~400KB each, a 1024x1024 spin button, 2MB reel/scatter +// plates — ~11MB of art for a game that draws those tiles into ~80px cells. +// That cost the player twice: a long first load, and a per-frame resample of +// multi-megapixel bitmaps down to thumbnail size on every canvas draw. +// +// This script re-encodes each one to WebP at roughly 2x its maximum on-screen +// size (2x so it still looks sharp on a retina backing store). WebP is +// supported by every browser the game targets (Safari 14+, 2020), so the +// client references the .webp directly — no , no PNG fallback. +// +// The PNGs stay in the repo as the sources of truth: re-run this script after +// replacing any of them. +// +// Usage: npm run assets:optimize +// Output: client/assets/symbols/*.webp +// --------------------------------------------------------------------------- + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const sharp = require("sharp"); + +const SYMBOLS_DIR = path.join(__dirname, "..", "client", "assets", "symbols"); + +// Every asset the client actually references, with the box it is drawn into. +// `max` is the longest edge to keep; the other edge follows the source aspect +// ratio (fit: "inside", never enlarging). Sizes are ~2x the largest the asset +// is ever painted at, measured from the CSS/canvas layout. +const TARGETS = [ + // Symbol tiles — drawn into grid cells that top out around 120px. + { src: "Crown.png", max: 256 }, + { src: "HourGlass.png", max: 256 }, + { src: "Ring.png", max: 256 }, + { src: "Chaile.png", max: 256 }, + { src: "RedGem.png", max: 256 }, + { src: "PurpleGem.png", max: 256 }, + { src: "YellowGem.png", max: 256 }, + { src: "GreenGem.png", max: 256 }, + { src: "BlueGem.png", max: 256 }, + { src: "Common.png", max: 256 }, + { src: "Rare.png", max: 256 }, + { src: "Epic.png", max: 256 }, + { src: "Legendary.png", max: 256 }, + { src: "Mythic.png", max: 256 }, + + // Scatter is a wide tile drawn contained inside a cell, so it needs a little + // more than a square symbol to keep its detail. + { src: "Scatter.png", max: 512 }, + + // The reel frame is baked into the full-height stage cache once per resize, + // so it can be asked for the full backing-store height of a tall canvas. + // Kept at native size — WebP makes it cheap enough that downscaling is not + // worth the risk of a soft frame on a large display. + { src: "REEL.png", max: 1672 }, + + // Full-viewport background. Keep the pixels, just drop the PNG overhead. + { src: "Background.png", max: 1376, quality: 78 }, + + // UI plates. The spin button caps out around 160px CSS; the removebg plates + // are wide banners that never exceed ~400px CSS. + { src: "SPIN.png", max: 320 }, + { src: "ANTE_ACTIVE-removebg-preview.png", max: 800 }, + { src: "ANTE_INACTIVE-removebg-preview.png", max: 800 }, + { src: "BET-removebg-preview.png", max: 776 }, + { src: "BUY_FEATURE-removebg-preview.png", max: 800 }, + { src: "FREESPINS_INFO-removebg-preview.png", max: 707 } +]; + +const DEFAULT_QUALITY = 82; + +function kb(bytes) { + return `${(bytes / 1024).toFixed(0)} KB`; +} + +async function run() { + let srcTotal = 0; + let outTotal = 0; + let failed = 0; + + for (const target of TARGETS) { + const srcPath = path.join(SYMBOLS_DIR, target.src); + const outPath = path.join(SYMBOLS_DIR, target.src.replace(/\.png$/i, ".webp")); + if (!fs.existsSync(srcPath)) { + console.error(` MISSING ${target.src}`); + failed += 1; + continue; + } + + const srcBytes = fs.statSync(srcPath).size; + const meta = await sharp(srcPath).metadata(); + + await sharp(srcPath) + .resize({ + width: target.max, + height: target.max, + fit: "inside", + withoutEnlargement: true + }) + // Alpha matters on every one of these — they all sit over the backdrop. + .webp({ quality: target.quality || DEFAULT_QUALITY, alphaQuality: 100, effort: 6 }) + .toFile(outPath); + + const outBytes = fs.statSync(outPath).size; + const outMeta = await sharp(outPath).metadata(); + srcTotal += srcBytes; + outTotal += outBytes; + + const saved = (100 * (1 - outBytes / srcBytes)).toFixed(0); + console.log( + ` ${path.basename(outPath).padEnd(38)} ` + + `${String(meta.width).padStart(4)}x${String(meta.height).padEnd(4)} -> ` + + `${String(outMeta.width).padStart(4)}x${String(outMeta.height).padEnd(4)} ` + + `${kb(srcBytes).padStart(8)} -> ${kb(outBytes).padStart(8)} (-${saved}%)` + ); + } + + console.log( + `\n ${TARGETS.length - failed}/${TARGETS.length} assets ` + + `${kb(srcTotal)} -> ${kb(outTotal)} ` + + `(-${(100 * (1 - outTotal / srcTotal)).toFixed(0)}%)` + ); + if (failed) process.exitCode = 1; +} + +console.log(`\nOptimizing art in ${SYMBOLS_DIR}\n`); +run().catch((err) => { + console.error(err); + process.exit(1); +});