Spaces:
Running
Running
Download game.html from mendeld/My-First-Space: direct link, hf CLI and curl.
- Browser
- Download file 80.3 kB
-
https://huggingface.co/spaces/mendeld/My-First-Space/resolve/main/game.html
- Command line
-
hf download hf://spaces/mendeld/My-First-Space/game.html
-
curl -L -o game.html https://huggingface.co/spaces/mendeld/My-First-Space/resolve/main/game.html
80.3 kB
| <!-- VERSION: PERMANENT-MENORAH-AUTO-LANDING-MELT + MULTI-UPGRADE-SHOP + ACCOUNT-UI v16 --> | |
| <!-- VERSION: DOUBLE-JUMP-FIX + POLISH-PASS-1 v11 --> | |
| <!-- VERSION: FIXED-GREEN-PLATFORM-SPEED + FIXED-RUNTIME + FIXED-BOOK-PATTERN + INFINITE-STABLE-BOOKCASE + FULLSCREEN + TALL-AUTO-EXTENDING-WORLD + ALWAYS-VISIBLE-JUMP v8 --> | |
| <html> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> | |
| <title>Rabbi Bounce - Candy Land</title> | |
| <style> | |
| html, body { width:100%; height:100%; overflow:hidden; } | |
| body { margin:0; background:#1a1025; font-family:'Comic Sans MS', Arial, sans-serif; touch-action:none; } | |
| canvas { display:block; width:100vw; height:100vh; width:100dvw; height:100dvh; background:#ffe0f2; touch-action:none; } | |
| #backToSite { | |
| position: fixed; | |
| top: 12px; | |
| left: 15px; | |
| z-index: 4000; | |
| padding: 7px 12px; | |
| border: none; | |
| border-radius: 8px; | |
| font-family: "Poppins", Arial, sans-serif; | |
| font-size: 13px; | |
| cursor: pointer; | |
| } | |
| #tiltButton { | |
| position: fixed; | |
| top: 50px; | |
| left: 15px; | |
| z-index: 4000; | |
| padding: 7px 12px; | |
| border: none; | |
| border-radius: 8px; | |
| font-size: 13px; | |
| cursor: pointer; | |
| display: none; | |
| } | |
| #welcomeUserGame { | |
| position: fixed; | |
| top: 12px; | |
| right: 15px; | |
| z-index: 4000; | |
| font-family: "Poppins", Arial, sans-serif; | |
| font-size: 14px; | |
| font-weight: 600; | |
| color: white; | |
| text-shadow: 0 1px 4px rgba(0, 0, 0, 0.7); | |
| } | |
| #logoutGameButton { | |
| position: fixed; | |
| top: 38px; | |
| right: 15px; | |
| z-index: 4000; | |
| padding: 7px 12px; | |
| border: none; | |
| border-radius: 8px; | |
| font-family: "Poppins", Arial, sans-serif; | |
| font-size: 13px; | |
| cursor: pointer; | |
| } | |
| .customContextMenu { | |
| position: fixed; | |
| z-index: 6000; | |
| display: none; | |
| min-width: 220px; | |
| padding: 8px; | |
| background: rgba(255, 255, 255, 0.97); | |
| border: 1px solid rgba(35, 40, 80, 0.16); | |
| border-radius: 16px; | |
| box-shadow: 0 16px 42px rgba(35, 40, 80, 0.22); | |
| backdrop-filter: blur(10px); | |
| text-align: left; | |
| font-family: "Poppins", Arial, sans-serif; | |
| } | |
| .customContextMenu.show { | |
| display: block; | |
| } | |
| .customContextMenu button { | |
| width: 100%; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 11px 12px; | |
| border: 0; | |
| border-radius: 10px; | |
| background: transparent; | |
| color: #2f344d; | |
| font-size: 15px; | |
| text-align: left; | |
| cursor: pointer; | |
| } | |
| .customContextMenu button:hover, | |
| .customContextMenu button:focus-visible { | |
| background: #eef3ff; | |
| outline: none; | |
| } | |
| .contextIcon { | |
| width: 24px; | |
| text-align: center; | |
| } | |
| .contextDivider { | |
| height: 1px; | |
| margin: 6px 4px; | |
| background: rgba(35, 40, 80, 0.12); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <noscript> | |
| <style> | |
| body > * { display: none ; } | |
| #javascriptRequired { | |
| display: block ; | |
| color: white; | |
| font-family: Arial, sans-serif; | |
| text-align: center; | |
| margin-top: 100px; | |
| font-size: 20px; | |
| } | |
| </style> | |
| <div id="javascriptRequired">JavaScript is required to play Rabbi Bounce.</div> | |
| </noscript> | |
| <button | |
| id="backToSite" | |
| onclick="window.location.href='index.html'" | |
| > | |
| ← Back | |
| </button> | |
| <button id="tiltButton" type="button">Enable tilt</button> | |
| <div id="welcomeUserGame"></div> | |
| <button id="logoutGameButton" type="button">Log out</button> | |
| <div | |
| id="gameContextMenu" | |
| class="customContextMenu" | |
| role="menu" | |
| aria-hidden="true" | |
| > | |
| <button type="button" role="menuitem" data-action="home"> | |
| <span class="contextIcon">🏠</span> | |
| <span>Game menu</span> | |
| </button> | |
| <button type="button" role="menuitem" data-action="clicker"> | |
| <span class="contextIcon">🖱️</span> | |
| <span>Play Clicker</span> | |
| </button> | |
| <button type="button" role="menuitem" data-action="rabbi"> | |
| <span class="contextIcon">📚</span> | |
| <span>Rabbi Bounce menu</span> | |
| </button> | |
| <div class="contextDivider"></div> | |
| <button type="button" role="menuitem" data-action="logout"> | |
| <span class="contextIcon">↪️</span> | |
| <span>Log out</span> | |
| </button> | |
| </div> | |
| <canvas id="game"></canvas> | |
| <script> | |
| if ( | |
| localStorage.getItem("loggedIn") !== "true" || | |
| !localStorage.getItem("username") | |
| ) { | |
| window.location.href = "index.html"; | |
| } | |
| function logoutGame(){ | |
| localStorage.removeItem("loggedIn"); | |
| localStorage.removeItem("username"); | |
| window.location.href = "index.html"; | |
| } | |
| document.getElementById("logoutGameButton").addEventListener("click", logoutGame); | |
| const canvas = document.getElementById('game'); | |
| const ctx = canvas.getContext('2d'); | |
| // The game keeps the original 800x600 scale as its minimum logical size, | |
| // but the logical WORLD expands to match the shape of the actual screen. | |
| // That means there are no empty side bars on a wide monitor and no empty | |
| // top/bottom bands on a tall phone: shelves, platforms and hazards can use | |
| // every visible part of the screen without stretching the artwork. | |
| const LOGICAL_WIDTH = 800; | |
| const LOGICAL_HEIGHT = 600; | |
| let viewScale = 1; | |
| let visibleWorldWidth = LOGICAL_WIDTH; | |
| let visibleWorldHeight = LOGICAL_HEIGHT; | |
| let WORLD_HALF = LOGICAL_WIDTH / 2; | |
| let VIEW_HALF_HEIGHT = LOGICAL_HEIGHT / 2; | |
| function resizeGameCanvas(){ | |
| const cssWidth = Math.max(1, window.innerWidth); | |
| const cssHeight = Math.max(1, window.innerHeight); | |
| const dpr = Math.max(1, window.devicePixelRatio || 1); | |
| canvas.style.width = cssWidth + 'px'; | |
| canvas.style.height = cssHeight + 'px'; | |
| canvas.width = Math.round(cssWidth * dpr); | |
| canvas.height = Math.round(cssHeight * dpr); | |
| // Use one uniform scale so circles stay round and the game never stretches. | |
| // Whichever dimension has extra room simply reveals MORE of the game world. | |
| viewScale = Math.min( | |
| canvas.width / LOGICAL_WIDTH, | |
| canvas.height / LOGICAL_HEIGHT | |
| ); | |
| visibleWorldWidth = canvas.width / viewScale; | |
| visibleWorldHeight = canvas.height / viewScale; | |
| WORLD_HALF = visibleWorldWidth / 2; | |
| VIEW_HALF_HEIGHT = visibleWorldHeight / 2; | |
| } | |
| resizeGameCanvas(); | |
| window.addEventListener('resize', resizeGameCanvas); | |
| window.addEventListener('orientationchange', () => { | |
| setTimeout(resizeGameCanvas, 100); | |
| tiltNeutral = null; | |
| }); | |
| const screen = { | |
| clear(color){ | |
| ctx.setTransform(1,0,0,1,0,0); | |
| ctx.fillStyle = color; | |
| ctx.fillRect(0,0,canvas.width,canvas.height); | |
| ctx.setTransform( | |
| viewScale, | |
| 0, | |
| 0, | |
| -viewScale, | |
| canvas.width / 2, | |
| canvas.height / 2 | |
| ); | |
| }, | |
| drawRect(x,y,w,h,color){ | |
| ctx.fillStyle = color; | |
| ctx.fillRect(x-w/2, y-h/2, w, h); | |
| }, | |
| drawCircle(x,y,r,color){ | |
| ctx.beginPath(); | |
| ctx.arc(x,y,r,0,Math.PI*2); | |
| ctx.fillStyle = color; | |
| ctx.fill(); | |
| }, | |
| drawText(text,x,y,size,color){ | |
| ctx.save(); | |
| ctx.scale(1,-1); | |
| ctx.fillStyle = color; | |
| ctx.font = `bold ${size}px 'Comic Sans MS', Arial, sans-serif`; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| ctx.fillText(text, x, -y); | |
| ctx.restore(); | |
| } | |
| }; | |
| const keys = {}; | |
| window.addEventListener('keydown', e => { keys[e.key] = true; if(e.key===' ') e.preventDefault(); }); | |
| window.addEventListener('keyup', e => keys[e.key] = false); | |
| const mouse = { x:0, y:0, pressed:false }; | |
| function updatePointer(clientX, clientY){ | |
| const r = canvas.getBoundingClientRect(); | |
| const backingX = (clientX - r.left) * (canvas.width / r.width); | |
| const backingY = (clientY - r.top) * (canvas.height / r.height); | |
| mouse.x = (backingX - canvas.width / 2) / viewScale; | |
| mouse.y = -((backingY - canvas.height / 2) / viewScale); | |
| } | |
| canvas.addEventListener('mousedown', e => { if(e.button !== 0) return; mouse.pressed = true; updatePointer(e.clientX, e.clientY); }); | |
| window.addEventListener('mouseup', () => mouse.pressed = false); | |
| canvas.addEventListener('mousemove', e => updatePointer(e.clientX, e.clientY)); | |
| canvas.addEventListener('touchstart', e => { e.preventDefault(); mouse.pressed = true; updatePointer(e.touches[0].clientX, e.touches[0].clientY); }, {passive:false}); | |
| canvas.addEventListener('touchmove', e => { e.preventDefault(); updatePointer(e.touches[0].clientX, e.touches[0].clientY); }, {passive:false}); | |
| canvas.addEventListener('touchend', e => { e.preventDefault(); mouse.pressed = false; }, {passive:false}); | |
| // ----------------------------- | |
| // PHONE TILT CONTROLS | |
| // ----------------------------- | |
| const tiltButton = document.getElementById('tiltButton'); | |
| let tiltEnabled = false; | |
| let tiltListening = false; | |
| let tiltNeutral = null; | |
| let tiltMove = 0; | |
| function isProbablyMobile(){ | |
| return window.matchMedia('(pointer: coarse)').matches || | |
| /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); | |
| } | |
| function getSideTilt(event){ | |
| const rawAngle = (window.screen.orientation && typeof window.screen.orientation.angle === 'number') | |
| ? window.screen.orientation.angle | |
| : (typeof window.orientation === 'number' ? window.orientation : 0); | |
| const angle = ((rawAngle % 360) + 360) % 360; | |
| if(angle === 90) return event.beta ?? 0; | |
| if(angle === 270) return -(event.beta ?? 0); | |
| if(angle === 180) return -(event.gamma ?? 0); | |
| return event.gamma ?? 0; | |
| } | |
| function handleDeviceOrientation(event){ | |
| const sideways = getSideTilt(event); | |
| if(tiltNeutral === null){ | |
| tiltNeutral = sideways; | |
| tiltMove = 0; | |
| return; | |
| } | |
| const difference = sideways - tiltNeutral; | |
| const deadZone = 4; | |
| if(Math.abs(difference) <= deadZone){ | |
| tiltMove = 0; | |
| } else { | |
| tiltMove = Math.max(-1, Math.min(1, difference / 22)); | |
| } | |
| } | |
| function startTiltListening(){ | |
| if(tiltListening) return; | |
| window.addEventListener('deviceorientation', handleDeviceOrientation, true); | |
| tiltListening = true; | |
| tiltEnabled = true; | |
| tiltNeutral = null; | |
| tiltButton.textContent = 'Tilt on'; | |
| setTimeout(() => { | |
| if(tiltEnabled) tiltButton.style.display = 'none'; | |
| }, 1200); | |
| } | |
| async function enableTiltControls(){ | |
| if(!('DeviceOrientationEvent' in window)){ | |
| tiltButton.textContent = 'Tilt unavailable'; | |
| return; | |
| } | |
| try{ | |
| if(typeof DeviceOrientationEvent.requestPermission === 'function'){ | |
| const result = await DeviceOrientationEvent.requestPermission(); | |
| if(result !== 'granted'){ | |
| tiltButton.textContent = 'Tilt blocked'; | |
| return; | |
| } | |
| } | |
| startTiltListening(); | |
| } catch(error){ | |
| tiltButton.textContent = 'Tilt blocked'; | |
| } | |
| } | |
| if(isProbablyMobile() && 'DeviceOrientationEvent' in window){ | |
| if(typeof DeviceOrientationEvent.requestPermission === 'function'){ | |
| tiltButton.style.display = 'block'; | |
| } else { | |
| startTiltListening(); | |
| } | |
| } | |
| tiltButton.addEventListener('click', enableTiltControls); | |
| // ===================================== | |
| // SOUND SETTINGS - SAVED PER USER | |
| // ===================================== | |
| const SOUND_SETTINGS_PREFIX = | |
| "rabbiBounceSound_v1_"; | |
| function getSoundSettingsKey() { | |
| const username = | |
| localStorage.getItem("username") || "guest"; | |
| return SOUND_SETTINGS_PREFIX + username; | |
| } | |
| let soundMuted = false; | |
| let soundVolume = 100; | |
| function loadSoundSettings() { | |
| try { | |
| const raw = | |
| localStorage.getItem( | |
| getSoundSettingsKey() | |
| ); | |
| if (!raw) return; | |
| const saved = | |
| JSON.parse(raw); | |
| soundMuted = | |
| saved.muted === true; | |
| if ( | |
| Number.isFinite( | |
| Number(saved.volume) | |
| ) | |
| ) { | |
| soundVolume = | |
| Math.max( | |
| 0, | |
| Math.min( | |
| 100, | |
| Number(saved.volume) | |
| ) | |
| ); | |
| } | |
| } catch(error) { | |
| // Use defaults if saved settings are damaged. | |
| } | |
| } | |
| function saveSoundSettings() { | |
| try { | |
| localStorage.setItem( | |
| getSoundSettingsKey(), | |
| JSON.stringify({ | |
| muted: soundMuted, | |
| volume: soundVolume | |
| }) | |
| ); | |
| } catch(error) {} | |
| } | |
| loadSoundSettings(); | |
| // ===================================== | |
| // GAME SOUNDS | |
| // ===================================== | |
| let audioCtx; | |
| function playBeep(freq, duration) { | |
| if ( | |
| soundMuted || | |
| soundVolume <= 0 | |
| ) { | |
| return; | |
| } | |
| if (!audioCtx) { | |
| audioCtx = | |
| new ( | |
| window.AudioContext || | |
| window.webkitAudioContext | |
| )(); | |
| } | |
| if ( | |
| audioCtx.state === "suspended" | |
| ) { | |
| audioCtx.resume().catch( | |
| function() {} | |
| ); | |
| } | |
| const osc = | |
| audioCtx.createOscillator(); | |
| const gain = | |
| audioCtx.createGain(); | |
| osc.frequency.value = freq; | |
| osc.type = "square"; | |
| const actualVolume = | |
| 0.15 * | |
| (soundVolume / 100); | |
| gain.gain.setValueAtTime( | |
| actualVolume, | |
| audioCtx.currentTime | |
| ); | |
| gain.gain.exponentialRampToValueAtTime( | |
| 0.001, | |
| audioCtx.currentTime + duration | |
| ); | |
| osc.connect(gain); | |
| gain.connect(audioCtx.destination); | |
| osc.start(); | |
| osc.stop( | |
| audioCtx.currentTime + duration | |
| ); | |
| } | |
| // ===================================== | |
| // SOUND SETTINGS BUTTON | |
| // ===================================== | |
| function createSoundSettingsUI() { | |
| const soundButton = | |
| document.createElement("button"); | |
| soundButton.type = "button"; | |
| const soundPanel = | |
| document.createElement("div"); | |
| const soundTitle = | |
| document.createElement("div"); | |
| const muteButton = | |
| document.createElement("button"); | |
| muteButton.type = "button"; | |
| const volumeLabel = | |
| document.createElement("div"); | |
| const volumeSlider = | |
| document.createElement("input"); | |
| volumeSlider.type = "range"; | |
| volumeSlider.min = "0"; | |
| volumeSlider.max = "100"; | |
| volumeSlider.step = "1"; | |
| volumeSlider.value = | |
| String(soundVolume); | |
| // ----------------------------- | |
| // SOUND BUTTON STYLE | |
| // ----------------------------- | |
| Object.assign( | |
| soundButton.style, | |
| { | |
| position: "fixed", | |
| top: "100px", | |
| right: "12px", | |
| zIndex: "2000", | |
| padding: "8px 12px", | |
| border: "none", | |
| borderRadius: "8px", | |
| fontSize: "14px", | |
| cursor: "pointer", | |
| background: "white" | |
| } | |
| ); | |
| // ----------------------------- | |
| // SOUND PANEL STYLE | |
| // ----------------------------- | |
| Object.assign( | |
| soundPanel.style, | |
| { | |
| position: "fixed", | |
| top: "142px", | |
| right: "12px", | |
| zIndex: "2001", | |
| width: "210px", | |
| padding: "14px", | |
| borderRadius: "12px", | |
| background: "white", | |
| boxShadow: | |
| "0 6px 20px rgba(0,0,0,0.3)", | |
| textAlign: "center", | |
| display: "none", | |
| touchAction: "auto" | |
| } | |
| ); | |
| Object.assign( | |
| soundTitle.style, | |
| { | |
| fontSize: "17px", | |
| fontWeight: "bold", | |
| marginBottom: "10px" | |
| } | |
| ); | |
| soundTitle.textContent = | |
| "Sound Settings"; | |
| Object.assign( | |
| muteButton.style, | |
| { | |
| padding: "7px 12px", | |
| marginBottom: "12px", | |
| border: "none", | |
| borderRadius: "7px", | |
| cursor: "pointer" | |
| } | |
| ); | |
| Object.assign( | |
| volumeLabel.style, | |
| { | |
| fontSize: "14px", | |
| marginBottom: "6px" | |
| } | |
| ); | |
| volumeSlider.style.width = | |
| "170px"; | |
| volumeSlider.style.cursor = | |
| "pointer"; | |
| function updateSoundDisplay() { | |
| soundButton.textContent = | |
| soundMuted | |
| ? "🔇 Sound" | |
| : "🔊 Sound"; | |
| muteButton.textContent = | |
| soundMuted | |
| ? "Unmute" | |
| : "Mute"; | |
| volumeLabel.textContent = | |
| "Volume: " + | |
| Math.round(soundVolume) + | |
| "%"; | |
| } | |
| // Open / close settings | |
| soundButton.addEventListener( | |
| "click", | |
| function() { | |
| soundPanel.style.display = | |
| soundPanel.style.display === | |
| "none" | |
| ? "block" | |
| : "none"; | |
| } | |
| ); | |
| // Mute / unmute | |
| muteButton.addEventListener( | |
| "click", | |
| function() { | |
| soundMuted = | |
| !soundMuted; | |
| saveSoundSettings(); | |
| updateSoundDisplay(); | |
| if (!soundMuted) { | |
| playBeep( | |
| 700, | |
| 0.08 | |
| ); | |
| } | |
| } | |
| ); | |
| // Change volume | |
| volumeSlider.addEventListener( | |
| "input", | |
| function() { | |
| soundVolume = | |
| Number( | |
| volumeSlider.value | |
| ); | |
| saveSoundSettings(); | |
| updateSoundDisplay(); | |
| } | |
| ); | |
| // Small test beep after moving slider | |
| volumeSlider.addEventListener( | |
| "change", | |
| function() { | |
| if ( | |
| !soundMuted && | |
| soundVolume > 0 | |
| ) { | |
| playBeep( | |
| 700, | |
| 0.08 | |
| ); | |
| } | |
| } | |
| ); | |
| soundPanel.appendChild( | |
| soundTitle | |
| ); | |
| soundPanel.appendChild( | |
| muteButton | |
| ); | |
| soundPanel.appendChild( | |
| volumeLabel | |
| ); | |
| soundPanel.appendChild( | |
| volumeSlider | |
| ); | |
| document.body.appendChild( | |
| soundButton | |
| ); | |
| document.body.appendChild( | |
| soundPanel | |
| ); | |
| updateSoundDisplay(); | |
| } | |
| createSoundSettingsUI(); | |
| function hitRect(px, py, r){ | |
| return px > r.x - r.w/2 && px < r.x + r.w/2 && py > r.y - r.h/2 && py < r.y + r.h/2; | |
| } | |
| const currentUsername = | |
| localStorage.getItem("username"); | |
| document.getElementById("welcomeUserGame").textContent = | |
| "Hello, " + currentUsername + "!"; | |
| const SAVE_KEY = | |
| "rabbiBounceSave_v2_" + currentUsername; | |
| // Keep the old key available so existing coins / best score can migrate once. | |
| const OLD_SAVE_KEY = | |
| "rabbiBounceSave_v1_" + currentUsername; | |
| function loadSave(){ | |
| try{ | |
| const newRaw = localStorage.getItem(SAVE_KEY); | |
| if(newRaw) return JSON.parse(newRaw); | |
| const oldRaw = localStorage.getItem(OLD_SAVE_KEY); | |
| return oldRaw ? JSON.parse(oldRaw) : {}; | |
| }catch(e){ | |
| return {}; | |
| } | |
| } | |
| const savedData = loadSave(); | |
| function saveGame(){ | |
| try{ | |
| localStorage.setItem(SAVE_KEY, JSON.stringify({ | |
| coins: coinsCollected, | |
| best, | |
| doubleJumpSeconds, | |
| slowFallSeconds, | |
| highBounceSeconds, | |
| coinMagnetSeconds, | |
| menorahsOwned, | |
| candlesOwned | |
| })); | |
| }catch(e){} | |
| } | |
| const START_BTN = {x:0, y:-180, w:220, h:60}; | |
| const HELP_BTN = {x:0, y:-240, w:180, h:48}; | |
| const BACK_BTN = {x:0, y:-220, w:220, h:60}; | |
| const HELP_PREV_BTN = {x:-360, y:30, w:64, h:64}; | |
| const HELP_NEXT_BTN = {x:360, y:30, w:64, h:64}; | |
| let helpPage = 0; | |
| let helpAnimTimer = 0; | |
| let helpLeftWasDown = false, helpRightWasDown = false, helpSpaceWasDown = false; | |
| let helpReturnState = 'title'; | |
| const PAUSE_BTN = {x:365, y:220, w:64, h:46}; | |
| const PAUSE_RESUME_BTN = {x:-140, y:-110, w:230, h:56}; | |
| const PAUSE_MENU_BTN = {x:140, y:-110, w:230, h:56}; | |
| const PAUSE_SHOP_BTN = {x:-140, y:-190, w:230, h:56}; | |
| const PAUSE_HELP_BTN = {x:140, y:-190, w:230, h:56}; | |
| const PLAY_AGAIN_BTN = {x:0, y:-80, w:240, h:60}; | |
| // Shop prices. Timed upgrades begin immediately and only count down while playing. | |
| const DOUBLE_JUMP_COST_PER_MINUTE = 8; | |
| const SLOW_FALL_COST_PER_MINUTE = 6; | |
| const HIGH_BOUNCE_COST_PER_MINUTE = 10; | |
| const COIN_MAGNET_COST_PER_MINUTE = 5; | |
| const MENORAH_COST = 150; | |
| const CANDLE_COST = 1; | |
| const MAX_CANDLES = 18; | |
| const SHOP_DOUBLE_JUMP_BTN = {x:-180, y:150, w:310, h:72}; | |
| const SHOP_SLOW_FALL_BTN = {x:180, y:150, w:310, h:72}; | |
| const SHOP_HIGH_BOUNCE_BTN = {x:-180, y:55, w:310, h:72}; | |
| const SHOP_COIN_MAGNET_BTN = {x:180, y:55, w:310, h:72}; | |
| const SHOP_MENORAH_BTN = {x:-180, y:-40, w:310, h:72}; | |
| const SHOP_CANDLES_BTN = {x:180, y:-40, w:310, h:72}; | |
| const JUMP_BTN = {x:330, y:-225, w:96, h:96}; | |
| const HELP_PAGES = [ | |
| { | |
| title: 'Purple Platform', | |
| kind: 'start', | |
| lines: [ | |
| 'This is where you begin your climb.', | |
| 'The dashed lines mark safe ground — nothing dangerous here!' | |
| ] | |
| }, | |
| { | |
| title: 'Blue Platforms', | |
| kind: 'normal', | |
| lines: [ | |
| 'Totally safe — just land on it and you bounce automatically.', | |
| 'No spikes, no movement, nothing to worry about.' | |
| ] | |
| }, | |
| { | |
| title: 'Green Platforms', | |
| kind: 'moving', | |
| lines: [ | |
| 'Safe to land on, but they slide side to side.', | |
| 'Time your jump so you land on it, not next to it!' | |
| ] | |
| }, | |
| { | |
| title: 'Yellow Platforms', | |
| kind: 'spiked', | |
| lines: [ | |
| 'Spikes on top make these dangerous to land on.', | |
| 'Bump it from below first to clear the spikes — then it\'s safe!' | |
| ] | |
| }, | |
| { | |
| title: 'Orange Platforms', | |
| kind: 'trapdoor', | |
| lines: [ | |
| 'These hide their spikes underneath instead of on top.', | |
| 'Land on top to clear it safely — don\'t hit the bottom!' | |
| ] | |
| }, | |
| { | |
| title: 'Red Platforms', | |
| kind: 'moving-spiked', | |
| lines: [ | |
| 'Like yellow platforms, but they slide side to side too.', | |
| 'Bump it from below to clear the spikes before you land.' | |
| ] | |
| }, | |
| { | |
| title: 'Gray Platforms', | |
| kind: 'allspike', | |
| lines: [ | |
| 'Spikes cover every side of this platform.', | |
| 'Never touch it at all — jump around it instead.' | |
| ] | |
| }, | |
| { | |
| title: 'Black Platforms', | |
| kind: 'allspike-moving', | |
| lines: [ | |
| 'Just as dangerous as gray platforms, and it slides side to side too.', | |
| 'Spikes on every side — never touch it, just jump around it.' | |
| ] | |
| }, | |
| { | |
| title: 'Laser Beams', | |
| kind: 'laser', | |
| lines: [ | |
| 'Red lasers switch on and off as you climb higher.', | |
| 'Watch the timing and only pass through while the beam is off.' | |
| ] | |
| } | |
| ]; | |
| const GRAVITY = 0.45, FALL_GRAVITY = 0.18, BOUNCE_V = 15, MOVE_SPEED = 6.5; | |
| const CANDY_COLORS = ['#8b2e2e','#2e4a7d','#2e6b4f','#a67c27','#5a2e6b']; | |
| const BOOK_SPINE_COLORS = ['#0e1830','#4d1a1a','#3b2415']; | |
| const CAMERA_SMOOTH = 0.08, DESIRED_SCREEN_Y = -150, START_PLATFORM_Y = -20; | |
| // The climb is pre-generated very far upward so it feels like one enormous | |
| // continuous tower. When the player gets close to the currently generated | |
| // ceiling, another equally large section is created automatically. | |
| const INITIAL_WORLD_TOP = 50000; // about 83 original 600px screens tall | |
| const WORLD_EXTENSION = 50000; // each extension adds another huge section | |
| const WORLD_EXTENSION_MARGIN = 8000; // start extending long before the player sees the top | |
| let generatedWorldTop = INITIAL_WORLD_TOP; | |
| let state = 'title'; | |
| let player, camera, platforms, nextPlatformY, score, best = savedData.best || 0, wobble = 0, baseline = 0, landingCount = 0, scoringStarted = false, lastLandedY = 0, lasers = []; | |
| let lastPlatformX = 0; | |
| let lastWasUnlandable = false; | |
| let coins = [], coinsCollected = savedData.coins || 0, coinAnimTimer = 0; | |
| const COIN_FLOAT_Y = 26, COIN_R = 11; | |
| // Timed shop upgrades and inventory are saved separately for each username. | |
| // Old banked Double Jump minutes are converted into active Double Jump time once. | |
| const migratedOldDoubleJumpSeconds = | |
| !savedData.doubleJumpSeconds && savedData.minutesBank | |
| ? Number(savedData.minutesBank) * 60 | |
| : 0; | |
| let doubleJumpSeconds = Math.max(0, Number(savedData.doubleJumpSeconds) || migratedOldDoubleJumpSeconds || 0); | |
| let slowFallSeconds = Math.max(0, Number(savedData.slowFallSeconds) || 0); | |
| let highBounceSeconds = Math.max(0, Number(savedData.highBounceSeconds) || 0); | |
| let coinMagnetSeconds = Math.max(0, Number(savedData.coinMagnetSeconds) || 0); | |
| let menorahsOwned = Math.max(0, Math.floor(Number(savedData.menorahsOwned) || 0)); | |
| let candlesOwned = Math.max(0, Math.min(MAX_CANDLES, Math.floor(Number(savedData.candlesOwned) || 0))); | |
| let doubleJumpReady = false; | |
| let jumpInputWasDownPlaying = false; | |
| let lastFrameTime = null; | |
| let lastTimedSaveAt = 0; | |
| let menorahUse = null; | |
| let menorahStatus = ''; | |
| let menorahStatusUntil = 0; | |
| // Polish / animation state | |
| let countdownStartedAt = 0; | |
| const COUNTDOWN_MS = 3200; | |
| let landingParticles = []; | |
| let gameOverStartedAt = 0; | |
| let gameOverAngle = 0; | |
| function init(){ | |
| baseline = -20 + 20/2; | |
| player = { x:0, y:baseline, vx:0, vy:0, facing:1 }; | |
| camera = { y: baseline - DESIRED_SCREEN_Y }; | |
| lastLandedY = baseline; | |
| platforms = []; | |
| nextPlatformY = -20; | |
| platforms.push({ x:0, y:-20, w:120, h:20, kind:'normal', vx:0, cleared:false, color:'#8a3fd6', start:true, meltedSpikes:[] }); | |
| lastPlatformX = 0; | |
| lastWasUnlandable = false; | |
| coins = []; | |
| lasers = []; | |
| nextLaserY = 300; | |
| // Build a huge amount of tower immediately. There is no visible top. | |
| generatedWorldTop = INITIAL_WORLD_TOP; | |
| genPlatformsUpTo(generatedWorldTop); | |
| genLasersUpTo(generatedWorldTop); | |
| score = 0; | |
| landingCount = 0; | |
| scoringStarted = false; | |
| doubleJumpReady = doubleJumpSeconds > 0; | |
| landingParticles = []; | |
| menorahUse = null; | |
| menorahStatus = ''; | |
| menorahStatusUntil = 0; | |
| gameOverAngle = 0; | |
| } | |
| function updateBest(){ | |
| if(score > best){ best = score; saveGame(); } | |
| } | |
| function spawnLandingParticles(x, y){ | |
| for(let i=0; i<12; i++){ | |
| const angle = Math.PI * (0.15 + Math.random()*0.7); | |
| const speed = 1.8 + Math.random()*3.2; | |
| landingParticles.push({ | |
| x: x + (Math.random()*18 - 9), | |
| y: y + 3, | |
| vx: Math.cos(angle) * speed, | |
| vy: Math.sin(angle) * speed, | |
| life: 28 + Math.floor(Math.random()*18), | |
| maxLife: 46, | |
| size: 2 + Math.random()*3 | |
| }); | |
| } | |
| } | |
| function updateLandingParticles(){ | |
| for(const p of landingParticles){ | |
| p.x += p.vx; | |
| p.y += p.vy; | |
| p.vy -= 0.16; | |
| p.vx *= 0.97; | |
| p.life--; | |
| } | |
| landingParticles = landingParticles.filter(p => p.life > 0); | |
| } | |
| function triggerGameOver(){ | |
| if(state === 'gameover') return; | |
| state = 'gameover'; | |
| gameOverStartedAt = performance.now(); | |
| gameOverAngle = 0; | |
| if(player) player.vx = 0; | |
| updateBest(); | |
| saveGame(); | |
| playBeep(150, 0.4); | |
| } | |
| function getBounceVelocity(){ | |
| return highBounceSeconds > 0 ? 18 : BOUNCE_V; | |
| } | |
| function getFallGravity(){ | |
| return slowFallSeconds > 0 ? 0.10 : FALL_GRAVITY; | |
| } | |
| function formatTime(seconds){ | |
| const total = Math.max(0, Math.ceil(seconds)); | |
| const mm = Math.floor(total / 60); | |
| const ss = total % 60; | |
| return mm + ':' + String(ss).padStart(2, '0'); | |
| } | |
| function attemptDoubleJump(){ | |
| if(state !== 'playing' || doubleJumpSeconds <= 0 || !doubleJumpReady || !player) return false; | |
| doubleJumpReady = false; | |
| player.vy = getBounceVelocity(); | |
| wobble = 8; | |
| playBeep(700, 0.12); | |
| setTimeout(()=>playBeep(950, 0.1), 60); | |
| return true; | |
| } | |
| function promptPositiveWholeNumber(message){ | |
| const input = window.prompt(message); | |
| if(input === null) return null; | |
| const n = Math.floor(Number(input)); | |
| if(!Number.isFinite(n) || n <= 0){ | |
| window.alert('Please enter a whole number greater than 0.'); | |
| return null; | |
| } | |
| return n; | |
| } | |
| function buyTimedUpgrade(kind, label, costPerMinute){ | |
| const minutes = promptPositiveWholeNumber( | |
| 'How many minutes of ' + label + ' do you want?\n' + | |
| costPerMinute + ' coins per minute. You have ' + coinsCollected + ' coins.' | |
| ); | |
| if(minutes === null) return; | |
| const totalCost = minutes * costPerMinute; | |
| if(totalCost > coinsCollected){ | |
| window.alert('That costs ' + totalCost + ' coins, but you only have ' + coinsCollected + '.'); | |
| playBeep(150, 0.2); | |
| return; | |
| } | |
| coinsCollected -= totalCost; | |
| const addSeconds = minutes * 60; | |
| if(kind === 'doubleJump'){ | |
| doubleJumpSeconds += addSeconds; | |
| doubleJumpReady = true; | |
| } else if(kind === 'slowFall'){ | |
| slowFallSeconds += addSeconds; | |
| } else if(kind === 'highBounce'){ | |
| highBounceSeconds += addSeconds; | |
| } else if(kind === 'coinMagnet'){ | |
| coinMagnetSeconds += addSeconds; | |
| } | |
| saveGame(); | |
| playBeep(1000, 0.15); | |
| } | |
| function buyMenorahs(){ | |
| const quantity = promptPositiveWholeNumber( | |
| 'How many menorahs do you want?\n' + | |
| MENORAH_COST + ' coins each. Menorahs are permanent and never get used up. You have ' + coinsCollected + ' coins.' | |
| ); | |
| if(quantity === null) return; | |
| const totalCost = quantity * MENORAH_COST; | |
| if(totalCost > coinsCollected){ | |
| window.alert('That costs ' + totalCost + ' coins, but you only have ' + coinsCollected + '.'); | |
| playBeep(150, 0.2); | |
| return; | |
| } | |
| coinsCollected -= totalCost; | |
| menorahsOwned += quantity; | |
| saveGame(); | |
| playBeep(1000, 0.15); | |
| } | |
| function buyCandles(){ | |
| const roomLeft = MAX_CANDLES - candlesOwned; | |
| if(roomLeft <= 0){ | |
| window.alert('You already have the maximum of ' + MAX_CANDLES + ' candles.'); | |
| playBeep(150, 0.2); | |
| return; | |
| } | |
| const quantity = promptPositiveWholeNumber( | |
| 'How many candles do you want?\n' + | |
| CANDLE_COST + ' coin each. You can buy up to ' + roomLeft + ' more.' | |
| ); | |
| if(quantity === null) return; | |
| if(quantity > roomLeft){ | |
| window.alert('You can only have ' + MAX_CANDLES + ' candles at once. You can buy ' + roomLeft + ' more right now.'); | |
| playBeep(150, 0.2); | |
| return; | |
| } | |
| const totalCost = quantity * CANDLE_COST; | |
| if(totalCost > coinsCollected){ | |
| window.alert('That costs ' + totalCost + ' coins, but you only have ' + coinsCollected + '.'); | |
| playBeep(150, 0.2); | |
| return; | |
| } | |
| coinsCollected -= totalCost; | |
| candlesOwned += quantity; | |
| saveGame(); | |
| playBeep(1000, 0.15); | |
| } | |
| // ----------------------------- | |
| // MENORAH SPIKE MELTING | |
| // ----------------------------- | |
| function setMenorahStatus(message, duration = 1500){ | |
| menorahStatus = message; | |
| menorahStatusUntil = performance.now() + duration; | |
| } | |
| function ensureMeltedSpikeList(p){ | |
| if(!Array.isArray(p.meltedSpikes)) p.meltedSpikes = []; | |
| return p.meltedSpikes; | |
| } | |
| function isSpikeMelted(p, id){ | |
| return ensureMeltedSpikeList(p).includes(id); | |
| } | |
| function getPlatformSpikeDescriptors(p){ | |
| const spikes = []; | |
| const count = 5; | |
| const startX = p.x - p.w/2 + 8; | |
| const spanW = p.w - 16; | |
| const allSides = p.kind === 'allspike' || p.kind === 'allspike-moving'; | |
| const topSpiked = ((p.kind === 'spiked' || p.kind === 'moving-spiked') && !p.cleared) || allSides; | |
| const bottomSpiked = (p.kind === 'trapdoor' && !p.cleared) || allSides; | |
| if(topSpiked){ | |
| for(let i=0; i<count; i++){ | |
| const x = startX + (spanW / (count - 1)) * i; | |
| spikes.push({ | |
| id:'top' + i, | |
| side:'top', | |
| baseX:x, | |
| baseY:p.y + p.h/2, | |
| tipX:x, | |
| tipY:p.y + p.h/2 + 12 | |
| }); | |
| } | |
| } | |
| if(bottomSpiked){ | |
| for(let i=0; i<count; i++){ | |
| const x = startX + (spanW / (count - 1)) * i; | |
| spikes.push({ | |
| id:'bottom' + i, | |
| side:'bottom', | |
| baseX:x, | |
| baseY:p.y - p.h/2, | |
| tipX:x, | |
| tipY:p.y - p.h/2 - 12 | |
| }); | |
| } | |
| } | |
| if(allSides){ | |
| spikes.push({ | |
| id:'left', side:'left', | |
| baseX:p.x - p.w/2, baseY:p.y, | |
| tipX:p.x - p.w/2 - 10, tipY:p.y | |
| }); | |
| spikes.push({ | |
| id:'right', side:'right', | |
| baseX:p.x + p.w/2, baseY:p.y, | |
| tipX:p.x + p.w/2 + 10, tipY:p.y | |
| }); | |
| } | |
| return spikes; | |
| } | |
| function remainingPlatformSpikes(p){ | |
| return getPlatformSpikeDescriptors(p).filter(spike => !isSpikeMelted(p, spike.id)); | |
| } | |
| function remainingSpikeNearX(p, side, x, tolerance = 10){ | |
| return remainingPlatformSpikes(p).some(spike => | |
| spike.side === side && Math.abs(spike.tipX - x) <= tolerance | |
| ); | |
| } | |
| function playerTouchesRemainingSpike(p){ | |
| for(const spike of remainingPlatformSpikes(p)){ | |
| // A permanent menorah is automatic: when Rabbi is descending onto TOP | |
| // spikes and has at least one candle, let him reach the platform surface. | |
| // The landing collision below will start the melting animation instead of | |
| // killing him at the spike tip. Side/bottom spikes are still dangerous. | |
| if( | |
| spike.side === 'top' && | |
| player.vy <= 0 && | |
| menorahsOwned > 0 && | |
| candlesOwned > 0 && | |
| player.x > p.x - p.w/2 - 10 && | |
| player.x < p.x + p.w/2 + 10 | |
| ){ | |
| continue; | |
| } | |
| if(spike.side === 'top' || spike.side === 'bottom'){ | |
| if(Math.abs(player.x - spike.tipX) <= 8 && Math.abs(player.y - spike.tipY) <= 11){ | |
| return true; | |
| } | |
| } else { | |
| if(Math.abs(player.x - spike.tipX) <= 10 && Math.abs(player.y - spike.tipY) <= 8){ | |
| return true; | |
| } | |
| } | |
| } | |
| return false; | |
| } | |
| function beginMenorahLanding(p, platTop){ | |
| // The menorah has NO manual activation. The only way to use it is to land | |
| // on top of a platform where dangerous top spikes are still present. | |
| if(state !== 'playing' || menorahUse || menorahsOwned <= 0 || candlesOwned <= 0){ | |
| return false; | |
| } | |
| const spikes = remainingPlatformSpikes(p) | |
| .sort((a, b) => | |
| Math.hypot(player.x - a.tipX, player.y - a.tipY) - | |
| Math.hypot(player.x - b.tipX, player.y - b.tipY) | |
| ); | |
| if(!spikes.some(spike => spike.side === 'top')) return false; | |
| const numberToMelt = Math.min(candlesOwned, spikes.length); | |
| if(numberToMelt <= 0) return false; | |
| // Menorahs last forever. ONLY candles are consumed. One candle melts one | |
| // spike, and if there are not enough candles the closest spikes melt first. | |
| candlesOwned -= numberToMelt; | |
| menorahUse = { | |
| platform: p, | |
| spikeIds: spikes.slice(0, numberToMelt).map(spike => spike.id), | |
| startedAt: performance.now(), | |
| meltedCount: 0, | |
| landingY: platTop, | |
| oldVx: player.vx | |
| }; | |
| // Rabbi has just landed. Freeze him on the platform while he bends the | |
| // menorah downward and melts the selected spikes. | |
| player.y = platTop; | |
| player.vx = 0; | |
| player.vy = 0; | |
| saveGame(); | |
| setMenorahStatus('Melting ' + numberToMelt + ' spike' + (numberToMelt === 1 ? '' : 's') + '...', 1800); | |
| playBeep(760, 0.12); | |
| return true; | |
| } | |
| function updateMenorahUse(){ | |
| if(!menorahUse) return false; | |
| pointerWasDown = mouse.pressed; | |
| const elapsed = performance.now() - menorahUse.startedAt; | |
| const firstMeltAt = 260; | |
| const perSpikeMs = 150; | |
| const total = menorahUse.spikeIds.length; | |
| const shouldBeMelted = Math.min( | |
| total, | |
| Math.max(0, Math.floor((elapsed - firstMeltAt) / perSpikeMs) + 1) | |
| ); | |
| while(menorahUse.meltedCount < shouldBeMelted){ | |
| const id = menorahUse.spikeIds[menorahUse.meltedCount]; | |
| const list = ensureMeltedSpikeList(menorahUse.platform); | |
| if(!list.includes(id)) list.push(id); | |
| menorahUse.meltedCount++; | |
| playBeep(980 + menorahUse.meltedCount * 45, 0.07); | |
| } | |
| const finishAt = firstMeltAt + total * perSpikeMs + 260; | |
| if(elapsed >= finishAt){ | |
| const landingY = menorahUse.landingY; | |
| const oldVx = menorahUse.oldVx; | |
| menorahUse = null; | |
| // Finish the landing normally after the melting animation, then bounce. | |
| player.y = landingY; | |
| player.vx = oldVx; | |
| player.vy = getBounceVelocity(); | |
| wobble = 8; | |
| spawnLandingParticles(player.x, landingY); | |
| lastLandedY = landingY; | |
| doubleJumpReady = doubleJumpSeconds > 0; | |
| landingCount++; | |
| if(landingCount === 2){ scoringStarted = true; baseline = player.y; } | |
| setMenorahStatus('Spikes melted!', 1000); | |
| playBeep(540 + Math.random()*150, 0.1); | |
| saveGame(); | |
| return false; | |
| } | |
| return true; | |
| } | |
| const LASER_UNLOCK_AMOS = 2100, HARD_START_AMOS = LASER_UNLOCK_AMOS + 300; | |
| function difficultyLevel(amos){ | |
| return amos >= HARD_START_AMOS ? Math.floor((amos - HARD_START_AMOS) / 1000) + 1 : 0; | |
| } | |
| function pickPlatformKind(amos){ | |
| const boost = 1 + difficultyLevel(amos) * 0.25; | |
| const options = [{kind:'normal', w:0.6}]; | |
| if(amos >= 300) options.push({kind:'moving', w:0.18*boost}); | |
| if(amos >= 600) options.push({kind:'spiked', w:0.16*boost}); | |
| if(amos >= 900) options.push({kind:'trapdoor', w:0.15*boost}); | |
| if(amos >= 1200) options.push({kind:'moving-spiked', w:0.14*boost}); | |
| if(amos >= 1500) options.push({kind:'allspike', w:0.13*boost}); | |
| if(amos >= 1800) options.push({kind:'allspike-moving', w:0.12*boost}); | |
| const total = options.reduce((s,o) => s + o.w, 0); | |
| let r = Math.random() * total; | |
| for(const o of options){ | |
| if(r < o.w) return o.kind; | |
| r -= o.w; | |
| } | |
| return 'normal'; | |
| } | |
| const GREEN_PLATFORM_SPEED = 2.25; | |
| function greenPlatSpeed(){ return (Math.random()<0.5 ? -1 : 1) * GREEN_PLATFORM_SPEED; } | |
| function randPlatSpeed(){ return (Math.random()<0.5?-1:1) * (1.5+Math.random()*1.5); } | |
| function platformGapAmos(amos){ | |
| if(amos < 100) return 3 + Math.random()*2.5; | |
| if(amos < 200) return 5 + Math.random()*3; | |
| return 6.875 + Math.random()*5.625; | |
| } | |
| const MILESTONE_CLEAR_AMOS = 10; // keep platforms from landing on a milestone's marker line | |
| function genPlatformsUpTo(targetY){ | |
| while(nextPlatformY < targetY){ | |
| const amosNow = nextPlatformY / 8; | |
| let gapAmos = platformGapAmos(amosNow); | |
| const level = difficultyLevel(amosNow); | |
| if(level > 0) gapAmos = Math.max(4, gapAmos - level*0.6); | |
| nextPlatformY += gapAmos * 8; | |
| let amos = nextPlatformY / 8; | |
| for(const m of MILESTONES){ | |
| const d = Math.abs(amos - m.amos); | |
| if(d < MILESTONE_CLEAR_AMOS){ | |
| // Jumping all the way to m.amos+MILESTONE_CLEAR_AMOS ignored how far | |
| // that actually was from the LAST platform (amosNow) — a candidate | |
| // that had barely entered the dead zone could get pushed to a total | |
| // gap of ~20+gapAmos amos (up to ~260px), past a single bounce's max | |
| // reach (~250px, from BOUNCE_V=15 decelerating at GRAVITY=0.45). | |
| // That's the impossible jump right at the start of each new zone. | |
| // Capping the snap to a safe reachable gap from amosNow fixes it — | |
| // it may land a bit closer to the milestone line than before, which | |
| // was only a cosmetic concern, not a gameplay one. | |
| const SAFE_MAX_GAP_AMOS = 22; // ~176px, comfortably under the ~250px max reach | |
| amos = Math.min(m.amos + MILESTONE_CLEAR_AMOS, amosNow + SAFE_MAX_GAP_AMOS); | |
| nextPlatformY = amos * 8; | |
| } | |
| } | |
| // Keep each new platform within comfortable jumping distance of the | |
| // previous one (instead of a fully independent random x) — a fully | |
| // random x could occasionally land a platform at the opposite edge of | |
| // the world right after the previous one with barely any vertical gap | |
| // to build up horizontal travel time, which felt like an impossible jump. | |
| // A pure random walk ((Math.random()*2-1)*MAX) picks a near-zero shift | |
| // fairly often — with no floor on the step size, a run of small shifts | |
| // stacks several platforms almost directly above each other, most | |
| // noticeable early on (amos < 300) where the vertical gaps are also | |
| // small, so a lot of platforms are visible on screen at once. Forcing | |
| // a minimum step keeps every platform meaningfully left/right of the | |
| // last one, while the sign stays random so the path still wanders. | |
| const MAX_PLATFORM_DX = 180, MIN_PLATFORM_DX = 70; | |
| const dxMag = MIN_PLATFORM_DX + Math.random()*(MAX_PLATFORM_DX-MIN_PLATFORM_DX); | |
| let dir = Math.random()<0.5 ? -1 : 1; | |
| // The playable left/right edge now follows the actual screen width. | |
| // Keep a small margin so platforms never spawn half off-screen. | |
| const platformEdge = Math.max(320, WORLD_HALF - 80); | |
| // Near a world edge, reflect the next step inward instead of clamping it | |
| // back onto the same x position. | |
| if(lastPlatformX + dir*dxMag < -platformEdge || lastPlatformX + dir*dxMag > platformEdge) dir = -dir; | |
| let x = lastPlatformX + dir*dxMag; | |
| x = Math.max(-platformEdge, Math.min(platformEdge, x)); | |
| lastPlatformX = x; | |
| let kind = pickPlatformKind(amos); | |
| // allspike/allspike-moving can never be landed on (touching = death), so | |
| // never let two of them land back-to-back — that would force the player | |
| // to clear two gaps in a single jump, which can exceed max jump height. | |
| if((kind === 'allspike' || kind === 'allspike-moving') && lastWasUnlandable) kind = 'normal'; | |
| lastWasUnlandable = (kind === 'allspike' || kind === 'allspike-moving'); | |
| let vx = 0, w = 72, color = '#3f8fd6'; | |
| if(kind === 'moving'){ vx = greenPlatSpeed(); color = '#3fae4a'; } | |
| else if(kind === 'spiked'){ color = '#3f8fd6'; } | |
| else if(kind === 'trapdoor'){ color = '#3f8fd6'; } | |
| else if(kind === 'moving-spiked'){ vx = randPlatSpeed(); color = '#3fae4a'; } | |
| else if(kind === 'allspike'){ color = '#8a8a92'; } | |
| else if(kind === 'allspike-moving'){ vx = randPlatSpeed(); color = '#2a2a30'; } | |
| const plat = { x, y: nextPlatformY, w, h:18, kind, vx, cleared:false, color, meltedSpikes:[] }; | |
| platforms.push(plat); | |
| // Coins only sit on kinds that are safe to stand on right away — a coin | |
| // hovering over a spike platform would either be unreachable or bait the | |
| // player into a hazard, neither of which is fun. | |
| if((kind === 'normal' || kind === 'moving') && Math.random() < 0.35){ | |
| coins.push({ platform: plat, dx: (Math.random()*2-1)*(w/2-14), taken:false, bobPhase: Math.random()*Math.PI*2, spinPhase: Math.random()*Math.PI*2 }); | |
| } | |
| } | |
| } | |
| function laserGapAmos(amos){ | |
| const sinceUnlock = amos - LASER_UNLOCK_AMOS; | |
| let gap; | |
| if(sinceUnlock < 100) gap = 60 + Math.random()*35; | |
| else if(sinceUnlock < 200) gap = 25 + Math.random()*15; | |
| else gap = 12 + Math.random()*10; | |
| const level = difficultyLevel(amos); | |
| if(level > 0) gap = Math.max(6, gap - level*2); | |
| return gap; | |
| } | |
| let nextLaserY = 300; | |
| function genLasersUpTo(targetY){ | |
| while(nextLaserY < targetY){ | |
| const amosNow = nextLaserY / 8; | |
| nextLaserY += laserGapAmos(amosNow) * 8; | |
| if(nextLaserY / 8 >= LASER_UNLOCK_AMOS){ | |
| lasers.push({ y: nextLaserY, timer: Math.random()*160 }); | |
| } | |
| } | |
| } | |
| // Keep a giant generated region above the player at all times. If the camera | |
| // approaches the current generated ceiling, extend the tower by another huge | |
| // chunk before that ceiling could ever become visible. | |
| function ensureTallWorld(){ | |
| if(!camera) return; | |
| const topOfView = camera.y + VIEW_HALF_HEIGHT; | |
| const safetyMargin = Math.max( | |
| WORLD_EXTENSION_MARGIN, | |
| visibleWorldHeight * 4 | |
| ); | |
| while(generatedWorldTop - topOfView < safetyMargin){ | |
| generatedWorldTop += WORLD_EXTENSION; | |
| } | |
| genPlatformsUpTo(generatedWorldTop); | |
| genLasersUpTo(generatedWorldTop); | |
| } | |
| let pointerWasDown = false; | |
| function update(){ | |
| syncResponsiveUi(); | |
| if(state === 'title'){ | |
| const clicked = mouse.pressed && !pointerWasDown; | |
| pointerWasDown = mouse.pressed; | |
| if(keys[' ']){ startGame(); return; } | |
| if(clicked){ | |
| if(hitRect(mouse.x, mouse.y, START_BTN)) startGame(); | |
| else if(hitRect(mouse.x, mouse.y, HELP_BTN)){ state = 'help'; helpPage = 0; helpReturnState = 'title'; } | |
| } | |
| return; | |
| } | |
| if(state === 'countdown'){ | |
| pointerWasDown = mouse.pressed; | |
| const elapsed = performance.now() - countdownStartedAt; | |
| if(elapsed >= COUNTDOWN_MS){ | |
| state = 'playing'; | |
| jumpInputWasDownPlaying = false; | |
| playBeep(980, 0.10); | |
| } | |
| return; | |
| } | |
| if(state === 'help'){ | |
| const clicked = mouse.pressed && !pointerWasDown; | |
| pointerWasDown = mouse.pressed; | |
| helpAnimTimer++; | |
| if(clicked){ | |
| if(hitRect(mouse.x, mouse.y, BACK_BTN)) state = helpReturnState; | |
| else if(hitRect(mouse.x, mouse.y, HELP_PREV_BTN)) helpPage = Math.max(0, helpPage - 1); | |
| else if(hitRect(mouse.x, mouse.y, HELP_NEXT_BTN)) helpPage = Math.min(HELP_PAGES.length - 1, helpPage + 1); | |
| } | |
| const leftPressed = keys['ArrowLeft'] && !helpLeftWasDown; | |
| const rightPressed = keys['ArrowRight'] && !helpRightWasDown; | |
| const spacePressed = keys[' '] && !helpSpaceWasDown; | |
| helpLeftWasDown = keys['ArrowLeft']; | |
| helpRightWasDown = keys['ArrowRight']; | |
| helpSpaceWasDown = keys[' ']; | |
| if(leftPressed) helpPage = Math.max(0, helpPage - 1); | |
| if(rightPressed || spacePressed) helpPage = Math.min(HELP_PAGES.length - 1, helpPage + 1); | |
| return; | |
| } | |
| if(state === 'gameover'){ | |
| const clicked = mouse.pressed && !pointerWasDown; | |
| pointerWasDown = mouse.pressed; | |
| const elapsed = performance.now() - gameOverStartedAt; | |
| if(elapsed < 700){ | |
| gameOverAngle += 0.055; | |
| } | |
| updateLandingParticles(); | |
| if( | |
| elapsed > 700 && | |
| (keys[' '] || (clicked && hitRect(mouse.x, mouse.y, PLAY_AGAIN_BTN))) | |
| ){ | |
| startGame(); | |
| } | |
| return; | |
| } | |
| if(state === 'paused'){ | |
| const clicked = mouse.pressed && !pointerWasDown; | |
| pointerWasDown = mouse.pressed; | |
| if(clicked){ | |
| if(hitRect(mouse.x, mouse.y, PAUSE_RESUME_BTN)) state = 'playing'; | |
| else if(hitRect(mouse.x, mouse.y, PAUSE_MENU_BTN)) state = 'title'; | |
| else if(hitRect(mouse.x, mouse.y, PAUSE_SHOP_BTN)) state = 'shop'; | |
| else if(hitRect(mouse.x, mouse.y, PAUSE_HELP_BTN)){ state = 'help'; helpPage = 0; helpReturnState = 'paused'; } | |
| } | |
| return; | |
| } | |
| if(state === 'shop'){ | |
| const clicked = mouse.pressed && !pointerWasDown; | |
| pointerWasDown = mouse.pressed; | |
| if(clicked){ | |
| if(hitRect(mouse.x, mouse.y, BACK_BTN)){ | |
| state = 'paused'; | |
| } else if(hitRect(mouse.x, mouse.y, SHOP_DOUBLE_JUMP_BTN)){ | |
| buyTimedUpgrade('doubleJump', 'Double Jump', DOUBLE_JUMP_COST_PER_MINUTE); | |
| } else if(hitRect(mouse.x, mouse.y, SHOP_SLOW_FALL_BTN)){ | |
| buyTimedUpgrade('slowFall', 'Slower Falling', SLOW_FALL_COST_PER_MINUTE); | |
| } else if(hitRect(mouse.x, mouse.y, SHOP_HIGH_BOUNCE_BTN)){ | |
| buyTimedUpgrade('highBounce', 'Higher Bounce', HIGH_BOUNCE_COST_PER_MINUTE); | |
| } else if(hitRect(mouse.x, mouse.y, SHOP_COIN_MAGNET_BTN)){ | |
| buyTimedUpgrade('coinMagnet', 'Coin Magnet', COIN_MAGNET_COST_PER_MINUTE); | |
| } else if(hitRect(mouse.x, mouse.y, SHOP_MENORAH_BTN)){ | |
| buyMenorahs(); | |
| } else if(hitRect(mouse.x, mouse.y, SHOP_CANDLES_BTN)){ | |
| buyCandles(); | |
| } | |
| } | |
| return; | |
| } | |
| // playing | |
| if(menorahUse){ | |
| updateMenorahUse(); | |
| return; | |
| } | |
| const pauseClicked = mouse.pressed && !pointerWasDown; | |
| pointerWasDown = mouse.pressed; | |
| if(pauseClicked && hitRect(mouse.x, mouse.y, PAUSE_BTN)){ state = 'paused'; return; } | |
| if(pauseClicked && hitRect(mouse.x, mouse.y, JUMP_BTN)){ attemptDoubleJump(); } | |
| const jumpInputDown = keys[' '] || keys['w'] || keys['W'] || keys['ArrowUp']; | |
| const jumpInputEdge = jumpInputDown && !jumpInputWasDownPlaying; | |
| jumpInputWasDownPlaying = jumpInputDown; | |
| if(jumpInputEdge) attemptDoubleJump(); | |
| let dir = 0; | |
| if(keys['ArrowLeft'] || keys['a'] || keys['A']) dir = -1; | |
| if(keys['ArrowRight'] || keys['d'] || keys['D']) dir = 1; | |
| const overUiButton = hitRect(mouse.x, mouse.y, PAUSE_BTN) || | |
| hitRect(mouse.x, mouse.y, JUMP_BTN); | |
| if(mouse.pressed && !overUiButton){ | |
| dir = mouse.x < 0 ? -1 : 1; | |
| } else if(dir === 0 && tiltEnabled){ | |
| dir = tiltMove; | |
| } | |
| if(dir !== 0) player.facing = dir < 0 ? -1 : 1; | |
| // Smooth horizontal movement instead of instantly snapping to full speed. | |
| const targetVx = dir * MOVE_SPEED; | |
| const accel = dir === 0 ? 0.18 : 0.26; | |
| player.vx += (targetVx - player.vx) * accel; | |
| if(dir === 0 && Math.abs(player.vx) < 0.05) player.vx = 0; | |
| player.x += player.vx; | |
| if(player.x > WORLD_HALF) player.x = -WORLD_HALF; | |
| if(player.x < -WORLD_HALF) player.x = WORLD_HALF; | |
| updateLandingParticles(); | |
| coinAnimTimer++; | |
| const COIN_COLLECT_R = coinMagnetSeconds > 0 ? 110 : 24; | |
| for(const c of coins){ | |
| if(c.taken) continue; | |
| const cx = c.platform.x + c.dx; | |
| const cy = c.platform.y + c.platform.h/2 + COIN_FLOAT_Y; | |
| if(Math.hypot(player.x - cx, player.y - cy) < COIN_COLLECT_R){ | |
| c.taken = true; | |
| coinsCollected++; | |
| saveGame(); | |
| playBeep(1100, 0.08, 'sine', 0.2); | |
| setTimeout(()=>playBeep(1400, 0.1, 'sine', 0.18), 60); | |
| } | |
| } | |
| player.vy -= (player.vy > 0 ? GRAVITY : getFallGravity()); | |
| const prevY = player.y; | |
| player.y += player.vy; | |
| // Only the individual spikes that are still present are dangerous. | |
| // Melted spikes leave real safe gaps that Rabbi can use. | |
| for(const p of platforms){ | |
| if(playerTouchesRemainingSpike(p)){ | |
| triggerGameOver(); | |
| return; | |
| } | |
| } | |
| for(const l of lasers){ | |
| l.timer++; | |
| const phase = l.timer % 170; | |
| l.on = phase < 75; | |
| if(l.on && Math.abs(player.y - l.y) < 8){ | |
| triggerGameOver(); | |
| return; | |
| } | |
| } | |
| if(player.vy > 0){ | |
| for(const p of platforms){ | |
| if((p.kind === 'spiked' || p.kind === 'moving-spiked') && !p.cleared){ | |
| const platBottom = p.y - p.h/2; | |
| if(prevY <= platBottom && player.y >= platBottom && | |
| player.x > p.x - p.w/2 - 10 && player.x < p.x + p.w/2 + 10){ | |
| p.cleared = true; | |
| playBeep(800, 0.08); | |
| } | |
| } | |
| if(p.kind === 'trapdoor' && !p.cleared){ | |
| const platBottom = p.y - p.h/2; | |
| if(prevY <= platBottom && player.y >= platBottom && | |
| player.x > p.x - p.w/2 - 10 && player.x < p.x + p.w/2 + 10 && | |
| remainingSpikeNearX(p, 'bottom', player.x, 10)){ | |
| triggerGameOver(); | |
| return; | |
| } | |
| } | |
| } | |
| } | |
| if(player.vy <= 0){ | |
| for(const p of platforms){ | |
| const platTop = p.y + p.h/2; | |
| if(prevY >= platTop && player.y <= platTop && | |
| player.x > p.x - p.w/2 - 10 && player.x < p.x + p.w/2 + 10){ | |
| player.y = platTop; | |
| if(remainingSpikeNearX(p, 'top', player.x, 10)){ | |
| // Landing on a spiked platform is the ONLY way the menorah activates. | |
| // If Rabbi owns a permanent menorah and has candles, melt the nearest | |
| // spikes first. Otherwise this landing is still fatal. | |
| if(beginMenorahLanding(p, platTop)){ | |
| return; | |
| } | |
| triggerGameOver(); | |
| break; | |
| } | |
| if(p.kind === 'trapdoor') p.cleared = true; | |
| player.vy = getBounceVelocity(); | |
| wobble = 8; | |
| spawnLandingParticles(player.x, platTop); | |
| lastLandedY = platTop; | |
| doubleJumpReady = doubleJumpSeconds > 0; | |
| playBeep(500 + Math.random()*200, 0.1); | |
| landingCount++; | |
| if(landingCount === 2){ scoringStarted = true; baseline = player.y; } | |
| break; | |
| } | |
| } | |
| } | |
| for(const p of platforms){ | |
| if(p.kind === 'moving' || p.kind === 'moving-spiked' || p.kind === 'allspike-moving'){ | |
| p.x += p.vx; | |
| if(p.x > WORLD_HALF - p.w/2 || p.x < -WORLD_HALF + p.w/2) p.vx *= -1; | |
| } | |
| } | |
| const fallingBelowPlatform = player.vy < 0 && player.y < lastLandedY; | |
| if(fallingBelowPlatform){ | |
| // Track the rabbi exactly while he's falling — any smoothing here lets | |
| // him fall faster than the camera scrolls and he visually outruns it. | |
| camera.y = player.y - DESIRED_SCREEN_Y; | |
| } else { | |
| const targetCameraY = lastLandedY - DESIRED_SCREEN_Y; | |
| camera.y += (targetCameraY - camera.y) * CAMERA_SMOOTH; | |
| } | |
| // The tower is intentionally enormous. As the player approaches the highest | |
| // generated section, silently create tens of thousands more pixels above it. | |
| ensureTallWorld(); | |
| if(scoringStarted) score = Math.max(score, Math.floor((player.y - baseline) / 8)); | |
| if(player.y < START_PLATFORM_Y - 30){ | |
| triggerGameOver(); | |
| } | |
| if(wobble > 0) wobble--; | |
| } | |
| function drawLandingParticles(){ | |
| for(const p of landingParticles){ | |
| const screenY = p.y - camera.y; | |
| const alpha = Math.max(0, Math.min(1, p.life / p.maxLife)); | |
| ctx.save(); | |
| ctx.globalAlpha = alpha; | |
| screen.drawCircle(p.x, screenY, p.size, '#fff0b3'); | |
| ctx.restore(); | |
| } | |
| } | |
| function drawLimb(x1, y1, angleDeg, length, thickness, color){ | |
| const rad = angleDeg * Math.PI / 180; | |
| const dx = Math.cos(rad), dy = Math.sin(rad); | |
| for(let i=0; i<=4; i++){ | |
| const t = i/4; | |
| screen.drawCircle(x1 + dx*length*t, y1 + dy*length*t, thickness/2, color); | |
| } | |
| } | |
| function drawHeldMenorah(rabbiX, rabbiBaseY, facing){ | |
| if(!menorahUse || !player) return; | |
| const bodyY = rabbiBaseY + 31; | |
| const handX = rabbiX + facing * 18; | |
| const handY = bodyY - 9; | |
| const menorahX = handX + facing * 4; | |
| const menorahY = bodyY - 28; | |
| // Rabbi lowers one arm and holds the menorah beneath him toward the platform. | |
| drawLimb(rabbiX + facing*14, bodyY + 5, 258, 26, 5, '#2b2b3a'); | |
| screen.drawCircle(handX, handY - 13, 4, '#f0c8a0'); | |
| const gold = '#f2c230'; | |
| screen.drawRect(menorahX, menorahY, 4, 28, gold); | |
| screen.drawRect(menorahX, menorahY - 14, 24, 4, gold); | |
| screen.drawRect(menorahX, menorahY + 10, 30, 3, gold); | |
| screen.drawRect(menorahX - 12, menorahY + 5, 3, 12, gold); | |
| screen.drawRect(menorahX + 12, menorahY + 5, 3, 12, gold); | |
| screen.drawRect(menorahX - 6, menorahY + 8, 3, 9, gold); | |
| screen.drawRect(menorahX + 6, menorahY + 8, 3, 9, gold); | |
| // Little flames while it is actively melting. | |
| for(const fx of [-12,-6,0,6,12]){ | |
| screen.drawCircle(menorahX + fx, menorahY + 13, 2.3, '#ffb347'); | |
| } | |
| const currentIndex = Math.min(menorahUse.meltedCount, menorahUse.spikeIds.length - 1); | |
| const currentId = menorahUse.spikeIds[currentIndex]; | |
| const spike = getPlatformSpikeDescriptors(menorahUse.platform).find(item => item.id === currentId); | |
| if(spike){ | |
| const targetY = spike.tipY - camera.y; | |
| ctx.save(); | |
| ctx.strokeStyle = '#ffcf40'; | |
| ctx.lineWidth = 3; | |
| ctx.globalAlpha = 0.85; | |
| ctx.beginPath(); | |
| ctx.moveTo(menorahX, menorahY - 8); | |
| ctx.lineTo(spike.tipX, targetY); | |
| ctx.stroke(); | |
| ctx.restore(); | |
| screen.drawCircle(spike.tipX, targetY, 7 + Math.sin(performance.now()*0.025)*2, '#ff9f3f88'); | |
| } | |
| } | |
| function drawRabbi(x, y, facing){ | |
| const baseY = y; | |
| y += 31; | |
| screen.drawRect(x-9, y-20, 5, 22, '#2b2b3a'); | |
| screen.drawRect(x+9, y-20, 5, 22, '#2b2b3a'); | |
| screen.drawCircle(x-9, y-31, 6, '#1a1a24'); | |
| screen.drawCircle(x+9, y-31, 6, '#1a1a24'); | |
| screen.drawCircle(x, y, 17 + wobble*0.3, '#2b2b3a'); | |
| drawLimb(x-20, y+6, 270, 16, 5, '#2b2b3a'); | |
| drawLimb(x+20, y+6, 270, 16, 5, '#2b2b3a'); | |
| screen.drawCircle(x, y+24, 12, '#f0c8a0'); | |
| screen.drawCircle(x, y+13, 8, '#e8e8e8'); | |
| screen.drawCircle(x-7, y+13, 6, '#e8e8e8'); | |
| screen.drawCircle(x+7, y+13, 6, '#e8e8e8'); | |
| screen.drawCircle(x + facing*4, y+26, 1.8, '#2d3142'); | |
| screen.drawCircle(x - facing*4, y+26, 1.8, '#2d3142'); | |
| screen.drawRect(x, y+36, 26, 4, '#161616'); | |
| screen.drawCircle(x, y+42, 9, '#161616'); | |
| screen.drawRect(x, y+38, 20, 2, '#3a3a3a'); | |
| if(menorahUse && state === 'playing' && player && Math.abs(x - player.x) < 0.5){ | |
| drawHeldMenorah(x, baseY, facing); | |
| } | |
| } | |
| function drawSpikeRow(cx, edgeY, w, dir, color){ | |
| const spikeCount = 5; | |
| const startX = cx - w/2 + 8; | |
| const spanW = w - 16; | |
| for(let i=0; i<spikeCount; i++){ | |
| const sx = startX + (spanW / (spikeCount-1)) * i; | |
| ctx.beginPath(); | |
| ctx.moveTo(sx - 6, edgeY); | |
| ctx.lineTo(sx + 6, edgeY); | |
| ctx.lineTo(sx, edgeY + dir*12); | |
| ctx.closePath(); | |
| ctx.fillStyle = color; | |
| ctx.fill(); | |
| ctx.strokeStyle = '#8f939c'; | |
| ctx.lineWidth = 1; | |
| ctx.stroke(); | |
| } | |
| } | |
| function drawSpikeCol(edgeX, cy, h, dir, color, spikeCount){ | |
| spikeCount = spikeCount || 3; | |
| const startY = cy - h/2 + 6; | |
| const spanH = h - 12; | |
| for(let i=0; i<spikeCount; i++){ | |
| const sy = spikeCount === 1 ? cy : startY + (spanH / (spikeCount-1)) * i; | |
| ctx.beginPath(); | |
| ctx.moveTo(edgeX, sy - 5); | |
| ctx.lineTo(edgeX, sy + 5); | |
| ctx.lineTo(edgeX + dir*10, sy); | |
| ctx.closePath(); | |
| ctx.fillStyle = color; | |
| ctx.fill(); | |
| ctx.strokeStyle = '#8f939c'; | |
| ctx.lineWidth = 1; | |
| ctx.stroke(); | |
| } | |
| } | |
| // Camera-independent preview art for the help pages — draws a platform | |
| // sample at a fixed screen position (no character), reusing the same | |
| // colors/spike logic as the real in-game platforms for visual consistency. | |
| function drawHelpPlatformPreview(kind, cx, cy){ | |
| const isStart = kind === 'start'; | |
| // The real start platform (see init()) is wider than every other kind's | |
| // 72x18 — so previewing it at the generic size made it look the same | |
| // proportions as everything else instead of the bigger, safer starting | |
| // ledge it actually is. | |
| const w = isStart ? 118 : 72; | |
| const h = 18; | |
| const SCALE = 1.7; | |
| ctx.save(); | |
| ctx.translate(cx, cy); | |
| ctx.scale(SCALE, SCALE); | |
| ctx.translate(-cx, -cy); | |
| const allSides = kind === 'allspike' || kind === 'allspike-moving'; | |
| const topSpiked = kind === 'spiked' || kind === 'moving-spiked' || allSides; | |
| const bottomSpiked = kind === 'trapdoor' || allSides; | |
| let color = '#3f8fd6'; | |
| if(kind === 'start') color = '#8a3fd6'; | |
| if(kind === 'moving') color = '#3fae4a'; | |
| if(kind === 'spiked') color = '#f2c230'; | |
| if(kind === 'moving-spiked') color = '#e0392f'; | |
| if(kind === 'trapdoor') color = '#ff8c1a'; | |
| if(kind === 'allspike') color = '#8a8a92'; | |
| if(kind === 'allspike-moving') color = '#2a2a30'; | |
| screen.drawRect(cx, cy, w, h, color); | |
| screen.drawRect(cx, cy+h/2-2, w, 3, '#00000033'); | |
| screen.drawRect(cx, cy-h/2+2, w, 3, '#ffffff22'); | |
| screen.drawRect(cx - w/2 + 5, cy, 4, h - 5, '#fdf6e3'); | |
| if(topSpiked) drawSpikeRow(cx, cy + h/2, w, 1, '#d8dbe0'); | |
| if(bottomSpiked) drawSpikeRow(cx, cy - h/2, w, -1, '#d8dbe0'); | |
| if(allSides){ | |
| drawSpikeCol(cx - w/2, cy, h, -1, '#d8dbe0', 1); | |
| drawSpikeCol(cx + w/2, cy, h, 1, '#d8dbe0', 1); | |
| } | |
| ctx.restore(); | |
| } | |
| // A real embedded video isn't possible in this single-file game, so the | |
| // laser help page gets an animated on/off preview instead — the closest | |
| // achievable equivalent, cycling on the same timing as the real hazard. | |
| function drawHelpLaserPreview(cx, cy){ | |
| const phase = Math.floor(helpAnimTimer) % 170; | |
| const on = phase < 75; | |
| screen.drawRect(cx, cy+38, 280, 14, '#3a2a1a'); | |
| screen.drawRect(cx, cy-38, 280, 14, '#3a2a1a'); | |
| if(on){ | |
| screen.drawRect(cx, cy, 280, 10, '#ff3a3a'); | |
| screen.drawRect(cx, cy, 280, 4, '#ffe0a0'); | |
| } else { | |
| screen.drawRect(cx, cy, 280, 2, '#ff3a3a55'); | |
| } | |
| screen.drawText(on ? 'ON — danger!' : 'off — safe to pass', cx, cy-62, 15, on ? '#ff5c5c' : '#5fc768'); | |
| } | |
| // Each line's color marks the kind that was just introduced in the zone | |
| // you're finishing (e.g. the 1800 line closes out the 1500-1800 zone, | |
| // where gray/allspike platforms start appearing) — 1800 was set to purple, | |
| // which didn't match anything; gray (matching allspike) is correct. | |
| const MILESTONES = [ | |
| {amos:300, color:'#3f8fd6'}, | |
| {amos:600, color:'#3fae4a'}, | |
| {amos:900, color:'#f2c230'}, | |
| {amos:1200, color:'#ff8c1a'}, | |
| {amos:1500, color:'#e0392f'}, | |
| {amos:1800, color:'#8a8a92'}, | |
| {amos:2100, color:'#2a2a30'} | |
| ]; | |
| function drawMilestone(amos, color){ | |
| const worldY = amos * 8; | |
| const y = worldY - camera.y; | |
| ctx.save(); | |
| ctx.strokeStyle = color; | |
| ctx.lineWidth = 3; | |
| ctx.setLineDash([8, 8]); | |
| ctx.beginPath(); | |
| ctx.moveTo(-WORLD_HALF, y); | |
| ctx.lineTo(WORLD_HALF, y); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.restore(); | |
| screen.drawText(amos + ' amos', 0, y + 16, 18, color); | |
| } | |
| function drawSinglePlatformSpike(spike){ | |
| const baseY = spike.baseY - camera.y; | |
| const tipY = spike.tipY - camera.y; | |
| ctx.beginPath(); | |
| if(spike.side === 'top' || spike.side === 'bottom'){ | |
| ctx.moveTo(spike.baseX - 6, baseY); | |
| ctx.lineTo(spike.baseX + 6, baseY); | |
| ctx.lineTo(spike.tipX, tipY); | |
| } else { | |
| ctx.moveTo(spike.baseX, baseY - 5); | |
| ctx.lineTo(spike.baseX, baseY + 5); | |
| ctx.lineTo(spike.tipX, tipY); | |
| } | |
| ctx.closePath(); | |
| ctx.fillStyle = '#d8dbe0'; | |
| ctx.fill(); | |
| ctx.strokeStyle = '#8f939c'; | |
| ctx.lineWidth = 1; | |
| ctx.stroke(); | |
| } | |
| function drawPlatform(p){ | |
| const y = p.y - camera.y; | |
| const allSides = p.kind === 'allspike' || p.kind === 'allspike-moving'; | |
| const remaining = remainingPlatformSpikes(p); | |
| const topSpiked = remaining.some(spike => spike.side === 'top'); | |
| const bottomSpiked = remaining.some(spike => spike.side === 'bottom'); | |
| let color = p.color; | |
| if(topSpiked && !allSides) color = p.kind === 'moving-spiked' ? '#e0392f' : '#f2c230'; | |
| if(p.kind === 'trapdoor' && bottomSpiked) color = '#ff8c1a'; | |
| screen.drawRect(p.x, y, p.w, p.h, color); | |
| screen.drawRect(p.x, y+p.h/2-2, p.w, 3, '#00000033'); | |
| screen.drawRect(p.x, y-p.h/2+2, p.w, 3, '#ffffff22'); | |
| screen.drawRect(p.x - p.w/2 + 5, y, 4, p.h - 5, '#fdf6e3'); | |
| for(const spike of remaining){ | |
| drawSinglePlatformSpike(spike); | |
| } | |
| if(p.start){ | |
| ctx.save(); | |
| ctx.strokeStyle = '#8a3fd6'; | |
| ctx.lineWidth = 3; | |
| ctx.setLineDash([8, 8]); | |
| ctx.beginPath(); | |
| ctx.moveTo(p.x - p.w/2, y); | |
| ctx.lineTo(-WORLD_HALF, y); | |
| ctx.moveTo(p.x + p.w/2, y); | |
| ctx.lineTo(WORLD_HALF, y); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.restore(); | |
| } | |
| } | |
| function drawCoin(c){ | |
| if(c.taken) return; | |
| const cx = c.platform.x + c.dx; | |
| const cy = c.platform.y + c.platform.h/2 + COIN_FLOAT_Y + Math.sin(coinAnimTimer*0.05 + c.bobPhase)*4; | |
| const y = cy - camera.y; | |
| // Squashing the horizontal radius with a cosine wave (while the outline | |
| // stays a full ellipse) is a cheap 2D stand-in for a coin spinning on its | |
| // vertical axis — same trick as a classic Mario-style coin. | |
| const spinScale = Math.abs(Math.cos(coinAnimTimer*0.08 + c.spinPhase)); | |
| const rx = Math.max(2, COIN_R * spinScale); | |
| ctx.save(); | |
| ctx.translate(cx, y); | |
| ctx.beginPath(); | |
| ctx.ellipse(0, 0, rx, COIN_R, 0, 0, Math.PI*2); | |
| ctx.fillStyle = '#f2c230'; | |
| ctx.fill(); | |
| ctx.strokeStyle = '#c89a1a'; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| if(spinScale > 0.5){ | |
| ctx.fillStyle = '#ffe27a'; | |
| ctx.beginPath(); | |
| ctx.ellipse(0, 0, rx*0.5, COIN_R*0.5, 0, 0, Math.PI*2); | |
| ctx.fill(); | |
| } | |
| ctx.restore(); | |
| } | |
| function drawLaser(l){ | |
| const y = l.y - camera.y; | |
| const laserWidth = Math.max(770, visibleWorldWidth - 30); | |
| if(l.on){ | |
| screen.drawRect(0, y, laserWidth, 10, '#ff3a3a'); | |
| screen.drawRect(0, y, laserWidth, 4, '#ffe0a0'); | |
| } else { | |
| screen.drawRect(0, y, laserWidth, 2, '#ff3a3a55'); | |
| } | |
| } | |
| function drawLibraryBackground(){ | |
| // Draw a procedurally infinite bookcase that is ANCHORED TO THE GAME WORLD. | |
| // The old version moved the shelves at 40% of camera speed and then wrapped | |
| // them with modulo math. That created the strange drifting / popping effect | |
| // visible while climbing. This version moves the shelves at exactly the same | |
| // rate as platforms and Rabbi, and determines each row from its absolute | |
| // world position, so rows never teleport or change pattern when they recycle. | |
| ctx.save(); | |
| ctx.setTransform(1, 0, 0, 1, 0, 0); | |
| const px = viewScale; | |
| const bw = 40 * px; | |
| const bh = 140 * px; | |
| const shelfGapWorld = 150; | |
| const shelfThickness = Math.max(1, 10 * px); | |
| // Wood backing covers every physical pixel of the canvas. | |
| ctx.fillStyle = '#7a5233'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| // Find the actual world-space range currently visible on screen. | |
| const visibleTopWorld = camera.y + VIEW_HALF_HEIGHT; | |
| const visibleBottomWorld = camera.y - VIEW_HALF_HEIGHT; | |
| // Include extra rows just beyond the screen edges so there can never be a gap | |
| // during scrolling or resizing. | |
| const firstRow = Math.floor(visibleBottomWorld / shelfGapWorld) - 2; | |
| const lastRow = Math.ceil(visibleTopWorld / shelfGapWorld) + 2; | |
| const bookCount = Math.ceil(canvas.width / bw) + 2; | |
| for(let worldRow = firstRow; worldRow <= lastRow; worldRow++){ | |
| const shelfWorldY = worldRow * shelfGapWorld; | |
| // Convert the shelf's permanent world Y coordinate into a physical canvas Y. | |
| // World Y increases upward; canvas Y increases downward. | |
| const shelfY = canvas.height / 2 - (shelfWorldY - camera.y) * px; | |
| let prevCi = -1; | |
| for(let col = -1; col < bookCount; col++){ | |
| const x = col * bw; | |
| // Keep the horizontal book pattern IDENTICAL on every shelf row. | |
| // The previous version included worldRow in this calculation, which meant | |
| // each new shelf row started with a different color arrangement. While | |
| // climbing, that made the whole background look like it was changing every | |
| // few amos. Using only the permanent column number keeps the book pattern | |
| // visually locked while the shelves scroll naturally through the world. | |
| let ci = (col * 2) % BOOK_SPINE_COLORS.length; | |
| if(ci < 0) ci += BOOK_SPINE_COLORS.length; | |
| if(ci === prevCi) ci = (ci + 1) % BOOK_SPINE_COLORS.length; | |
| prevCi = ci; | |
| // Books sit immediately above each shelf. | |
| const bookTop = shelfY - bh; | |
| ctx.fillStyle = BOOK_SPINE_COLORS[ci]; | |
| ctx.fillRect(x, bookTop, bw + 1, bh); | |
| // Spine shadow. | |
| ctx.fillStyle = '#00000030'; | |
| ctx.fillRect( | |
| x + bw - Math.max(1, 2 * px), | |
| bookTop, | |
| Math.max(1, 2 * px), | |
| bh | |
| ); | |
| // Spine highlight. | |
| ctx.fillStyle = '#ffffff30'; | |
| ctx.fillRect( | |
| x + 4 * px, | |
| bookTop + 6 * px, | |
| Math.max(1, bw - 8 * px), | |
| Math.max(1, 2 * px) | |
| ); | |
| } | |
| // Shelf board. | |
| ctx.fillStyle = '#4a2f1a'; | |
| ctx.fillRect( | |
| 0, | |
| shelfY - shelfThickness / 2, | |
| canvas.width, | |
| shelfThickness | |
| ); | |
| } | |
| // Permanent bookcase side rails at the true viewport edges. | |
| const railW = Math.max(12, 30 * px); | |
| ctx.fillStyle = '#3a2413'; | |
| ctx.fillRect(0, 0, railW, canvas.height); | |
| ctx.fillRect(canvas.width - railW, 0, railW, canvas.height); | |
| ctx.restore(); | |
| // Restore the normal world transform for gameplay drawing. | |
| ctx.setTransform( | |
| viewScale, | |
| 0, | |
| 0, | |
| -viewScale, | |
| canvas.width / 2, | |
| canvas.height / 2 | |
| ); | |
| } | |
| function syncResponsiveUi(){ | |
| const top = VIEW_HALF_HEIGHT; | |
| const bottom = -VIEW_HALF_HEIGHT; | |
| const left = -WORLD_HALF; | |
| const right = WORLD_HALF; | |
| PAUSE_BTN.x = right - 38; | |
| // Lower than before so it sits below Hello + Log out. | |
| PAUSE_BTN.y = top - 92; | |
| JUMP_BTN.x = right - 74; | |
| JUMP_BTN.y = bottom + 78; | |
| } | |
| function draw(){ | |
| screen.clear('#7a5233'); | |
| syncResponsiveUi(); | |
| drawLibraryBackground(); | |
| if(state === 'title'){ | |
| screen.drawText('RABBI BOUNCE', 0, 150, 50, '#ff5c8a'); | |
| drawRabbi(0, 0, 1); | |
| screen.drawRect(START_BTN.x, START_BTN.y, START_BTN.w, START_BTN.h, '#4caf50'); | |
| screen.drawText('START', START_BTN.x, START_BTN.y, 34, '#ffffff'); | |
| screen.drawRect(HELP_BTN.x, HELP_BTN.y, HELP_BTN.w, HELP_BTN.h, '#3f8fd6'); | |
| screen.drawText('HELP', HELP_BTN.x, HELP_BTN.y, 22, '#ffffff'); | |
| return; | |
| } | |
| if(state === 'countdown'){ | |
| for(const p of platforms) drawPlatform(p); | |
| for(const c of coins) drawCoin(c); | |
| drawRabbi(player.x, player.y - camera.y, player.facing); | |
| const elapsed = performance.now() - countdownStartedAt; | |
| const remaining = Math.max(0, COUNTDOWN_MS - elapsed); | |
| let label = '3'; | |
| if(remaining <= 2200) label = '2'; | |
| if(remaining <= 1200) label = '1'; | |
| if(remaining <= 350) label = 'GO!'; | |
| const pulse = 1 + 0.08*Math.sin(performance.now()*0.018); | |
| ctx.save(); | |
| ctx.scale(pulse, pulse); | |
| screen.drawCircle(0, 20, 70, '#00000088'); | |
| screen.drawText(label, 0, 20, label === 'GO!' ? 42 : 58, '#ffffff'); | |
| ctx.restore(); | |
| return; | |
| } | |
| if(state === 'help'){ | |
| const page = HELP_PAGES[helpPage]; | |
| screen.drawText('HOW TO PLAY', 0, 220, 38, '#ff5c8a'); | |
| screen.drawRect(0, 30, 620, 340, '#ffffffee'); | |
| screen.drawText(page.title, 0, 165, 24, '#333'); | |
| if(page.kind === 'laser') drawHelpLaserPreview(0, 95); | |
| else drawHelpPlatformPreview(page.kind, 0, 95); | |
| page.lines.forEach((line, i) => { | |
| screen.drawText(line, 0, 15 - i*32, 16, '#333'); | |
| }); | |
| screen.drawText((helpPage+1) + ' / ' + HELP_PAGES.length, 0, -95, 15, '#888'); | |
| const atFirst = helpPage === 0, atLast = helpPage === HELP_PAGES.length - 1; | |
| screen.drawRect(HELP_PREV_BTN.x, HELP_PREV_BTN.y, HELP_PREV_BTN.w, HELP_PREV_BTN.h, atFirst ? '#8a5cff55' : '#8a5cff'); | |
| screen.drawText('<', HELP_PREV_BTN.x, HELP_PREV_BTN.y, 30, atFirst ? '#ffffff88' : '#ffffff'); | |
| screen.drawRect(HELP_NEXT_BTN.x, HELP_NEXT_BTN.y, HELP_NEXT_BTN.w, HELP_NEXT_BTN.h, atLast ? '#8a5cff55' : '#8a5cff'); | |
| screen.drawText('>', HELP_NEXT_BTN.x, HELP_NEXT_BTN.y, 30, atLast ? '#ffffff88' : '#ffffff'); | |
| screen.drawRect(BACK_BTN.x, BACK_BTN.y, BACK_BTN.w, BACK_BTN.h, '#4caf50'); | |
| screen.drawText('BACK', BACK_BTN.x, BACK_BTN.y, 30, '#ffffff'); | |
| return; | |
| } | |
| if(state === 'paused'){ | |
| screen.clear('#efe3cc'); | |
| screen.drawText('Score: ' + score, -WORLD_HALF + 120, VIEW_HALF_HEIGHT - 42, 26, '#8a5cff'); | |
| screen.drawText('Best: ' + best, WORLD_HALF - 120, VIEW_HALF_HEIGHT - 42, 26, '#ff5c8a'); | |
| screen.drawRect(-18, 60, 22, 72, '#5a4a3a'); | |
| screen.drawRect(18, 60, 22, 72, '#5a4a3a'); | |
| screen.drawText('PAUSED', 0, -40, 30, '#5a4a3a'); | |
| screen.drawRect(PAUSE_RESUME_BTN.x, PAUSE_RESUME_BTN.y, PAUSE_RESUME_BTN.w, PAUSE_RESUME_BTN.h, '#4caf50'); | |
| screen.drawText('RESUME', PAUSE_RESUME_BTN.x, PAUSE_RESUME_BTN.y, 22, '#ffffff'); | |
| screen.drawRect(PAUSE_MENU_BTN.x, PAUSE_MENU_BTN.y, PAUSE_MENU_BTN.w, PAUSE_MENU_BTN.h, '#ff5c8a'); | |
| screen.drawText('MENU', PAUSE_MENU_BTN.x, PAUSE_MENU_BTN.y, 22, '#ffffff'); | |
| screen.drawRect(PAUSE_SHOP_BTN.x, PAUSE_SHOP_BTN.y, PAUSE_SHOP_BTN.w, PAUSE_SHOP_BTN.h, '#f2a63f'); | |
| screen.drawText('SHOP', PAUSE_SHOP_BTN.x, PAUSE_SHOP_BTN.y, 22, '#ffffff'); | |
| screen.drawRect(PAUSE_HELP_BTN.x, PAUSE_HELP_BTN.y, PAUSE_HELP_BTN.w, PAUSE_HELP_BTN.h, '#3f8fd6'); | |
| screen.drawText('HELP', PAUSE_HELP_BTN.x, PAUSE_HELP_BTN.y, 22, '#ffffff'); | |
| return; | |
| } | |
| if(state === 'shop'){ | |
| screen.clear('#efe3cc'); | |
| screen.drawText('SHOP', 0, 260, 36, '#f2a63f'); | |
| screen.drawCircle(-330, 258, 11, '#f2c230'); | |
| ctx.save(); ctx.strokeStyle = '#c89a1a'; ctx.lineWidth = 1.5; | |
| ctx.beginPath(); ctx.arc(-330, 258, 11, 0, Math.PI*2); ctx.stroke(); | |
| ctx.restore(); | |
| screen.drawText(String(coinsCollected), -296, 258, 24, '#c89a1a'); | |
| screen.drawRect(0, 45, 740, 425, '#ffffffee'); | |
| const drawShopButton = (btn, title, detail, color) => { | |
| screen.drawRect(btn.x, btn.y, btn.w, btn.h, color); | |
| screen.drawText(title, btn.x, btn.y + 12, 17, '#ffffff'); | |
| screen.drawText(detail, btn.x, btn.y - 15, 12, '#ffffff'); | |
| }; | |
| drawShopButton( | |
| SHOP_DOUBLE_JUMP_BTN, | |
| 'DOUBLE JUMP - ' + DOUBLE_JUMP_COST_PER_MINUTE + '/MIN', | |
| formatTime(doubleJumpSeconds) + ' left', | |
| '#8a5cff' | |
| ); | |
| drawShopButton( | |
| SHOP_SLOW_FALL_BTN, | |
| 'SLOWER FALLING - ' + SLOW_FALL_COST_PER_MINUTE + '/MIN', | |
| formatTime(slowFallSeconds) + ' left', | |
| '#3f8fd6' | |
| ); | |
| drawShopButton( | |
| SHOP_HIGH_BOUNCE_BTN, | |
| 'HIGHER BOUNCE - ' + HIGH_BOUNCE_COST_PER_MINUTE + '/MIN', | |
| formatTime(highBounceSeconds) + ' left', | |
| '#e56b3f' | |
| ); | |
| drawShopButton( | |
| SHOP_COIN_MAGNET_BTN, | |
| 'COIN MAGNET - ' + COIN_MAGNET_COST_PER_MINUTE + '/MIN', | |
| formatTime(coinMagnetSeconds) + ' left', | |
| '#4caf50' | |
| ); | |
| drawShopButton( | |
| SHOP_MENORAH_BTN, | |
| 'MENORAH - ' + MENORAH_COST, | |
| 'Owned: ' + menorahsOwned + ' • permanent', | |
| '#c7922f' | |
| ); | |
| drawShopButton( | |
| SHOP_CANDLES_BTN, | |
| 'CANDLES - ' + CANDLE_COST + ' EACH', | |
| candlesOwned + '/' + MAX_CANDLES + ' • 1 candle = 1 spike', | |
| '#d35478' | |
| ); | |
| screen.drawRect(BACK_BTN.x, BACK_BTN.y, BACK_BTN.w, BACK_BTN.h, '#4caf50'); | |
| screen.drawText('BACK', BACK_BTN.x, BACK_BTN.y, 30, '#ffffff'); | |
| return; | |
| } | |
| for(const p of platforms) drawPlatform(p); | |
| for(const c of coins) drawCoin(c); | |
| for(const l of lasers) drawLaser(l); | |
| for(const m of MILESTONES) drawMilestone(m.amos, m.color); | |
| drawLandingParticles(); | |
| if(state === 'gameover' && performance.now() - gameOverStartedAt < 700){ | |
| const t = (performance.now() - gameOverStartedAt) / 700; | |
| const px = player.x; | |
| const py = player.y - camera.y - t*35; | |
| ctx.save(); | |
| ctx.translate(px, py); | |
| ctx.rotate(gameOverAngle); | |
| ctx.scale(1 - t*0.18, 1 - t*0.18); | |
| drawRabbi(0, 0, player.facing); | |
| ctx.restore(); | |
| } else { | |
| drawRabbi(player.x, player.y - camera.y, player.facing); | |
| } | |
| const hudLeftX = -WORLD_HALF + 120; | |
| const hudTopY = VIEW_HALF_HEIGHT - 42; | |
| screen.drawText('Score: ' + score, hudLeftX, hudTopY, 26, '#8a5cff'); | |
| screen.drawCircle(hudLeftX - 26, hudTopY - 34, 9, '#f2c230'); | |
| ctx.save(); ctx.strokeStyle = '#c89a1a'; ctx.lineWidth = 1.5; | |
| ctx.beginPath(); ctx.arc(hudLeftX - 26, hudTopY - 34, 9, 0, Math.PI*2); ctx.stroke(); | |
| ctx.restore(); | |
| screen.drawText(String(coinsCollected), hudLeftX, hudTopY - 34, 22, '#c89a1a'); | |
| if(state === 'playing'){ | |
| screen.drawCircle(PAUSE_BTN.x+2, PAUSE_BTN.y-3, 24, '#00000028'); | |
| screen.drawCircle(PAUSE_BTN.x, PAUSE_BTN.y, 24, '#ffffff'); | |
| screen.drawCircle(PAUSE_BTN.x, PAUSE_BTN.y, 21, '#8a5cff'); | |
| screen.drawRect(PAUSE_BTN.x-6, PAUSE_BTN.y, 5, 18, '#ffffff'); | |
| screen.drawRect(PAUSE_BTN.x+6, PAUSE_BTN.y, 5, 18, '#ffffff'); | |
| // Double Jump is always visible. Gray means no purchased time remains. | |
| const jumpButtonColor = doubleJumpSeconds <= 0 | |
| ? '#777777' | |
| : (doubleJumpReady ? '#ff9f3f' : '#b97842'); | |
| screen.drawCircle(JUMP_BTN.x+2, JUMP_BTN.y-3, 44, '#00000028'); | |
| screen.drawCircle(JUMP_BTN.x, JUMP_BTN.y, 44, '#ffffff'); | |
| screen.drawCircle(JUMP_BTN.x, JUMP_BTN.y, 38, jumpButtonColor); | |
| screen.drawText('DOUBLE', JUMP_BTN.x, JUMP_BTN.y+10, 12, '#ffffff'); | |
| screen.drawText('JUMP', JUMP_BTN.x, JUMP_BTN.y-5, 13, '#ffffff'); | |
| let jumpStatus = 'NO TIME'; | |
| if(doubleJumpSeconds > 0 && doubleJumpReady) jumpStatus = 'READY ' + formatTime(doubleJumpSeconds); | |
| else if(doubleJumpSeconds > 0) jumpStatus = 'USED ' + formatTime(doubleJumpSeconds); | |
| screen.drawText(jumpStatus, JUMP_BTN.x, JUMP_BTN.y-24, 8, '#ffffff'); | |
| // The menorah is permanent and automatic, so there is deliberately NO | |
| // Menorah button. This is only an inventory readout. It activates when | |
| // Rabbi lands on dangerous top spikes. | |
| const menorahHudX = -WORLD_HALF + 130; | |
| const menorahHudY = -VIEW_HALF_HEIGHT + 36; | |
| screen.drawRect(menorahHudX, menorahHudY, 220, 34, '#00000055'); | |
| screen.drawText( | |
| (menorahsOwned > 0 ? 'MENORAH ✓' : 'NO MENORAH') + | |
| ' CANDLES ' + candlesOwned + '/' + MAX_CANDLES, | |
| menorahHudX, | |
| menorahHudY, | |
| 11, | |
| '#ffffff' | |
| ); | |
| if(menorahStatus && performance.now() < menorahStatusUntil){ | |
| screen.drawText(menorahStatus, 0, -VIEW_HALF_HEIGHT + 30, 13, '#ffffff'); | |
| } | |
| // Compact active-upgrade timers above the permanent Menorah inventory. | |
| const active = []; | |
| if(slowFallSeconds > 0) active.push('Slow ' + formatTime(slowFallSeconds)); | |
| if(highBounceSeconds > 0) active.push('High ' + formatTime(highBounceSeconds)); | |
| if(coinMagnetSeconds > 0) active.push('Magnet ' + formatTime(coinMagnetSeconds)); | |
| if(active.length){ | |
| const x = -WORLD_HALF + 125; | |
| let y = -VIEW_HALF_HEIGHT + 160; | |
| for(const line of active){ | |
| screen.drawText(line, x, y, 13, '#ffffff'); | |
| y += 19; | |
| } | |
| } | |
| } | |
| if(state === 'gameover'){ | |
| const elapsed = performance.now() - gameOverStartedAt; | |
| // Quick impact flash first. | |
| if(elapsed < 260){ | |
| const flashAlpha = 0.36 * (1 - elapsed/260); | |
| ctx.save(); | |
| ctx.setTransform(1,0,0,1,0,0); | |
| ctx.fillStyle = `rgba(255,80,110,${flashAlpha})`; | |
| ctx.fillRect(0,0,canvas.width,canvas.height); | |
| ctx.restore(); | |
| } | |
| // Let the little spin/fall animation happen before the panel appears. | |
| if(elapsed >= 520){ | |
| const panelT = Math.min(1, (elapsed - 520) / 300); | |
| const ease = 1 - Math.pow(1-panelT, 3); | |
| const s = 0.78 + ease*0.22; | |
| ctx.save(); | |
| ctx.scale(s, s); | |
| screen.drawRect(0, 0, 500, 300, '#ffffffee'); | |
| screen.drawText('GAME OVER', 0, 100, 44, '#ff5c8a'); | |
| screen.drawText('You climbed ' + score + ' amos!', 0, 40, 24, '#333'); | |
| screen.drawText('Best: ' + best, 0, 0, 22, '#8a5cff'); | |
| screen.drawRect(PLAY_AGAIN_BTN.x, PLAY_AGAIN_BTN.y, PLAY_AGAIN_BTN.w, PLAY_AGAIN_BTN.h, '#4caf50'); | |
| screen.drawText('PLAY AGAIN', PLAY_AGAIN_BTN.x, PLAY_AGAIN_BTN.y, 26, '#ffffff'); | |
| ctx.restore(); | |
| } | |
| } | |
| } | |
| function startGame(){ | |
| init(); | |
| state = 'countdown'; | |
| countdownStartedAt = performance.now(); | |
| pointerWasDown = mouse.pressed; | |
| playBeep(520, 0.08); | |
| setTimeout(()=>playBeep(620, 0.08), 1000); | |
| setTimeout(()=>playBeep(740, 0.08), 2000); | |
| } | |
| function loop(now){ | |
| if(lastFrameTime === null) lastFrameTime = now; | |
| const dtSec = (now - lastFrameTime) / 1000; | |
| lastFrameTime = now; | |
| if(state === 'playing' && !menorahUse){ | |
| if(doubleJumpSeconds > 0){ | |
| doubleJumpSeconds = Math.max(0, doubleJumpSeconds - dtSec); | |
| if(doubleJumpSeconds === 0) doubleJumpReady = false; | |
| } | |
| if(slowFallSeconds > 0) slowFallSeconds = Math.max(0, slowFallSeconds - dtSec); | |
| if(highBounceSeconds > 0) highBounceSeconds = Math.max(0, highBounceSeconds - dtSec); | |
| if(coinMagnetSeconds > 0) coinMagnetSeconds = Math.max(0, coinMagnetSeconds - dtSec); | |
| if(now - lastTimedSaveAt >= 1000){ | |
| lastTimedSaveAt = now; | |
| saveGame(); | |
| } | |
| } | |
| update(); | |
| draw(); | |
| requestAnimationFrame(loop); | |
| } | |
| // ===================================== | |
| // RELIABLE DOUBLE-JUMP KEY INPUT | |
| // ===================================== | |
| window.addEventListener('keydown', function(event){ | |
| const isJumpKey = | |
| event.code === 'Space' || | |
| event.code === 'KeyW' || | |
| event.code === 'ArrowUp'; | |
| if(!isJumpKey) return; | |
| if(event.code === 'Space') event.preventDefault(); | |
| // One physical key press = one attempt. Holding the key cannot spend | |
| // multiple recharges because browser auto-repeat is ignored. | |
| if(!event.repeat && state === 'playing'){ | |
| attemptDoubleJump(); | |
| } | |
| }); | |
| // ===================================== | |
| // CUSTOM RIGHT-CLICK MENU | |
| // ===================================== | |
| const gameContextMenu = document.getElementById('gameContextMenu'); | |
| function hideGameContextMenu(){ | |
| gameContextMenu.classList.remove('show'); | |
| gameContextMenu.setAttribute('aria-hidden', 'true'); | |
| } | |
| function showGameContextMenu(x, y){ | |
| gameContextMenu.classList.add('show'); | |
| gameContextMenu.setAttribute('aria-hidden', 'false'); | |
| gameContextMenu.style.left = x + 'px'; | |
| gameContextMenu.style.top = y + 'px'; | |
| requestAnimationFrame(function(){ | |
| const rect = gameContextMenu.getBoundingClientRect(); | |
| const safeLeft = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8)); | |
| const safeTop = Math.max(8, Math.min(y, window.innerHeight - rect.height - 8)); | |
| gameContextMenu.style.left = safeLeft + 'px'; | |
| gameContextMenu.style.top = safeTop + 'px'; | |
| }); | |
| } | |
| document.addEventListener('contextmenu', function(event){ | |
| event.preventDefault(); | |
| // The custom menu works before START and while PAUSED, exactly as requested. | |
| if(state === 'title' || state === 'paused'){ | |
| showGameContextMenu(event.clientX, event.clientY); | |
| } else { | |
| hideGameContextMenu(); | |
| } | |
| }); | |
| gameContextMenu.addEventListener('click', function(event){ | |
| const item = event.target.closest('button[data-action]'); | |
| if(!item) return; | |
| const action = item.dataset.action; | |
| hideGameContextMenu(); | |
| if(action === 'home'){ | |
| window.location.href = 'index.html'; | |
| return; | |
| } | |
| if(action === 'clicker'){ | |
| localStorage.setItem('openAfterReturn', 'clicker'); | |
| window.location.href = 'index.html'; | |
| return; | |
| } | |
| if(action === 'rabbi'){ | |
| init(); | |
| state = 'title'; | |
| pointerWasDown = false; | |
| return; | |
| } | |
| if(action === 'logout'){ | |
| logoutGame(); | |
| } | |
| }); | |
| document.addEventListener('pointerdown', function(event){ | |
| if(!gameContextMenu.contains(event.target)){ | |
| hideGameContextMenu(); | |
| } | |
| }); | |
| window.addEventListener('resize', hideGameContextMenu); | |
| // ===================================== | |
| // BLOCK COMMON SOURCE / INSPECT SHORTCUTS | |
| // ===================================== | |
| document.addEventListener('keydown', function(event){ | |
| const key = String(event.key || '').toLowerCase(); | |
| if(event.key === 'Escape'){ | |
| hideGameContextMenu(); | |
| } | |
| if(event.key === 'F12'){ | |
| event.preventDefault(); | |
| return; | |
| } | |
| if((event.ctrlKey || event.metaKey) && !event.shiftKey && key === 'u'){ | |
| event.preventDefault(); | |
| return; | |
| } | |
| if(event.ctrlKey && event.shiftKey && (key === 'i' || key === 'j' || key === 'c' || key === 'k')){ | |
| event.preventDefault(); | |
| return; | |
| } | |
| if(event.metaKey && event.altKey && (key === 'i' || key === 'j' || key === 'c')){ | |
| event.preventDefault(); | |
| } | |
| }); | |
| init(); | |
| requestAnimationFrame(loop); | |
| </script> | |
| </body> | |
| </html> |