{"project":{"id":"53YkLsv","userId":"davidyarham@gmail.com","username":null,"userPicture":null,"name":"Stack","visible":true,"contributors":"","githubRepo":null,"forkedFrom":null,"isTemplate":false,"tags":"","files":{"folder":"","files":[{"name":"index.html","content":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>Untitled</title>\n  <link rel=\"stylesheet\" href=\"style.css\">\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n<link href=\"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;700&family=Syne:wght@600;700;800&display=swap\" rel=\"stylesheet\">\n<script src=\"https://unpkg.com/three@0.160.0/build/three.min.js\"></script>\n<script src=\"https://unpkg.com/lucide@latest\"></script>\n</head>\n<body>\n<div id=\"game-container\">\n\t<canvas id=\"game-canvas\"></canvas>\n\t<canvas id=\"particle-canvas\"></canvas>\n\t<div id=\"ui-overlay\">\n\t\t<div id=\"score-panel\">\n\t\t\t<div class=\"score-block\">\n\t\t\t\t<span class=\"score-label\">SCORE</span>\n\t\t\t\t<span id=\"score\" class=\"score-value\">0</span>\n\t\t\t</div>\n\t\t\t<div class=\"score-block score-block--hi\">\n\t\t\t\t<span class=\"score-label\">BEST</span>\n\t\t\t\t<span id=\"hiscore\" class=\"score-value\">0</span>\n\t\t\t</div>\n\t\t</div>\n\t\t<div id=\"start-screen\">\n\t\t\t<h1 class=\"game-title\">STACK</h1>\n\t\t\t<p class=\"game-subtitle\">Tap to place blocks</p>\n\t\t\t<button id=\"start-btn\" class=\"btn-play\">\n\t\t\t\t<i data-lucide=\"play\" class=\"btn-play__icon\"></i>\n\t\t\t\t<span>PLAY</span>\n\t\t\t</button>\n\t\t</div>\n\t\t<div id=\"game-over-screen\" class=\"hidden\">\n\t\t\t<div class=\"game-over-card\">\n\t\t\t\t<h2 class=\"game-over-title\">GAME OVER</h2>\n\t\t\t\t<div class=\"final-score-display\">\n\t\t\t\t\t<span class=\"final-score-label\">SCORE</span>\n\t\t\t\t\t<span id=\"final-score\" class=\"final-score-num\">0</span>\n\t\t\t\t</div>\n\t\t\t\t<div id=\"new-best\" class=\"new-best hidden\">★ NEW BEST ★</div>\n\t\t\t\t<button id=\"restart-btn\" class=\"btn-play\">\n\t\t\t\t\t<i data-lucide=\"rotate-ccw\" class=\"btn-play__icon\"></i>\n\t\t\t\t\t<span>RETRY</span>\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t</div>\n\t\t<div id=\"tap-hint\" class=\"hidden\">\n\t\t\t<span>TAP ANYWHERE</span>\n\t\t</div>\n\t</div>\n\t<div id=\"combo-display\" class=\"hidden\"></div>\n</div>\n  <script type=\"module\" src=\"main.js\"></script>\n</body>\n</html>"},{"name":"main.js","content":"// ===== THREE.JS STACK GAME =====\nconst {\n\tScene,\n\tPerspectiveCamera,\n\tWebGLRenderer,\n\tDirectionalLight,\n\tAmbientLight,\n\tBoxGeometry,\n\tMeshStandardMaterial,\n\tMesh,\n\tColor,\n\tFog,\n\tVector3,\n\tGroup,\n\tHemisphereLight,\n\tPointLight\n} = THREE;\n\n// ===== SOUND ENGINE =====\nclass SoundEngine {\n\tconstructor() {\n\t\tthis.ctx = null;\n\t\tthis.initialized = false;\n\t}\n\tinit() {\n\t\tif (this.initialized) return;\n\t\tthis.ctx = new(window.AudioContext || window.webkitAudioContext)();\n\t\tthis.initialized = true;\n\t}\n\tplay(type, noteOffset = 0) {\n\t\tif (!this.ctx) return;\n\t\tif (this.ctx.state === 'suspended') this.ctx.resume();\n\t\tconst t = this.ctx.currentTime;\n\t\tif (type === 'place') {\n\t\t\tconst osc = this.ctx.createOscillator();\n\t\t\tconst gain = this.ctx.createGain();\n\t\t\tosc.type = 'sine';\n\t\t\tconst freq = 260 + noteOffset * 18;\n\t\t\tosc.frequency.setValueAtTime(Math.min(freq, 980), t);\n\t\t\tosc.frequency.exponentialRampToValueAtTime(Math.min(freq * 1.4, 1400), t + 0.06);\n\t\t\tgain.gain.setValueAtTime(0.13, t);\n\t\t\tgain.gain.exponentialRampToValueAtTime(0.001, t + 0.25);\n\t\t\tosc.connect(gain).connect(this.ctx.destination);\n\t\t\tosc.start(t);\n\t\t\tosc.stop(t + 0.25);\n\t\t}\n\t\tif (type === 'perfect') {\n\t\t\tconst notes = [523, 659, 784, 1047];\n\t\t\tnotes.forEach((f, i) => {\n\t\t\t\tconst osc = this.ctx.createOscillator();\n\t\t\t\tconst gain = this.ctx.createGain();\n\t\t\t\tosc.type = 'sine';\n\t\t\t\tconst delay = i * 0.07;\n\t\t\t\tosc.frequency.setValueAtTime(f, t + delay);\n\t\t\t\tgain.gain.setValueAtTime(0.1, t + delay);\n\t\t\t\tgain.gain.exponentialRampToValueAtTime(0.001, t + delay + 0.35);\n\t\t\t\tosc.connect(gain).connect(this.ctx.destination);\n\t\t\t\tosc.start(t + delay);\n\t\t\t\tosc.stop(t + delay + 0.4);\n\t\t\t});\n\t\t}\n\t\tif (type === 'fall') {\n\t\t\tconst osc = this.ctx.createOscillator();\n\t\t\tconst gain = this.ctx.createGain();\n\t\t\tosc.type = 'sawtooth';\n\t\t\tosc.frequency.setValueAtTime(350, t);\n\t\t\tosc.frequency.exponentialRampToValueAtTime(40, t + 0.7);\n\t\t\tgain.gain.setValueAtTime(0.08, t);\n\t\t\tgain.gain.exponentialRampToValueAtTime(0.001, t + 0.7);\n\t\t\tosc.connect(gain).connect(this.ctx.destination);\n\t\t\tosc.start(t);\n\t\t\tosc.stop(t + 0.75);\n\t\t}\n\t\tif (type === 'combo') {\n\t\t\tconst base = 520 + noteOffset * 40;\n\t\t\tconst osc = this.ctx.createOscillator();\n\t\t\tconst gain = this.ctx.createGain();\n\t\t\tosc.type = 'triangle';\n\t\t\tosc.frequency.setValueAtTime(Math.min(base, 1200), t);\n\t\t\tgain.gain.setValueAtTime(0.1, t);\n\t\t\tgain.gain.exponentialRampToValueAtTime(0.001, t + 0.3);\n\t\t\tosc.connect(gain).connect(this.ctx.destination);\n\t\t\tosc.start(t);\n\t\t\tosc.stop(t + 0.35);\n\t\t}\n\t}\n}\n\n// ===== HSL COLOR SYSTEM =====\nfunction getBlockColor(index) {\n\tconst hue = (index * 14 + 340) % 360;\n\tconst sat = 65 + Math.sin(index * 0.3) * 15;\n\tconst light = 52 + Math.sin(index * 0.5) * 8;\n\treturn `hsl(${hue}, ${sat}%, ${light}%)`;\n}\n\nfunction getBlockEmissive(index) {\n\tconst hue = (index * 14 + 340) % 360;\n\treturn `hsl(${hue}, 40%, 15%)`;\n}\n\n// ===== PARTICLE SYSTEM (2D CANVAS) =====\nclass ParticleSystem {\n\tconstructor(canvas) {\n\t\tthis.canvas = canvas;\n\t\tthis.ctx = canvas.getContext('2d');\n\t\tthis.particles = [];\n\t\tthis.resize();\n\t\twindow.addEventListener('resize', () => this.resize());\n\t}\n\tresize() {\n\t\tthis.canvas.width = window.innerWidth * Math.min(devicePixelRatio, 2);\n\t\tthis.canvas.height = window.innerHeight * Math.min(devicePixelRatio, 2);\n\t}\n\temit(screenX, screenY, color, count = 12) {\n\t\tconst scale = Math.min(devicePixelRatio, 2);\n\t\tfor (let i = 0; i < count; i++) {\n\t\t\tconst angle = (Math.PI * 2 * i / count) + (Math.random() - 0.5) * 0.5;\n\t\t\tconst speed = 2 + Math.random() * 4;\n\t\t\tthis.particles.push({\n\t\t\t\tx: screenX * scale,\n\t\t\t\ty: screenY * scale,\n\t\t\t\tvx: Math.cos(angle) * speed * scale,\n\t\t\t\tvy: Math.sin(angle) * speed * scale - 2 * scale,\n\t\t\t\tlife: 1,\n\t\t\t\tdecay: 0.015 + Math.random() * 0.02,\n\t\t\t\tsize: (2 + Math.random() * 3) * scale,\n\t\t\t\tcolor: color\n\t\t\t});\n\t\t}\n\t}\n\tupdate() {\n\t\tthis.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);\n\t\tfor (let i = this.particles.length - 1; i >= 0; i--) {\n\t\t\tconst p = this.particles[i];\n\t\t\tp.x += p.vx;\n\t\t\tp.y += p.vy;\n\t\t\tp.vy += 0.12;\n\t\t\tp.vx *= 0.98;\n\t\t\tp.life -= p.decay;\n\t\t\tif (p.life <= 0) {\n\t\t\t\tthis.particles.splice(i, 1);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tthis.ctx.globalAlpha = p.life * p.life;\n\t\t\tthis.ctx.fillStyle = p.color;\n\t\t\tthis.ctx.beginPath();\n\t\t\tthis.ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);\n\t\t\tthis.ctx.fill();\n\t\t}\n\t\tthis.ctx.globalAlpha = 1;\n\t}\n}\n\n// ===== GAME STATE =====\nlet scene, camera, renderer, gameGroup;\nlet stack = [];\nlet fallingPieces = [];\nlet gameState = 'idle';\nlet score = 0;\nlet hiScore = parseInt(localStorage.getItem('stackHiScore') || '0');\nlet combo = 0;\nlet baseSpeed = 0.04;\nlet animFrameId;\nlet groupTargetY = 0;\nlet moveTime = 0;\nlet particles;\nconst BLOCK_HEIGHT = 0.35;\nconst PERFECT_THRESHOLD = 0.15;\nconst SWING_AMPLITUDE = 5.5;\n\nconst sound = new SoundEngine();\n\n// DOM refs\nconst canvas = document.getElementById('game-canvas');\nconst scoreEl = document.getElementById('score');\nconst hiScoreEl = document.getElementById('hiscore');\nconst startScreen = document.getElementById('start-screen');\nconst gameOverScreen = document.getElementById('game-over-screen');\nconst finalScoreEl = document.getElementById('final-score');\nconst newBestEl = document.getElementById('new-best');\nconst tapHint = document.getElementById('tap-hint');\nconst comboDisplay = document.getElementById('combo-display');\n\nhiScoreEl.textContent = hiScore;\n\n// ===== INIT THREE.JS =====\nfunction initScene() {\n\tscene = new Scene();\n\tscene.background = new Color('#1a1a2e');\n\tscene.fog = new Fog('#1a1a2e', 14, 30);\n\n\tcamera = new PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100);\n\tcamera.position.set(6, 8, 6);\n\tcamera.lookAt(0, 0, 0);\n\n\trenderer = new WebGLRenderer({\n\t\tcanvas,\n\t\tantialias: true,\n\t\talpha: false\n\t});\n\trenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n\trenderer.setSize(window.innerWidth, window.innerHeight);\n\trenderer.shadowMap.enabled = true;\n\trenderer.shadowMap.type = THREE.PCFSoftShadowMap;\n\trenderer.toneMapping = THREE.ACESFilmicToneMapping;\n\trenderer.toneMappingExposure = 1.1;\n\n\tconst hemiLight = new HemisphereLight(0x6677aa, 0x223344, 0.6);\n\tscene.add(hemiLight);\n\n\tconst dirLight = new DirectionalLight(0xffeedd, 2.0);\n\tdirLight.position.set(5, 15, 8);\n\tdirLight.castShadow = true;\n\tdirLight.shadow.camera.near = 0.1;\n\tdirLight.shadow.camera.far = 50;\n\tdirLight.shadow.camera.left = -10;\n\tdirLight.shadow.camera.right = 10;\n\tdirLight.shadow.camera.top = 10;\n\tdirLight.shadow.camera.bottom = -10;\n\tdirLight.shadow.mapSize.set(2048, 2048);\n\tdirLight.shadow.bias = -0.001;\n\tscene.add(dirLight);\n\n\tconst ambLight = new AmbientLight(0x8899cc, 0.3);\n\tscene.add(ambLight);\n\n\tconst fillLight = new DirectionalLight(0xaabbff, 0.5);\n\tfillLight.position.set(-5, 8, -5);\n\tscene.add(fillLight);\n\n\tgameGroup = new Group();\n\tscene.add(gameGroup);\n\n\tparticles = new ParticleSystem(document.getElementById('particle-canvas'));\n}\n\nfunction createBlock(x, y, z, width, depth, colorIndex) {\n\tconst geo = new BoxGeometry(width, BLOCK_HEIGHT, depth);\n\tconst mat = new MeshStandardMaterial({\n\t\tcolor: new Color(getBlockColor(colorIndex)),\n\t\temissive: new Color(getBlockEmissive(colorIndex)),\n\t\temissiveIntensity: 0.15,\n\t\troughness: 0.45,\n\t\tmetalness: 0.15,\n\t});\n\tconst mesh = new Mesh(geo, mat);\n\tmesh.position.set(x, y, z);\n\tmesh.castShadow = true;\n\tmesh.receiveShadow = true;\n\tmesh.userData.colorIndex = colorIndex;\n\treturn mesh;\n}\n\nfunction resetGame() {\n\twhile (gameGroup.children.length > 0) {\n\t\tconst child = gameGroup.children[0];\n\t\tgameGroup.remove(child);\n\t\tif (child.geometry) child.geometry.dispose();\n\t\tif (child.material) child.material.dispose();\n\t}\n\tfallingPieces = [];\n\tstack = [];\n\tscore = 0;\n\tcombo = 0;\n\tbaseSpeed = 0.04;\n\tgroupTargetY = 0;\n\tmoveTime = 0;\n\n\tscoreEl.textContent = '0';\n\n\tconst base = createBlock(0, 0, 0, 3, 3, 0);\n\tgameGroup.add(base);\n\tgameGroup.position.y = 0;\n\tstack.push({\n\t\tmesh: base,\n\t\twidth: 3,\n\t\tdepth: 3,\n\t\tx: 0,\n\t\tz: 0,\n\t\taxis: 'x'\n\t});\n\n\taddNewBlock();\n}\n\nfunction addNewBlock() {\n\tconst prev = stack[stack.length - 1];\n\tconst y = stack.length * BLOCK_HEIGHT;\n\tconst axis = stack.length % 2 === 1 ? 'x' : 'z';\n\n\tconst x = axis === 'x' ? -SWING_AMPLITUDE : prev.x;\n\tconst z = axis === 'z' ? -SWING_AMPLITUDE : prev.z;\n\n\tconst block = createBlock(x, y, z, prev.width, prev.depth, stack.length);\n\tgameGroup.add(block);\n\n\tmoveTime = -Math.PI / 2; // Start from the left edge, moving right via sin\n\n\tstack.push({\n\t\tmesh: block,\n\t\twidth: prev.width,\n\t\tdepth: prev.depth,\n\t\tx: x,\n\t\tz: z,\n\t\taxis: axis,\n\t\tmoving: true\n\t});\n}\n\nfunction projectToScreen(worldPos) {\n\tconst v = worldPos.clone().project(camera);\n\treturn {\n\t\tx: (v.x * 0.5 + 0.5) * window.innerWidth,\n\t\ty: (-v.y * 0.5 + 0.5) * window.innerHeight\n\t};\n}\n\nfunction placeBlock() {\n\tconst current = stack[stack.length - 1];\n\tif (!current.moving) return;\n\tcurrent.moving = false;\n\n\tconst prev = stack[stack.length - 2];\n\tlet currentPos, prevPos, currentSize;\n\n\tif (current.axis === 'x') {\n\t\tcurrentPos = current.mesh.position.x;\n\t\tprevPos = prev.x;\n\t\tcurrentSize = current.width;\n\t} else {\n\t\tcurrentPos = current.mesh.position.z;\n\t\tprevPos = prev.z;\n\t\tcurrentSize = current.depth;\n\t}\n\n\tconst overshoot = currentPos - prevPos;\n\tconst overlap = currentSize - Math.abs(overshoot);\n\n\tif (overlap <= 0) {\n\t\tgameOver();\n\t\treturn;\n\t}\n\n\t// Perfect placement\n\tif (Math.abs(overshoot) < PERFECT_THRESHOLD) {\n\t\tcombo++;\n\t\tsound.play('perfect');\n\n\t\t// Snap to previous position\n\t\tif (current.axis === 'x') {\n\t\t\tcurrent.mesh.position.x = prevPos;\n\t\t\tcurrent.x = prevPos;\n\t\t} else {\n\t\t\tcurrent.mesh.position.z = prevPos;\n\t\t\tcurrent.z = prevPos;\n\t\t}\n\n\t\tshowCombo();\n\n\t\t// Emit particles for perfect\n\t\tconst sPos = projectToScreen(current.mesh.position);\n\t\tconst clr = getBlockColor(stack.length - 1);\n\t\tparticles.emit(sPos.x, sPos.y, clr, 20 + combo * 4);\n\t\tparticles.emit(sPos.x, sPos.y, '#ffc947', 8);\n\n\t\t// Grow block on combo streak\n\t\tif (combo >= 3) {\n\t\t\tconst growAmt = 0.15;\n\t\t\tif (current.axis === 'x') {\n\t\t\t\tcurrent.depth = Math.min(current.depth + growAmt, 3);\n\t\t\t} else {\n\t\t\t\tcurrent.width = Math.min(current.width + growAmt, 3);\n\t\t\t}\n\t\t\tcurrent.mesh.geometry.dispose();\n\t\t\tcurrent.mesh.geometry = new BoxGeometry(current.width, BLOCK_HEIGHT, current.depth);\n\t\t}\n\n\t\t// Pulse effect on the block\n\t\tconst origY = current.mesh.position.y;\n\t\tconst startT = performance.now();\n\n\t\tfunction pulseAnim(now) {\n\t\t\tconst elapsed = (now - startT) / 300;\n\t\t\tif (elapsed >= 1) {\n\t\t\t\tcurrent.mesh.position.y = origY;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tcurrent.mesh.position.y = origY + Math.sin(elapsed * Math.PI) * 0.06;\n\t\t\tcurrent.mesh.scale.setScalar(1 + Math.sin(elapsed * Math.PI) * 0.03);\n\t\t\trequestAnimationFrame(pulseAnim);\n\t\t}\n\t\trequestAnimationFrame(pulseAnim);\n\n\t} else {\n\t\tcombo = 0;\n\n\t\t// Calculate trimmed and cut pieces properly\n\t\tconst newSize = overlap;\n\t\t// The center of the overlapping region\n\t\tconst newPos = prevPos + overshoot / 2;\n\n\t\t// The cut-off piece\n\t\tconst cutSize = Math.abs(overshoot);\n\t\tconst cutPos = overshoot > 0 ?\n\t\t\t(newPos + newSize / 2 + cutSize / 2) :\n\t\t\t(newPos - newSize / 2 - cutSize / 2);\n\n\t\tlet fallGeo, fallX, fallZ;\n\t\tif (current.axis === 'x') {\n\t\t\tfallGeo = new BoxGeometry(cutSize, BLOCK_HEIGHT, current.depth);\n\t\t\tfallX = cutPos;\n\t\t\tfallZ = current.mesh.position.z;\n\t\t} else {\n\t\t\tfallGeo = new BoxGeometry(current.width, BLOCK_HEIGHT, cutSize);\n\t\t\tfallX = current.mesh.position.x;\n\t\t\tfallZ = cutPos;\n\t\t}\n\n\t\tconst fallMat = new MeshStandardMaterial({\n\t\t\tcolor: current.mesh.material.color.clone(),\n\t\t\temissive: current.mesh.material.emissive.clone(),\n\t\t\temissiveIntensity: 0.1,\n\t\t\troughness: 0.5,\n\t\t\tmetalness: 0.1,\n\t\t\ttransparent: true,\n\t\t\topacity: 1\n\t\t});\n\t\tconst fallMesh = new Mesh(fallGeo, fallMat);\n\t\tfallMesh.position.set(fallX, current.mesh.position.y, fallZ);\n\t\tfallMesh.castShadow = true;\n\t\tgameGroup.add(fallMesh);\n\t\tfallingPieces.push({\n\t\t\tmesh: fallMesh,\n\t\t\tvelocity: 0,\n\t\t\trotSpeed: (Math.random() - 0.5) * 0.12,\n\t\t\taxis: current.axis,\n\t\t\topacity: 1\n\t\t});\n\n\t\t// Update the placed block\n\t\tif (current.axis === 'x') {\n\t\t\tcurrent.width = newSize;\n\t\t\tcurrent.mesh.position.x = newPos;\n\t\t\tcurrent.x = newPos;\n\t\t} else {\n\t\t\tcurrent.depth = newSize;\n\t\t\tcurrent.mesh.position.z = newPos;\n\t\t\tcurrent.z = newPos;\n\t\t}\n\n\t\tcurrent.mesh.geometry.dispose();\n\t\tcurrent.mesh.geometry = new BoxGeometry(current.width, BLOCK_HEIGHT, current.depth);\n\n\t\tsound.play('place', score);\n\n\t\t// Small particle burst on slice\n\t\tconst sPos = projectToScreen(new Vector3(fallX, current.mesh.position.y, fallZ));\n\t\tparticles.emit(sPos.x, sPos.y, getBlockColor(stack.length - 1), 6);\n\t}\n\n\tscore++;\n\tscoreEl.textContent = score;\n\n\t// Animate score bump\n\tconst scoreBlock = scoreEl.closest('.score-block');\n\tscoreBlock.classList.remove('score-bump');\n\tvoid scoreBlock.offsetWidth;\n\tscoreBlock.classList.add('score-bump');\n\n\tif (score > hiScore) {\n\t\thiScore = score;\n\t\thiScoreEl.textContent = hiScore;\n\t\tlocalStorage.setItem('stackHiScore', hiScore.toString());\n\t}\n\n\tbaseSpeed = 0.04 + Math.min(score * 0.002, 0.06);\n\tgroupTargetY = -(stack.length - 1) * BLOCK_HEIGHT;\n\n\taddNewBlock();\n}\n\nfunction showCombo() {\n\tif (combo >= 2) {\n\t\tconst texts = ['PERFECT', 'PERFECT', 'AMAZING', 'INCREDIBLE', 'GODLIKE', 'UNSTOPPABLE'];\n\t\tconst tier = Math.min(combo - 2, texts.length - 1);\n\t\tcomboDisplay.textContent = `${texts[tier]} ×${combo}`;\n\n\t\t// Color based on combo level\n\t\tconst hue = combo * 30 % 360;\n\t\tcomboDisplay.style.color = combo >= 5 ? '#ff3366' : `hsl(${40 + hue}, 100%, 65%)`;\n\t\tcomboDisplay.style.textShadow = `0 2px 30px hsla(${40 + hue}, 100%, 50%, 0.6)`;\n\n\t\tcomboDisplay.classList.remove('hidden');\n\t\tcomboDisplay.classList.remove('combo-animate');\n\t\tvoid comboDisplay.offsetWidth;\n\t\tcomboDisplay.classList.add('combo-animate');\n\t\tsound.play('combo', combo);\n\t\tsetTimeout(() => comboDisplay.classList.add('hidden'), 800);\n\t}\n}\n\nfunction gameOver() {\n\tgameState = 'over';\n\tconst current = stack[stack.length - 1];\n\n\tfallingPieces.push({\n\t\tmesh: current.mesh,\n\t\tvelocity: 0.02,\n\t\trotSpeed: (Math.random() - 0.5) * 0.15,\n\t\taxis: current.axis,\n\t\topacity: 1\n\t});\n\tstack.pop();\n\n\tsound.play('fall');\n\ttapHint.classList.add('hidden');\n\n\tsetTimeout(() => {\n\t\tfinalScoreEl.textContent = score;\n\t\tif (score >= hiScore && score > 0) {\n\t\t\tnewBestEl.classList.remove('hidden');\n\t\t} else {\n\t\t\tnewBestEl.classList.add('hidden');\n\t\t}\n\t\tgameOverScreen.classList.remove('hidden');\n\t}, 800);\n}\n\n// ===== GAME LOOP =====\nlet lastTime = 0;\n\nfunction animate(now = 0) {\n\tanimFrameId = requestAnimationFrame(animate);\n\tconst dt = Math.min((now - lastTime) / 16.667, 3); // normalize to ~60fps\n\tlastTime = now;\n\n\t// Sinusoidal movement for current block (smooth easing at edges)\n\tif (gameState === 'playing') {\n\t\tconst current = stack[stack.length - 1];\n\t\tif (current && current.moving) {\n\t\t\tconst speed = baseSpeed + baseSpeed * 0.3 * Math.sin(score * 0.1); // slight variation\n\t\t\tmoveTime += speed * dt;\n\t\t\tconst pos = Math.sin(moveTime) * SWING_AMPLITUDE;\n\t\t\tif (current.axis === 'x') {\n\t\t\t\tcurrent.mesh.position.x = pos;\n\t\t\t} else {\n\t\t\t\tcurrent.mesh.position.z = pos;\n\t\t\t}\n\t\t}\n\t}\n\n\t// Animate falling pieces\n\tfor (let i = fallingPieces.length - 1; i >= 0; i--) {\n\t\tconst fp = fallingPieces[i];\n\t\tfp.velocity -= 0.01 * dt;\n\t\tfp.mesh.position.y += fp.velocity * dt;\n\n\t\tif (fp.axis === 'x') {\n\t\t\tfp.mesh.rotation.z += fp.rotSpeed * dt;\n\t\t} else {\n\t\t\tfp.mesh.rotation.x += fp.rotSpeed * dt;\n\t\t}\n\n\t\t// Fade out\n\t\tif (fp.mesh.material.transparent && fp.opacity !== undefined) {\n\t\t\tfp.opacity -= 0.008 * dt;\n\t\t\tfp.mesh.material.opacity = Math.max(0, fp.opacity);\n\t\t}\n\n\t\tif (fp.mesh.position.y < -20) {\n\t\t\tgameGroup.remove(fp.mesh);\n\t\t\tfp.mesh.geometry.dispose();\n\t\t\tfp.mesh.material.dispose();\n\t\t\tfallingPieces.splice(i, 1);\n\t\t}\n\t}\n\n\t// Smooth group descent - keep active block at consistent screen height\n\tgameGroup.position.y += (groupTargetY - gameGroup.position.y) * 0.07 * dt;\n\n\t// Gentle camera orbit at fixed height\n\tconst orbitAngle = now * 0.00008;\n\tconst orbitRadius = 8.5;\n\tconst fixedCamY = 8;\n\tcamera.position.y += (fixedCamY - camera.position.y) * 0.04 * dt;\n\tcamera.position.x += (Math.cos(orbitAngle) * orbitRadius - camera.position.x) * 0.02 * dt;\n\tcamera.position.z += (Math.sin(orbitAngle) * orbitRadius - camera.position.z) * 0.02 * dt;\n\n\t// Look at the top of the visible stack\n\tconst topWorldY = gameGroup.position.y + stack.length * BLOCK_HEIGHT;\n\tcamera.lookAt(0, topWorldY * 0.5 + 0.5, 0);\n\n\t// Update particles\n\tparticles.update();\n\n\trenderer.render(scene, camera);\n}\n\n// ===== EVENT HANDLING =====\nfunction onTap() {\n\tif (gameState === 'playing') {\n\t\tplaceBlock();\n\t}\n}\n\nfunction startGame() {\n\tsound.init();\n\tgameState = 'playing';\n\tstartScreen.classList.add('hidden');\n\tgameOverScreen.classList.add('hidden');\n\ttapHint.classList.remove('hidden');\n\tresetGame();\n}\n\ndocument.getElementById('start-btn').addEventListener('click', startGame);\ndocument.getElementById('restart-btn').addEventListener('click', startGame);\n\ncanvas.addEventListener('pointerdown', (e) => {\n\te.preventDefault();\n\tonTap();\n});\n\ndocument.addEventListener('keydown', (e) => {\n\tif (e.code === 'Space' || e.key === ' ') {\n\t\te.preventDefault();\n\t\tif (gameState === 'playing') onTap();\n\t\telse if (gameState === 'idle') startGame();\n\t\telse if (gameState === 'over' && !gameOverScreen.classList.contains('hidden')) startGame();\n\t}\n});\n\nwindow.addEventListener('resize', () => {\n\tif (!camera || !renderer) return;\n\tcamera.aspect = window.innerWidth / window.innerHeight;\n\tcamera.updateProjectionMatrix();\n\trenderer.setSize(window.innerWidth, window.innerHeight);\n});\n\n// ===== INIT =====\ninitScene();\n\nconst initialBase = createBlock(0, 0, 0, 3, 3, 0);\ngameGroup.add(initialBase);\n\nanimate();\nlucide.createIcons();"},{"name":"style.css","content":":root {\n\tcolor-scheme: dark;\n\t--bg: #1a1a2e;\n\t--surface: #16213e;\n\t--surface-2: #1a1a40;\n\t--text: #eef0ff;\n\t--text-muted: #8888aa;\n\t--accent: #e94560;\n\t--accent-glow: #ff6b81;\n\t--gold: #ffc947;\n\tfont-family: 'Syne', sans-serif;\n}\n\n* {\n\tbox-sizing: border-box;\n\tmargin: 0;\n\tpadding: 0;\n}\n\nhtml, body {\n\twidth: 100%;\n\theight: 100%;\n\toverflow: hidden;\n\tbackground: var(--bg);\n\tcolor: var(--text);\n\ttouch-action: manipulation;\n\t-webkit-tap-highlight-color: transparent;\n\tuser-select: none;\n}\n\n#game-container {\n\tposition: relative;\n\twidth: 100%;\n\theight: 100dvh;\n\toverflow: hidden;\n}\n\n#game-canvas {\n\tdisplay: block;\n\twidth: 100%;\n\theight: 100%;\n}\n\n#particle-canvas {\n\tposition: absolute;\n\tinset: 0;\n\twidth: 100%;\n\theight: 100%;\n\tpointer-events: none;\n\tz-index: 5;\n}\n\n#ui-overlay {\n\tposition: absolute;\n\tinset: 0;\n\tpointer-events: none;\n\tdisplay: flex;\n\tflex-direction: column;\n\talign-items: center;\n\tz-index: 10;\n}\n\n#score-panel {\n\tdisplay: flex;\n\tgap: 1.5rem;\n\tpadding: 1.25rem 1.5rem;\n\tmargin-top: env(safe-area-inset-top, 12px);\n}\n\n.score-block {\n\tdisplay: flex;\n\tflex-direction: column;\n\talign-items: center;\n\tbackground: rgba(22, 33, 62, 0.85);\n\tbackdrop-filter: blur(16px);\n\t-webkit-backdrop-filter: blur(16px);\n\tborder: 1px solid rgba(255, 255, 255, 0.08);\n\tborder-radius: 14px;\n\tpadding: 0.5rem 1.25rem;\n\tmin-width: 80px;\n\ttransition: transform 0.2s ease;\n}\n\n.score-block.score-bump {\n\tanimation: scoreBump 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);\n}\n\n@keyframes scoreBump {\n\t0% {\n\t\ttransform: scale(1);\n\t}\n\n\t50% {\n\t\ttransform: scale(1.15);\n\t}\n\n\t100% {\n\t\ttransform: scale(1);\n\t}\n}\n\n.score-label {\n\tfont-family: 'IBM Plex Mono', monospace;\n\tfont-size: 0.6rem;\n\tfont-weight: 600;\n\tletter-spacing: 0.15em;\n\tcolor: var(--text-muted);\n\ttext-transform: uppercase;\n}\n\n.score-value {\n\tfont-family: 'IBM Plex Mono', monospace;\n\tfont-size: 1.5rem;\n\tfont-weight: 700;\n\tcolor: var(--text);\n\tline-height: 1.2;\n}\n\n#start-screen {\n\tflex: 1;\n\tdisplay: flex;\n\tflex-direction: column;\n\talign-items: center;\n\tjustify-content: center;\n\tgap: 0.75rem;\n\tpointer-events: auto;\n}\n\n.game-title {\n\tfont-size: clamp(3.5rem, 12vw, 6rem);\n\tfont-weight: 800;\n\tletter-spacing: 0.08em;\n\tbackground: linear-gradient(135deg, var(--accent) 0%, var(--gold) 50%, #a78bfa 100%);\n\t-webkit-background-clip: text;\n\t-webkit-text-fill-color: transparent;\n\tbackground-clip: text;\n\tbackground-size: 200% 200%;\n\tline-height: 1;\n\ttext-shadow: none;\n\tanimation: titleShimmer 4s ease-in-out infinite;\n}\n\n@keyframes titleShimmer {\n\t0%, 100% {\n\t\tbackground-position: 0% 50%;\n\t\tfilter: brightness(1);\n\t}\n\n\t50% {\n\t\tbackground-position: 100% 50%;\n\t\tfilter: brightness(1.2);\n\t}\n}\n\n.game-subtitle {\n\tfont-family: 'IBM Plex Mono', monospace;\n\tfont-size: 0.85rem;\n\tcolor: var(--text-muted);\n\tletter-spacing: 0.1em;\n\tmargin-bottom: 1.5rem;\n}\n\n.btn-play {\n\tpointer-events: auto;\n\tdisplay: flex;\n\talign-items: center;\n\tgap: 0.5rem;\n\tbackground: linear-gradient(135deg, var(--accent), #c23152);\n\tcolor: white;\n\tborder: none;\n\tborder-radius: 50px;\n\tpadding: 0.85rem 2rem;\n\tfont-family: 'Syne', sans-serif;\n\tfont-size: 0.95rem;\n\tfont-weight: 700;\n\tletter-spacing: 0.12em;\n\tcursor: pointer;\n\ttransition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);\n\tbox-shadow: 0 4px 24px rgba(233, 69, 96, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.15);\n}\n\n.btn-play:hover {\n\ttransform: translateY(-3px) scale(1.03);\n\tbox-shadow: 0 8px 40px rgba(233, 69, 96, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.15);\n}\n\n.btn-play:active {\n\ttransform: translateY(0) scale(0.98);\n}\n\n.btn-play__icon {\n\twidth: 18px;\n\theight: 18px;\n}\n\n#game-over-screen {\n\tposition: absolute;\n\tinset: 0;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tpointer-events: auto;\n\tbackground: rgba(26, 26, 46, 0.7);\n\tbackdrop-filter: blur(12px);\n\t-webkit-backdrop-filter: blur(12px);\n\tanimation: fadeIn 0.5s ease;\n}\n\n.game-over-card {\n\tdisplay: flex;\n\tflex-direction: column;\n\talign-items: center;\n\tgap: 1rem;\n\tbackground: linear-gradient(145deg, var(--surface), var(--surface-2));\n\tborder: 1px solid rgba(255, 255, 255, 0.1);\n\tborder-radius: 24px;\n\tpadding: 2.5rem 3rem;\n\tbox-shadow: 0 24px 80px rgba(0, 0, 0, 0.6), 0 0 60px rgba(233, 69, 96, 0.08);\n\tanimation: cardSlideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1);\n}\n\n@keyframes cardSlideUp {\n\tfrom {\n\t\topacity: 0;\n\t\ttransform: translateY(40px) scale(0.92);\n\t}\n\n\tto {\n\t\topacity: 1;\n\t\ttransform: translateY(0) scale(1);\n\t}\n}\n\n@keyframes fadeIn {\n\tfrom {\n\t\topacity: 0;\n\t}\n\n\tto {\n\t\topacity: 1;\n\t}\n}\n\n.game-over-title {\n\tfont-size: 1.5rem;\n\tfont-weight: 800;\n\tletter-spacing: 0.1em;\n\tcolor: var(--text-muted);\n}\n\n.final-score-display {\n\tdisplay: flex;\n\tflex-direction: column;\n\talign-items: center;\n}\n\n.final-score-label {\n\tfont-family: 'IBM Plex Mono', monospace;\n\tfont-size: 0.6rem;\n\tletter-spacing: 0.15em;\n\tcolor: var(--text-muted);\n}\n\n.final-score-num {\n\tfont-family: 'IBM Plex Mono', monospace;\n\tfont-size: 3.5rem;\n\tfont-weight: 700;\n\tline-height: 1.1;\n\tbackground: linear-gradient(135deg, var(--accent), var(--gold));\n\t-webkit-background-clip: text;\n\t-webkit-text-fill-color: transparent;\n\tbackground-clip: text;\n}\n\n.new-best {\n\tfont-size: 0.85rem;\n\tfont-weight: 700;\n\tletter-spacing: 0.12em;\n\tcolor: var(--gold);\n\tanimation: bestPulse 1s ease-in-out infinite;\n}\n\n@keyframes bestPulse {\n\t0%, 100% {\n\t\topacity: 1;\n\t}\n\n\t50% {\n\t\topacity: 0.5;\n\t}\n}\n\n#tap-hint {\n\tposition: absolute;\n\tbottom: max(2rem, env(safe-area-inset-bottom, 1rem));\n\tleft: 50%;\n\ttransform: translateX(-50%);\n\tfont-family: 'IBM Plex Mono', monospace;\n\tfont-size: 0.7rem;\n\tletter-spacing: 0.2em;\n\tcolor: var(--text-muted);\n\tanimation: hintBlink 2s ease-in-out infinite;\n}\n\n@keyframes hintBlink {\n\t0%, 100% {\n\t\topacity: 0.3;\n\t}\n\n\t50% {\n\t\topacity: 0.9;\n\t}\n}\n\n#combo-display {\n\tposition: absolute;\n\ttop: 50%;\n\tleft: 50%;\n\ttransform: translate(-50%, -50%);\n\tfont-size: 2.2rem;\n\tfont-weight: 800;\n\tletter-spacing: 0.08em;\n\tpointer-events: none;\n\tz-index: 20;\n}\n\n.combo-animate {\n\tanimation: comboFlash 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;\n}\n\n@keyframes comboFlash {\n\t0% {\n\t\topacity: 0;\n\t\ttransform: translate(-50%, -50%) scale(0.3);\n\t}\n\n\t20% {\n\t\topacity: 1;\n\t\ttransform: translate(-50%, -50%) scale(1.3);\n\t}\n\n\t100% {\n\t\topacity: 0;\n\t\ttransform: translate(-50%, -65%) scale(1);\n\t}\n}\n\n.hidden {\n\tdisplay: none !important;\n}\n\n@media (max-width: 480px) {\n\t.game-over-card {\n\t\tpadding: 2rem 2rem;\n\t\tmargin: 0 1rem;\n\t}\n}"}],"folders":[]},"variants":null,"createdAt":"2026-03-01T01:17:35.439Z","updatedAt":"2026-03-01T03:35:12.525Z","hasThumbnail":true}}