| |
| |
| |
| |
|
|
| |
| const CANVAS_WIDTH = 800; |
| const CANVAS_HEIGHT = 600; |
| const GRID_SIZE = 20; |
| const COLS = CANVAS_WIDTH / GRID_SIZE; |
| const ROWS = CANVAS_HEIGHT / GRID_SIZE; |
|
|
| const POWER_TYPES = { |
| FIRE: { id: 'fire', icon: 'π₯', duration: 8000, color: '#ff4500' }, |
| ICE: { id: 'ice', icon: 'π§', duration: 8000, color: '#00ffff' }, |
| SPEED: { id: 'speed', icon: 'β‘', duration: 6000, color: '#ffff00' }, |
| SHIELD: { id: 'shield', icon: 'π‘οΈ', duration: 8000, color: '#ff00ff' }, |
| GHOST: { id: 'ghost', icon: 'π»', duration: 8000, color: '#ffffff' } |
| }; |
|
|
| const SKINS = [ |
| { id: 'classic', name: 'Classic Green', cost: 0, head: '#43523d', body: '#43523d', shadow: 'rgba(67, 82, 61, 0.5)' }, |
| { id: 'neon', name: 'Neon Cyan', cost: 50, head: '#ffffff', body: '#00ffff', shadow: 'rgba(0, 255, 255, 0.8)' }, |
| { id: 'lava', name: 'Lava Red', cost: 100, head: '#ffff00', body: '#ff4500', shadow: 'rgba(255, 69, 0, 0.8)' }, |
| { id: 'ocean', name: 'Ocean Blue', cost: 100, head: '#00ffff', body: '#0055ff', shadow: 'rgba(0, 85, 255, 0.8)' }, |
| { id: 'galaxy', name: 'Galaxy Purple', cost: 200, head: '#ff00ff', body: '#9d00ff', shadow: 'rgba(157, 0, 255, 0.8)' }, |
| { id: 'rainbow', name: 'Rainbow', cost: 300, head: '#ffffff', body: 'rainbow', shadow: 'rgba(255, 255, 255, 0.8)' } |
| ]; |
|
|
| |
| let gameState = { |
| running: false, |
| paused: false, |
| score: 0, |
| highScore: 0, |
| gems: 0, |
| level: 1, |
| combo: 1, |
| comboTimer: 0, |
| BaseSpeed: 10, |
| currentSpeed: 10, |
| activeSkin: 'classic', |
| unlockedSkins: ['classic'], |
| username: null, |
| |
| snake: [], |
| dir: { x: 1, y: 0 }, |
| nextDir: { x: 1, y: 0 }, |
| |
| food: null, |
| bonusFood: null, |
| powerUpDot: null, |
| bombs: [], |
| particles: [], |
|
|
| activePower: null, |
| powerEndTime: 0, |
| |
| lastEatTime: 0, |
| frameCount: 0, |
| lastTime: 0 |
| }; |
|
|
| |
| const canvas = document.getElementById('gameCanvas'); |
| const ctx = canvas.getContext('2d'); |
| const bootScreen = document.getElementById('boot-screen'); |
| const gameContainer = document.getElementById('game-container'); |
| const uiScore = document.getElementById('score'); |
| const uiHighScore = document.getElementById('high-score'); |
| const uiLevel = document.getElementById('level'); |
| const uiGems = document.getElementById('gems'); |
|
|
| const powerStatus = document.getElementById('power-up-status'); |
| const powerIcon = document.getElementById('power-up-icon'); |
| const powerProgress = document.getElementById('power-up-progress'); |
|
|
| const comboDisplay = document.getElementById('combo-display'); |
| const comboMultiplier = document.getElementById('combo-multiplier'); |
|
|
| const overlay = document.getElementById('overlay'); |
| const overlayPoints = document.querySelector('#overlay-points span'); |
| const btnRestart = document.getElementById('btn-restart'); |
| const btnShop = document.getElementById('btn-shop'); |
| const btnCloseShop = document.getElementById('btn-close-shop'); |
| const shopModal = document.getElementById('shop-modal'); |
| const shopGems = document.getElementById('shop-gem-count'); |
| const skinsContainer = document.getElementById('skins-container'); |
|
|
| |
| async function init() { |
| window.addEventListener('keydown', handleInput); |
| btnRestart.addEventListener('click', restartGame); |
| btnShop.addEventListener('click', openShop); |
| btnCloseShop.addEventListener('click', closeShop); |
| |
| document.getElementById('btn-login').addEventListener('click', handleLogin); |
| document.getElementById('btn-logout').addEventListener('click', handleLogout); |
|
|
| |
| setTimeout(() => { |
| bootScreen.style.opacity = '0'; |
| setTimeout(async () => { |
| bootScreen.classList.add('hidden'); |
| |
| try { |
| let res = await fetch('/api/profile'); |
| if (res.ok) { |
| let data = await res.json(); |
| loginSuccess(data); |
| } else { |
| showLoginModal(); |
| } |
| } catch(e) { |
| showLoginModal(); |
| } |
| }, 1000); |
| }, 1500); |
| } |
|
|
| function loginSuccess(data) { |
| gameState.username = data.username; |
| gameState.highScore = data.high_score; |
| gameState.gems = data.gems; |
| gameState.unlockedSkins = data.unlocked_skins || ['classic']; |
| gameState.activeSkin = data.active_skin || 'classic'; |
| |
| document.getElementById('login-modal').classList.add('hidden'); |
| document.getElementById('player-profile').classList.remove('hidden'); |
| document.getElementById('display-username').innerText = data.username; |
| |
| uiHighScore.innerText = gameState.highScore; |
| uiGems.innerText = gameState.gems; |
| |
| gameContainer.classList.remove('hidden'); |
| checkStart(); |
| } |
|
|
| async function showLoginModal() { |
| document.getElementById('login-modal').classList.remove('hidden'); |
| let list = document.getElementById('profiles-list'); |
| list.innerHTML = '<div style="color:#aaa; font-size:0.9rem">Loading profiles...</div>'; |
| try { |
| let res = await fetch('/api/users'); |
| let data = await res.json(); |
| list.innerHTML = ''; |
| if (data.users.length === 0) { |
| list.innerHTML = '<div style="color:#aaa; font-size:0.9rem">No profiles found</div>'; |
| } else { |
| data.users.forEach(u => { |
| let btn = document.createElement('div'); |
| btn.className = 'profile-btn'; |
| btn.innerHTML = `<span>${u.username}</span><span class="profile-score">π ${u.high_score}</span>`; |
| btn.onclick = () => loginWithUsername(u.username); |
| list.appendChild(btn); |
| }); |
| } |
| } catch(e) { |
| list.innerHTML = '<div style="color:#ff4500; font-size:0.9rem">Error loading profiles</div>'; |
| } |
| } |
|
|
| async function handleLogin() { |
| let username = document.getElementById('username-input').value.trim(); |
| if (!username) return; |
| await loginWithUsername(username); |
| } |
|
|
| async function loginWithUsername(username) { |
| let res = await fetch('/api/login', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({username}) |
| }); |
| if (res.ok) { |
| let pull = await fetch('/api/profile'); |
| loginSuccess(await pull.json()); |
| } |
| } |
|
|
| async function handleLogout() { |
| await fetch('/api/logout', {method: 'POST'}); |
| location.reload(); |
| } |
|
|
| async function syncProfile() { |
| if (!gameState.username) return; |
| fetch('/api/save', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({high_score: gameState.highScore, gems: gameState.gems}) |
| }); |
| } |
|
|
| function checkStart() { |
| if (!gameState.running && !overlay.classList.contains('hidden') === false) { |
| startNewGame(); |
| } |
| } |
|
|
| function startNewGame() { |
| gameState.running = true; |
| gameState.paused = false; |
| gameState.score = 0; |
| gameState.level = 1; |
| gameState.combo = 1; |
| gameState.dotsEaten = 0; |
| gameState.frameCount = 0; |
| gameState.BaseSpeed = 10; |
| gameState.currentSpeed = 10; |
| gameState.activePower = null; |
| |
| |
| gameState.snake = [ |
| {x: 10, y: 10}, |
| {x: 9, y: 10}, |
| {x: 8, y: 10} |
| ]; |
| gameState.dir = { x: 1, y: 0 }; |
| gameState.nextDir = { x: 1, y: 0 }; |
| |
| gameState.bombs = []; |
| gameState.particles = []; |
| |
| spawnFood(); |
| updateHUD(); |
| overlay.classList.add('hidden'); |
| powerStatus.classList.add('hidden'); |
| comboDisplay.classList.add('hidden'); |
| |
| requestAnimationFrame(gameLoop); |
| } |
|
|
| |
| function handleInput(e) { |
| if (!gameState.running) return; |
|
|
| if (e.key === 'p' || e.key === 'P' || e.key === ' ') { |
| gameState.paused = !gameState.paused; |
| if (!gameState.paused) { |
| requestAnimationFrame(gameLoop); |
| } |
| return; |
| } |
|
|
| if (gameState.paused) return; |
|
|
| const key = e.key; |
| if ((key === 'ArrowUp' || key === 'w' || key === 'W') && gameState.dir.y !== 1) { |
| gameState.nextDir = { x: 0, y: -1 }; |
| } else if ((key === 'ArrowDown' || key === 's' || key === 'S') && gameState.dir.y !== -1) { |
| gameState.nextDir = { x: 0, y: 1 }; |
| } else if ((key === 'ArrowLeft' || key === 'a' || key === 'A') && gameState.dir.x !== 1) { |
| gameState.nextDir = { x: -1, y: 0 }; |
| } else if ((key === 'ArrowRight' || key === 'd' || key === 'D') && gameState.dir.x !== -1) { |
| gameState.nextDir = { x: 1, y: 0 }; |
| } |
| } |
|
|
| |
| function randomPos() { |
| return { |
| x: Math.floor(Math.random() * COLS), |
| y: Math.floor(Math.random() * ROWS) |
| }; |
| } |
|
|
| function isOccupied(pos) { |
| |
| if (gameState.snake.some(segment => segment.x === pos.x && segment.y === pos.y)) return true; |
| |
| if (gameState.bombs.some(bomb => bomb.x === pos.x && bomb.y === pos.y)) return true; |
| return false; |
| } |
|
|
| function getFreePos() { |
| let pos; |
| let attempts = 0; |
| do { |
| pos = randomPos(); |
| attempts++; |
| } while (isOccupied(pos) && attempts < 100); |
| return pos; |
| } |
|
|
| function spawnFood() { |
| gameState.food = getFreePos(); |
| gameState.food.golden = Math.random() < 0.2; |
| } |
|
|
| function spawnBonusFood() { |
| if (!gameState.bonusFood && Math.random() < 0.3) { |
| gameState.bonusFood = getFreePos(); |
| gameState.bonusFood.timeLeft = 300; |
| gameState.bonusFood.dir = {x: 0, y: 0}; |
| } |
| } |
|
|
| function spawnPowerUp() { |
| if (!gameState.powerUpDot && Math.random() < 0.2) { |
| let pos = getFreePos(); |
| let types = Object.values(POWER_TYPES); |
| let type = types[Math.floor(Math.random() * types.length)]; |
| gameState.powerUpDot = { ...pos, type: type, timeLeft: 500 }; |
| } |
| } |
|
|
| function spawnBomb() { |
| if (gameState.level < 2) return; |
| |
| |
| if (gameState.bombs.length > 2 && Math.random() < 0.5) { |
| gameState.bombs.shift(); |
| } |
| |
| if (gameState.bombs.length < 5) { |
| let bomb = getFreePos(); |
| bomb.timer = 0; |
| gameState.bombs.push(bomb); |
| } |
| } |
|
|
| |
| function activatePowerUp(type) { |
| gameState.activePower = type; |
| gameState.powerEndTime = Date.now() + type.duration; |
| |
| powerStatus.classList.remove('hidden'); |
| powerIcon.innerText = type.icon; |
| |
| if (type.id === 'speed') { |
| gameState.currentSpeed = Math.max(2, Math.floor(gameState.BaseSpeed / 2)); |
| } |
| |
| createParticles(gameState.snake[0].x * GRID_SIZE + GRID_SIZE/2, |
| gameState.snake[0].y * GRID_SIZE + GRID_SIZE/2, |
| type.color, 30); |
| } |
|
|
| function processTimers() { |
| |
| if (gameState.activePower) { |
| let remain = gameState.powerEndTime - Date.now(); |
| if (remain <= 0) { |
| if (gameState.activePower.id === 'speed') { |
| gameState.currentSpeed = gameState.BaseSpeed; |
| } |
| gameState.activePower = null; |
| powerStatus.classList.add('hidden'); |
| } else { |
| let pct = (remain / gameState.activePower.duration) * 100; |
| powerProgress.style.transform = `scaleX(${pct/100})`; |
| } |
| } |
| |
| |
| if (Date.now() - gameState.lastEatTime > 2000) { |
| gameState.combo = 1; |
| comboDisplay.classList.add('hidden'); |
| } |
| |
| |
| if (gameState.frameCount % 60 === 0) { |
| if (Math.random() < 0.1) spawnBonusFood(); |
| if (Math.random() < 0.05) spawnPowerUp(); |
| if (gameState.level >= 2 && Math.random() < 0.2) spawnBomb(); |
| } |
| |
| |
| if (gameState.bonusFood) { |
| gameState.bonusFood.timeLeft--; |
| if (gameState.bonusFood.timeLeft <= 0) { |
| gameState.bonusFood = null; |
| } |
| } |
| |
| |
| if (gameState.powerUpDot) { |
| gameState.powerUpDot.timeLeft--; |
| if (gameState.powerUpDot.timeLeft <= 0) gameState.powerUpDot = null; |
| } |
| } |
|
|
| function update() { |
| gameState.dir = gameState.nextDir; |
| |
| let head = { ...gameState.snake[0] }; |
| head.x += gameState.dir.x; |
| head.y += gameState.dir.y; |
| |
| |
| if (head.x < 0) head.x = COLS - 1; |
| if (head.x >= COLS) head.x = 0; |
| if (head.y < 0) head.y = ROWS - 1; |
| if (head.y >= ROWS) head.y = 0; |
| |
| |
| let ghostActive = gameState.activePower && gameState.activePower.id === 'ghost'; |
| let shieldActive = gameState.activePower && gameState.activePower.id === 'shield'; |
| |
| if (!ghostActive) { |
| for (let i = 0; i < gameState.snake.length; i++) { |
| if (head.x === gameState.snake[i].x && head.y === gameState.snake[i].y) { |
| if (shieldActive) { |
| |
| gameState.activePower = null; |
| createParticles(head.x*GRID_SIZE, head.y*GRID_SIZE, '#ffffff', 20); |
| |
| if (gameState.snake.length > 5) { |
| gameState.snake.splice(gameState.snake.length - 2, 2); |
| } |
| continue; |
| } else { |
| gameOver(); |
| return; |
| } |
| } |
| } |
| } |
| |
| gameState.snake.unshift(head); |
| let ate = false; |
|
|
| |
| let iceActive = gameState.activePower && gameState.activePower.id === 'ice'; |
| if (iceActive && gameState.food) { |
| let dx = Math.abs(head.x - gameState.food.x); |
| let dy = Math.abs(head.y - gameState.food.y); |
| if (dx <= 2 && dy <= 2) { |
| head.x = gameState.food.x; |
| head.y = gameState.food.y; |
| |
| gameState.snake[0] = head; |
| } |
| } |
| |
| |
| if (head.x === gameState.food.x && head.y === gameState.food.y) { |
| ate = true; |
| let pnts = gameState.food.golden ? 25 : 10; |
| |
| |
| let now = Date.now(); |
| if (now - gameState.lastEatTime <= 2000) { |
| gameState.combo++; |
| comboDisplay.classList.remove('hidden'); |
| comboDisplay.style.animation = 'none'; |
| void comboDisplay.offsetWidth; |
| comboDisplay.style.animation = null; |
| comboMultiplier.innerText = 'x' + gameState.combo; |
| } else { |
| gameState.combo = 1; |
| comboDisplay.classList.add('hidden'); |
| } |
| gameState.lastEatTime = now; |
| |
| gameState.dotsEaten = (gameState.dotsEaten || 0) + 1; |
| if (gameState.dotsEaten % 3 === 0) { |
| |
| gameState.bombs.forEach(b => createParticles(b.x * GRID_SIZE + GRID_SIZE/2, b.y * GRID_SIZE + GRID_SIZE/2, '#ff0000', 15)); |
| gameState.bombs = []; |
| } |
| |
| addScore(pnts * gameState.combo); |
| createParticles(head.x * GRID_SIZE + GRID_SIZE/2, head.y * GRID_SIZE + GRID_SIZE/2, gameState.food.golden ? '#ffd700' : '#39ff14', 15); |
| spawnFood(); |
| playSound('eat'); |
| } |
| |
| |
| if (gameState.bonusFood && head.x === gameState.bonusFood.x && head.y === gameState.bonusFood.y) { |
| ate = true; |
| addScore(50); |
| addGems(5); |
| createParticles(head.x * GRID_SIZE + GRID_SIZE/2, head.y * GRID_SIZE + GRID_SIZE/2, '#00ffff', 30); |
| gameState.bonusFood = null; |
| playSound('bonus'); |
| } |
| |
| |
| if (gameState.powerUpDot && head.x === gameState.powerUpDot.x && head.y === gameState.powerUpDot.y) { |
| activatePowerUp(gameState.powerUpDot.type); |
| gameState.powerUpDot = null; |
| playSound('powerup'); |
| } |
| |
| if (!ate) { |
| gameState.snake.pop(); |
| } |
| |
| |
| let fireActive = gameState.activePower && gameState.activePower.id === 'fire'; |
| for (let i = gameState.bombs.length - 1; i >= 0; i--) { |
| let b = gameState.bombs[i]; |
| if (head.x === b.x && head.y === b.y) { |
| if (fireActive || shieldActive) { |
| |
| createParticles(b.x * GRID_SIZE, b.y * GRID_SIZE, '#ff4500', 40); |
| gameState.bombs.splice(i, 1); |
| if (shieldActive) gameState.activePower = null; |
| } else { |
| |
| addScore(-30); |
| createParticles(b.x * GRID_SIZE, b.y * GRID_SIZE, '#ff0000', 50); |
| gameState.bombs.splice(i, 1); |
| screenShake(); |
| playSound('bomb'); |
| } |
| } |
| } |
| } |
|
|
| function addScore(points) { |
| gameState.score = Math.max(0, gameState.score + points); |
| |
| |
| let newLvl = Math.floor(gameState.score / 100) + 1; |
| if (newLvl > gameState.level) { |
| gameState.level = newLvl; |
| if (gameState.BaseSpeed > 4) gameState.BaseSpeed -= 0.5; |
| if (!gameState.activePower || gameState.activePower.id !== 'speed') { |
| gameState.currentSpeed = Math.floor(gameState.BaseSpeed); |
| } |
| createParticles(CANVAS_WIDTH/2, CANVAS_HEIGHT/2, '#ffffff', 100); |
| playSound('levelup'); |
| } |
| |
| updateHUD(); |
| } |
|
|
| function addGems(amt) { |
| gameState.gems += amt; |
| updateHUD(); |
| syncProfile(); |
| } |
|
|
| function updateHUD() { |
| uiScore.innerText = gameState.score; |
| uiLevel.innerText = gameState.level; |
| uiGems.innerText = gameState.gems; |
| if (gameState.score > gameState.highScore) { |
| gameState.highScore = gameState.score; |
| uiHighScore.innerText = gameState.highScore; |
| } |
| } |
|
|
| function gameOver() { |
| gameState.running = false; |
| overlay.classList.remove('hidden'); |
| overlayPoints.innerText = gameState.score; |
| playSound('gameover'); |
| syncProfile(); |
| } |
|
|
| |
| function screenShake() { |
| gameContainer.classList.add('shake', 'flash-red'); |
| setTimeout(() => { |
| gameContainer.classList.remove('shake', 'flash-red'); |
| }, 500); |
| } |
|
|
| function createParticles(x, y, color, count) { |
| for (let i = 0; i < count; i++) { |
| gameState.particles.push({ |
| x: x, |
| y: y, |
| vx: (Math.random() - 0.5) * 10, |
| vy: (Math.random() - 0.5) * 10, |
| life: 1, |
| color: color |
| }); |
| } |
| } |
|
|
| function updateParticles() { |
| for (let i = gameState.particles.length - 1; i >= 0; i--) { |
| let p = gameState.particles[i]; |
| p.x += p.vx; |
| p.y += p.vy; |
| p.life -= 0.05; |
| if (p.life <= 0) { |
| gameState.particles.splice(i, 1); |
| } |
| } |
| } |
|
|
| function playSound(type) { |
| |
| try { |
| const AudioContext = window.AudioContext || window.webkitAudioContext; |
| const actx = new AudioContext(); |
| const osc = actx.createOscillator(); |
| const gainNode = actx.createGain(); |
| osc.connect(gainNode); |
| gainNode.connect(actx.destination); |
| |
| if (type === 'eat') { |
| osc.type = 'sine'; |
| osc.frequency.setValueAtTime(800, actx.currentTime); |
| osc.frequency.exponentialRampToValueAtTime(1200, actx.currentTime + 0.1); |
| gainNode.gain.setValueAtTime(0.1, actx.currentTime); |
| gainNode.gain.exponentialRampToValueAtTime(0.01, actx.currentTime + 0.1); |
| osc.start(); |
| osc.stop(actx.currentTime + 0.1); |
| } else if (type === 'bomb') { |
| osc.type = 'square'; |
| osc.frequency.setValueAtTime(100, actx.currentTime); |
| osc.frequency.exponentialRampToValueAtTime(50, actx.currentTime + 0.3); |
| gainNode.gain.setValueAtTime(0.3, actx.currentTime); |
| gainNode.gain.exponentialRampToValueAtTime(0.01, actx.currentTime + 0.3); |
| osc.start(); |
| osc.stop(actx.currentTime + 0.3); |
| } else if (type === 'powerup') { |
| osc.type = 'triangle'; |
| osc.frequency.setValueAtTime(400, actx.currentTime); |
| osc.frequency.linearRampToValueAtTime(800, actx.currentTime + 0.2); |
| gainNode.gain.setValueAtTime(0.1, actx.currentTime); |
| gainNode.gain.linearRampToValueAtTime(0.01, actx.currentTime + 0.2); |
| osc.start(); |
| osc.stop(actx.currentTime + 0.2); |
| } |
| } catch(e) {} |
| } |
|
|
| |
| function gameLoop(timestamp) { |
| if (!gameState.running || gameState.paused) return; |
|
|
| requestAnimationFrame(gameLoop); |
|
|
| |
| gameState.frameCount++; |
| if (gameState.frameCount % Math.floor(gameState.currentSpeed) === 0) { |
| update(); |
| } |
| |
| |
| processTimers(); |
| updateParticles(); |
| |
| draw(); |
| } |
|
|
| function draw() { |
| |
| ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); |
| |
| |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)'; |
| ctx.lineWidth = 1; |
| for (let i = 0; i <= CANVAS_WIDTH; i += GRID_SIZE) { |
| ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, CANVAS_HEIGHT); ctx.stroke(); |
| } |
| for (let i = 0; i <= CANVAS_HEIGHT; i += GRID_SIZE) { |
| ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(CANVAS_WIDTH, i); ctx.stroke(); |
| } |
|
|
| |
| let fireActive = gameState.activePower && gameState.activePower.id === 'fire'; |
| gameState.bombs.forEach(b => { |
| b.timer = (b.timer || 0) + 0.1; |
| let scale = 1 + Math.sin(b.timer) * 0.1; |
| |
| ctx.save(); |
| ctx.translate(b.x * GRID_SIZE + GRID_SIZE/2, b.y * GRID_SIZE + GRID_SIZE/2); |
| ctx.scale(scale, scale); |
| |
| if (fireActive) { |
| ctx.fillStyle = 'rgba(50, 50, 50, 0.5)'; |
| ctx.beginPath(); |
| ctx.arc(0, 0, GRID_SIZE/2 - 2, 0, Math.PI*2); |
| ctx.fill(); |
| } else { |
| ctx.fillStyle = '#111'; |
| ctx.shadowColor = '#ff0000'; |
| ctx.shadowBlur = 10; |
| ctx.beginPath(); |
| ctx.arc(0, 0, GRID_SIZE/2 - 2, 0, Math.PI*2); |
| ctx.fill(); |
| |
| |
| ctx.fillStyle = '#ff0000'; |
| ctx.beginPath(); |
| ctx.arc(-3, -3, 2, 0, Math.PI*2); |
| ctx.fill(); |
| } |
| ctx.restore(); |
| }); |
|
|
| |
| if (gameState.powerUpDot) { |
| ctx.save(); |
| ctx.translate(gameState.powerUpDot.x * GRID_SIZE + GRID_SIZE/2, gameState.powerUpDot.y * GRID_SIZE + GRID_SIZE/2); |
| ctx.shadowColor = gameState.powerUpDot.type.color; |
| ctx.shadowBlur = 15; |
| |
| let t = Date.now() / 200; |
| let offset = Math.sin(t) * 3; |
| ctx.translate(0, offset); |
| |
| ctx.fillStyle = gameState.powerUpDot.type.color; |
| ctx.beginPath(); |
| ctx.arc(0, 0, GRID_SIZE/2.5, 0, Math.PI*2); |
| ctx.fill(); |
| |
| |
| ctx.fillStyle = '#fff'; |
| ctx.beginPath(); |
| ctx.arc(0, 0, GRID_SIZE/5, 0, Math.PI*2); |
| ctx.fill(); |
| ctx.restore(); |
| } |
|
|
| |
| if (gameState.bonusFood) { |
| ctx.save(); |
| ctx.translate(gameState.bonusFood.x * GRID_SIZE + GRID_SIZE/2, gameState.bonusFood.y * GRID_SIZE + GRID_SIZE/2); |
| ctx.shadowColor = '#00ffff'; |
| ctx.shadowBlur = 20; |
| ctx.fillStyle = '#0ff'; |
| |
| ctx.beginPath(); |
| ctx.moveTo(0, -GRID_SIZE/2); |
| ctx.lineTo(GRID_SIZE/2, 0); |
| ctx.lineTo(0, GRID_SIZE/2); |
| ctx.lineTo(-GRID_SIZE/2, 0); |
| ctx.fill(); |
| ctx.restore(); |
| } |
|
|
| |
| if (gameState.food) { |
| ctx.save(); |
| ctx.translate(gameState.food.x * GRID_SIZE + GRID_SIZE/2, gameState.food.y * GRID_SIZE + GRID_SIZE/2); |
| ctx.shadowColor = gameState.food.golden ? '#ffd700' : '#39ff14'; |
| ctx.shadowBlur = 15; |
| ctx.fillStyle = gameState.food.golden ? '#ffd700' : '#39ff14'; |
| |
| |
| let scale = 1 + Math.sin(Date.now() / 150) * 0.15; |
| ctx.scale(scale, scale); |
| |
| ctx.beginPath(); |
| ctx.arc(0, 0, GRID_SIZE/2 - 2, 0, Math.PI*2); |
| ctx.fill(); |
| ctx.restore(); |
| } |
|
|
| |
| let skin = SKINS.find(s => s.id === gameState.activeSkin) || SKINS[0]; |
| let isGhost = gameState.activePower && gameState.activePower.id === 'ghost'; |
| let shieldActive = gameState.activePower && gameState.activePower.id === 'shield'; |
| |
| gameState.snake.forEach((segment, index) => { |
| let x = segment.x * GRID_SIZE; |
| let y = segment.y * GRID_SIZE; |
| |
| |
| let bColor = skin.body; |
| if (skin.id === 'rainbow') { |
| bColor = `hsl(${(Date.now()/10 + index*10) % 360}, 100%, 50%)`; |
| } |
| |
| if (gameState.activePower) { |
| |
| if (isGhost) bColor = 'rgba(255, 255, 255, 0.4)'; |
| } |
|
|
| ctx.fillStyle = index === 0 ? (isGhost ? 'rgba(255,255,255,0.7)' : skin.head) : bColor; |
| |
| ctx.shadowColor = skin.shadow; |
| ctx.shadowBlur = index === 0 ? 15 : 5; |
| |
| if (shieldActive) { |
| ctx.shadowColor = '#ff00ff'; |
| ctx.shadowBlur = 20; |
| } |
|
|
| |
| ctx.beginPath(); |
| ctx.roundRect(x + 1, y + 1, GRID_SIZE - 2, GRID_SIZE - 2, index === 0 ? 8 : 4); |
| ctx.fill(); |
| |
| |
| if (index === 0 && !isGhost) { |
| ctx.fillStyle = '#000'; |
| ctx.shadowBlur = 0; |
| |
| let ex1, ey1, ex2, ey2; |
| let es = 3; |
| if (gameState.dir.x === 1) { |
| ex1 = x + 14; ey1 = y + 4; ex2 = x + 14; ey2 = y + 12; |
| } else if (gameState.dir.x === -1) { |
| ex1 = x + 4; ey1 = y + 4; ex2 = x + 4; ey2 = y + 12; |
| } else if (gameState.dir.y === 1) { |
| ex1 = x + 4; ey1 = y + 14; ex2 = x + 12; ey2 = y + 14; |
| } else { |
| ex1 = x + 4; ey1 = y + 4; ex2 = x + 12; ey2 = y + 4; |
| } |
| ctx.beginPath(); ctx.arc(ex1, ey1, es, 0, Math.PI*2); ctx.fill(); |
| ctx.beginPath(); ctx.arc(ex2, ey2, es, 0, Math.PI*2); ctx.fill(); |
| } |
| }); |
|
|
| |
| gameState.particles.forEach(p => { |
| ctx.fillStyle = p.color; |
| ctx.globalAlpha = p.life; |
| ctx.beginPath(); |
| ctx.arc(p.x, p.y, Math.max(0.1, p.life * 4), 0, Math.PI*2); |
| ctx.fill(); |
| ctx.globalAlpha = 1.0; |
| }); |
| |
| |
| if (gameState.activePower && gameState.activePower.id === 'fire' && gameState.frameCount % 2 === 0) { |
| let tail = gameState.snake[gameState.snake.length-1]; |
| createParticles(tail.x * GRID_SIZE + GRID_SIZE/2, tail.y * GRID_SIZE + GRID_SIZE/2, '#ff4500', 1); |
| } |
| } |
|
|
| |
| function openShop() { |
| shopModal.classList.remove('hidden'); |
| shopGems.innerText = gameState.gems; |
| renderSkins(); |
| } |
|
|
| function closeShop() { |
| shopModal.classList.add('hidden'); |
| } |
|
|
| function renderSkins() { |
| skinsContainer.innerHTML = ''; |
| SKINS.forEach(skin => { |
| const isUnlocked = gameState.unlockedSkins.includes(skin.id); |
| const isEquipped = gameState.activeSkin === skin.id; |
| const affordable = !isUnlocked && gameState.gems >= skin.cost; |
|
|
| const card = document.createElement('div'); |
| card.className = 'skin-card'; |
| |
| let previewStyle = ''; |
| if (skin.id === 'rainbow') { |
| previewStyle = 'background: linear-gradient(90deg, red, orange, yellow, green, blue, purple);'; |
| } else { |
| previewStyle = `background: ${skin.body};`; |
| } |
|
|
| card.innerHTML = ` |
| <div class="skin-preview"> |
| <div class="skin-preview-snake" style="${previewStyle} box-shadow: 0 0 10px ${skin.shadow};"></div> |
| </div> |
| <div class="skin-name">${skin.name}</div> |
| <div style="font-size: 0.8rem; margin-bottom: 10px; color: #aaa;"> |
| ${isUnlocked ? 'Purchased' : 'π ' + skin.cost} |
| </div> |
| <button class="btn-equip ${isEquipped ? 'equipped' : (affordable ? 'affordable' : '')}" |
| data-id="${skin.id}" |
| ${!isUnlocked && !affordable ? 'disabled' : ''}> |
| ${isEquipped ? 'Equipped' : (isUnlocked ? 'Equip' : 'Buy')} |
| </button> |
| `; |
| skinsContainer.appendChild(card); |
| }); |
|
|
| |
| document.querySelectorAll('.btn-equip').forEach(btn => { |
| btn.addEventListener('click', (e) => { |
| const id = e.target.getAttribute('data-id'); |
| const skin = SKINS.find(s => s.id === id); |
| |
| if (gameState.unlockedSkins.includes(id)) { |
| |
| fetch('/api/equip', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({skin_id: id}) |
| }).then(() => { |
| gameState.activeSkin = id; |
| renderSkins(); |
| }); |
| } else if (gameState.gems >= skin.cost) { |
| |
| fetch('/api/buy', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({skin_id: id, cost: skin.cost}) |
| }).then(res => res.json()).then(data => { |
| if (data.success) { |
| gameState.gems = data.gems; |
| gameState.unlockedSkins = data.unlocked_skins; |
| gameState.activeSkin = id; |
| fetch('/api/equip', { |
| method: 'POST', |
| headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({skin_id: id}) |
| }); |
| renderSkins(); |
| shopGems.innerText = gameState.gems; |
| uiGems.innerText = gameState.gems; |
| } |
| }); |
| } |
| }); |
| }); |
| } |
|
|
| function restartGame() { |
| startNewGame(); |
| } |
|
|
| |
| window.onload = init; |
|
|