diff --git a/client/engine/ambiance.js b/client/engine/ambiance.js index 9aa42cc..ab6dee9 100644 --- a/client/engine/ambiance.js +++ b/client/engine/ambiance.js @@ -298,6 +298,12 @@ function init() { if (state.inited) return; + // Background ambiance DISABLED: no animated backdrop (nebulas/stars/floaters), + // no floating deco gems, no CSS mascot, no pointer parallax — the game uses a + // clean, static temple background with the real character image on top. Leaving + // early keeps `state.inited` false so every react() call is a safe no-op. + return; + // eslint-disable-next-line no-unreachable buildBackdrop(); buildStage(); installPointer(); diff --git a/client/main.js b/client/main.js index e49f56b..c2a37ba 100644 --- a/client/main.js +++ b/client/main.js @@ -220,6 +220,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // renderer animation methods + animationSleep. Fast-stop still wins and // collapses to near-instant. const turboScale = () => (state.turbo ? 0.4 : 1); +// On fast-stop, the in-flight drop is re-timed to land smoothly in ~this window +// (a quick, visible settle — NOT an instant teleport), and the per-step drop +// awaits resolve this long after the tap so the board never commits mid-fall. +const FAST_STOP_SETTLE_MS = 150; const animationSleep = (ms) => sleep(state.fastStopRequested ? Math.min(50, ms) : Math.round(ms * turboScale())); const lerp = (a, b, t) => a + (b - a) * t; @@ -650,16 +654,26 @@ class ReelCanvasRenderer { try { this.draw(); } catch (err) { console.error("forceDrawNow draw failed:", err); } } - requestFastStop() { + /** Re-time an in-flight drop so it CONTINUES from its current visual position + * and finishes smoothly in ~`remainingMs`, i.e. it appears to speed up rather + * than snapping. (The old code jumped progress to 0.75 instantly, which read + * as a glitchy teleport.) Preserves the current progress `p` and compresses + * only the remaining (1 - p) of the fall. */ + _accelerateDrop(fx, remainingMs) { + if (!fx) return; const now = performance.now(); - if (this.fx.drop) { - this.fx.drop.duration = Math.min(this.fx.drop.duration || 120, 120); - this.fx.drop.start = Math.min(this.fx.drop.start || now, now - 90); - } - if (this.fx.heavyDrop) { - this.fx.heavyDrop.duration = Math.min(this.fx.heavyDrop.duration || 120, 120); - this.fx.heavyDrop.start = Math.min(this.fx.heavyDrop.start || now, now - 90); - } + const dur = fx.duration || 1; + const p = clamp((now - fx.start) / dur, 0, 1); + if (p >= 1) return; + const newDur = Math.max(1, remainingMs / (1 - p)); + fx.duration = newDur; + fx.start = now - p * newDur; + } + + requestFastStop() { + // Speed the current drop(s) up to a smooth quick landing (no teleport). + this._accelerateDrop(this.fx.drop, FAST_STOP_SETTLE_MS - 20); + this._accelerateDrop(this.fx.heavyDrop, FAST_STOP_SETTLE_MS - 20); this.fx.cluster = null; this.fx.sweeps = []; this.fx.charge = null; @@ -667,6 +681,29 @@ class ReelCanvasRenderer { this.particles = this.particles.slice(-40); } + /** Wait `realMs` (already in real / turbo-adjusted ms — do NOT pass raw values + * through animationSleep, which would double-scale in turbo and resolve before + * the fall visually finishes, making symbols vanish mid-air). Resolves early on + * a fast-stop, but only ~FAST_STOP_SETTLE_MS after the request so the sped-up + * drop has time to visibly land first. */ + _dropSettle(realMs) { + const start = performance.now(); + return new Promise((resolve) => { + let stopAt = state.fastStopRequested ? start : null; + const tick = () => { + const now = performance.now(); + if (stopAt === null && state.fastStopRequested) stopAt = now; + if (stopAt !== null) { + if (now - stopAt >= FAST_STOP_SETTLE_MS) return resolve(); + } else if (now - start >= realMs) { + return resolve(); + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); + } + cellCenter(row, col) { const { padX, top, laneW, rowStep } = this.getLayout(); return { @@ -821,7 +858,10 @@ class ReelCanvasRenderer { const [row, col] = key.split("-").map((n) => Number(n)); return Math.max(acc, this.dropDelay(row, col, Number(count || 0))); }, 0); - await animationSleep(duration + maxDelay + 40); + // `duration` and `maxDelay` are already turbo-adjusted, so wait the REAL time + // (not animationSleep, which re-scales and would hide the board while symbols + // are still exiting — the "half-way out then vanish" bug). + await this._dropSettle(duration + maxDelay + 40); this.fx.drop = null; this.board.hidden = true; } @@ -1087,7 +1127,10 @@ class ReelCanvasRenderer { // Tail buffer: 30ms when no reveal cells (just settles the landing // frame), 90ms when reveals are running (lets the reveal-pop finish). - await animationSleep(dropDuration + maxDelay + revealLeadMaxMs + (hasRevealCells ? 90 : 30)); + // dropDuration / maxDelay are already turbo-adjusted — wait the REAL time so + // the fall fully lands before the board commits (animationSleep would double- + // scale in turbo and commit mid-fall → symbols vanish half-way down). + await this._dropSettle(dropDuration + maxDelay + revealLeadMaxMs + (hasRevealCells ? 90 : 30)); this.fx.drop = null; this.fx.heavyDrop = null; if (window.__renderDebug) { @@ -1390,12 +1433,16 @@ class ReelCanvasRenderer { getLayout() { const width = this.canvas.width / this.dpr; const height = this.canvas.height / this.dpr; - const padX = clamp(width * 0.04, 20, 40); - const top = clamp(height * 0.08, 34, 62); - const bottom = clamp(height * 0.08, 30, 60); + // Tighter frame padding so the 6×5 grid claims more of the canvas → bigger + // cells and bigger symbols (was 0.04 padX / 0.08 top+bottom, which wasted + // ~17% of the height on margins). Kept a small top band for the reel-frame + // crown/spine that draws above the first row. + const padX = clamp(width * 0.022, 8, 22); + const top = clamp(height * 0.045, 14, 34); + const bottom = clamp(height * 0.04, 12, 30); const laneW = (width - padX * 2) / this.cols; const rowStep = (height - top - bottom) / this.rows; - const radius = Math.min(laneW, rowStep) * 0.42; + const radius = Math.min(laneW, rowStep) * 0.46; return { width, height, padX, top, bottom, laneW, rowStep, radius }; } @@ -1443,8 +1490,17 @@ class ReelCanvasRenderer { } dropDelay(row, col, count) { - // Subtle per-column stagger (~25ms); per-row stagger kept light. - return Math.max(0, col * 25 + row * 10 + Math.max(0, count - 1) * 14); + // Fast-stop collapses the whole board in at once — no cascade. + if (state.fastStopRequested) return 0; + // Pronounced left-to-right REEL cascade: each column starts dropping a clear + // beat (~90ms) after the column to its left, so reel 1 lands first, then reel + // 2, then reel 3 … (Pragmatic-style waterfall) instead of the whole board + // arriving together. A small per-row stagger keeps each column reading as + // falling gravity rather than a rigid block, and symbols that fall farther + // (higher count) wait a touch longer. Scaled by turbo so the cascade + // compresses in step with the rest of the spin. + const raw = col * 90 + row * 12 + Math.max(0, count - 1) * 14; + return Math.max(0, Math.round(raw * turboScale())); } computeWinningBBox(winning) { @@ -1999,9 +2055,11 @@ class ReelCanvasRenderer { const coreR = radius * 1.05 * baseScale * revealScale; const scatterSizeBoost = symbol === "SCATTER" ? 1.22 : 1; const multiScale = symbol === "MULTI" ? multiplierIconScale(this.board.multiMap.get(key)) : 1; - const maxIconBase = Math.min(laneW, rowStep) * (symbol === "MULTI" ? 1.16 : 1.02); + // Fill the cell: symbols are drawn nearly edge-to-edge (Pragmatic-style) + // instead of the old ~0.94-of-cell size that left big gaps. + const maxIconBase = Math.min(laneW, rowStep) * (symbol === "MULTI" ? 1.24 : 1.12); const iconBase = Math.min( - radius * 2.25 * baseScale * scatterSizeBoost * revealScale * multiScale, + radius * 2.5 * baseScale * scatterSizeBoost * revealScale * multiScale, maxIconBase * baseScale * revealScale ); const iconW = iconBase * squashX; diff --git a/client/styles.css b/client/styles.css index 1085a5b..2c8ab70 100644 --- a/client/styles.css +++ b/client/styles.css @@ -740,6 +740,51 @@ body.public-mode .reels-canvas { 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. +---------------------------------------------------------------------------- */ +@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; + } +} + /* ---------------------------------------------------------------------------- Mobile portrait-first tuning (player view only). The public-mode layout is already a vertical stack (title → HUD → reels → controls); here we size the @@ -769,8 +814,7 @@ body.public-mode .reels-canvas { padding-bottom: 2px; } body.public-mode .img-btn-buy, - body.public-mode .ante-toggle, - body.public-mode .bet-stepper { + body.public-mode .ante-toggle { height: 56px; } body.public-mode .hud-node strong { @@ -809,8 +853,7 @@ body.public-mode .reels-canvas { max-height: clamp(72px, 12vh, 104px); } body.public-mode .img-btn-buy, - body.public-mode .ante-toggle, - body.public-mode .bet-stepper { + body.public-mode .ante-toggle { height: 48px; } } @@ -2034,11 +2077,15 @@ button.img-btn, .bet-stepper { position: relative; display: block; - height: 64px; - /* Match the BET pill image's aspect so clickable circles align. */ - aspect-ratio: 768 / 220; - max-width: 100%; - background: url("/assets/symbols/BET-removebg-preview.png") center / contain no-repeat; + /* WIDTH-driven so the box aspect ALWAYS equals the pill image's intrinsic + aspect (776×183). A fixed height + capped width would override aspect-ratio + and letterbox the pill, pushing the drawn +/- circles out of the invisible + round hit-zones (the "have to click off-target" bug). With width driving the + height, `contain` fills the box exactly and the %-positioned buttons always + 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; user-select: none; margin: 0 auto; } @@ -2065,8 +2112,8 @@ button.bet-step, outline: 0; transition: transform 0.1s ease, filter 0.12s ease; } -.bet-step-down { left: 1.5%; } -.bet-step-up { right: 1.5%; } +.bet-step-down { left: 2%; } +.bet-step-up { right: 2%; } .bet-step:hover { filter: brightness(1.18); } .bet-step:active { transform: translateY(-50%) scale(0.92); } .bet-step:disabled { cursor: not-allowed; opacity: 0.5; } @@ -2747,42 +2794,26 @@ pre { body { font-family: "SF Pro Display", "SF Pro Text", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - /* Drowned sun-temple scene is the whole-GAME backdrop: one fixed, viewport- - covering image sits behind everything so the temple fills the full view (not - just the reel panel). A soft blue top-glow + dark scrim over the image keep the - UI readable and blend it with the underwater theme; the reel table floats on top. */ - background: - radial-gradient(1200px 560px at 12% -8%, rgba(130, 176, 255, 0.22), transparent 60%), - radial-gradient(1100px 520px at 92% -12%, rgba(116, 232, 255, 0.15), transparent 58%), - linear-gradient(180deg, rgba(5, 10, 20, 0.55) 0%, rgba(8, 15, 31, 0.62) 42%, rgba(13, 23, 48, 0.7) 100%), - url("assets/symbols/Background.png") center / cover no-repeat fixed, - linear-gradient(180deg, #050a14 0%, #080f1f 42%, #0d1730 100%); + /* 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-color: #050a14; } -body::before { - background: - radial-gradient(circle at 22% 18%, rgba(181, 213, 255, 0.06), transparent 32%), - radial-gradient(circle at 80% 20%, rgba(148, 237, 255, 0.05), transparent 30%), - linear-gradient(90deg, transparent 0, rgba(200, 225, 255, 0.02) 50%, transparent 100%), - repeating-linear-gradient(90deg, rgba(198, 221, 255, 0.012) 0px, rgba(198, 221, 255, 0.012) 1px, transparent 1px, transparent 42px); -} - +/* Overlay layers removed — nothing washes/tints the background anymore. */ +body::before, body::after { - background: linear-gradient(180deg, rgba(205, 230, 255, 0.04), transparent 14%, transparent 84%, rgba(3, 7, 16, 0.34)); + display: none; } .altar { border-radius: 20px; border-color: rgba(171, 207, 255, 0.26); - /* The temple scene is painted on the panel itself too (under a translucent tint - for readability), so the altar carries the backdrop directly rather than only - letting the image show through. `cover` fills the panel responsively. */ - background: - linear-gradient(180deg, rgba(14, 23, 42, 0.38), rgba(7, 13, 28, 0.44)), - url("assets/symbols/Background.png") center / cover no-repeat; - backdrop-filter: blur(20px) saturate(140%); - -webkit-backdrop-filter: blur(20px) saturate(140%); + /* Transparent so the clean temple image shows through fully. No own image + copy, no dark scrim, and NO backdrop-filter blur (that frosted/blurred the + background — the "blended, not fully shown" look). */ + background: transparent; } .status-ribbon-item,