NeonViper / static /js /game.js
Mr-TD's picture
docs: Add a comprehensive README detailing the Neon Viper game's architecture, logic, and functionality.
7cfb1a8
/**
* Neon Viper 2026
* Core Game Engine
*/
// --- Constants & Config ---
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)' }
];
// --- State ---
let gameState = {
running: false,
paused: false,
score: 0,
highScore: 0,
gems: 0,
level: 1,
combo: 1,
comboTimer: 0,
BaseSpeed: 10, // Frames per update
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
};
// --- DOM Elements ---
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');
// --- Initialization ---
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);
// Fade boot screen first
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;
// Reset snake
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);
}
// --- Input Handling ---
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 };
}
}
// --- Spawning Logic ---
function randomPos() {
return {
x: Math.floor(Math.random() * COLS),
y: Math.floor(Math.random() * ROWS)
};
}
function isOccupied(pos) {
// Check snake
if (gameState.snake.some(segment => segment.x === pos.x && segment.y === pos.y)) return true;
// Check bombs
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; // 20% chance for golden food
}
function spawnBonusFood() {
if (!gameState.bonusFood && Math.random() < 0.3) {
gameState.bonusFood = getFreePos();
gameState.bonusFood.timeLeft = 300; // 5 seconds (at 60fps)
gameState.bonusFood.dir = {x: 0, y: 0}; // Starts stationary
}
}
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;
// Clear old bombs randomly
if (gameState.bombs.length > 2 && Math.random() < 0.5) {
gameState.bombs.shift();
}
if (gameState.bombs.length < 5) {
let bomb = getFreePos();
bomb.timer = 0; // for pulse animation
gameState.bombs.push(bomb);
}
}
// --- Game Logic ---
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() {
// Powerups
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})`;
}
}
// Combo
if (Date.now() - gameState.lastEatTime > 2000) {
gameState.combo = 1;
comboDisplay.classList.add('hidden');
}
// Spawners (run every ~1s)
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();
}
// Bonus food decay
if (gameState.bonusFood) {
gameState.bonusFood.timeLeft--;
if (gameState.bonusFood.timeLeft <= 0) {
gameState.bonusFood = null;
}
}
// PowerUp decay
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;
// Constraints / Wrap
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;
// Collision with self
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) {
// Shield breaks but saves you
gameState.activePower = null;
createParticles(head.x*GRID_SIZE, head.y*GRID_SIZE, '#ffffff', 20);
// Pop end of snake a bit
if (gameState.snake.length > 5) {
gameState.snake.splice(gameState.snake.length - 2, 2);
}
continue;
} else {
gameOver();
return;
}
}
}
}
gameState.snake.unshift(head);
let ate = false;
// Ice Magnet Effect
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;
// Unshift again to correct position
gameState.snake[0] = head;
}
}
// Check eat normal food
if (head.x === gameState.food.x && head.y === gameState.food.y) {
ate = true;
let pnts = gameState.food.golden ? 25 : 10;
// Combo logic
let now = Date.now();
if (now - gameState.lastEatTime <= 2000) {
gameState.combo++;
comboDisplay.classList.remove('hidden');
comboDisplay.style.animation = 'none';
void comboDisplay.offsetWidth; // trigger reflow
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) {
// Clear all bombs every 3 dots
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');
}
// Check eat bonus food
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');
}
// Check powerup
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();
}
// Check bomb collision
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) {
// Destroy bomb without penalty
createParticles(b.x * GRID_SIZE, b.y * GRID_SIZE, '#ff4500', 40);
gameState.bombs.splice(i, 1);
if (shieldActive) gameState.activePower = null;
} else {
// Hit bomb
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);
// Level up check
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();
}
// --- Visual & Audio Effects ---
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) {
// Simple synth tones using Web Audio API if available
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) {}
}
// --- Main Loop & Rendering ---
function gameLoop(timestamp) {
if (!gameState.running || gameState.paused) return;
requestAnimationFrame(gameLoop);
// Throttle logic update based on speed
gameState.frameCount++;
if (gameState.frameCount % Math.floor(gameState.currentSpeed) === 0) {
update();
}
// Always update these per frame for smooth animation
processTimers();
updateParticles();
draw();
}
function draw() {
// Clear
ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// Grid Lines (subtle)
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();
}
// Bombs
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();
// Spark
ctx.fillStyle = '#ff0000';
ctx.beginPath();
ctx.arc(-3, -3, 2, 0, Math.PI*2);
ctx.fill();
}
ctx.restore();
});
// PowerUp Dot
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();
// Internal glow
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(0, 0, GRID_SIZE/5, 0, Math.PI*2);
ctx.fill();
ctx.restore();
}
// Bonus Food
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';
// Diamond shape
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();
}
// Food
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';
// Pulse Effect
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();
}
// Snake
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;
// Rainbow mode effect
let bColor = skin.body;
if (skin.id === 'rainbow') {
bColor = `hsl(${(Date.now()/10 + index*10) % 360}, 100%, 50%)`;
}
if (gameState.activePower) {
// Override styles for powerups somewhat
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;
}
// Draw rounded rectangle
ctx.beginPath();
ctx.roundRect(x + 1, y + 1, GRID_SIZE - 2, GRID_SIZE - 2, index === 0 ? 8 : 4);
ctx.fill();
// Eyes for head
if (index === 0 && !isGhost) {
ctx.fillStyle = '#000';
ctx.shadowBlur = 0;
// determine eye position based on direction
let ex1, ey1, ex2, ey2;
let es = 3; // eye size
if (gameState.dir.x === 1) { // right
ex1 = x + 14; ey1 = y + 4; ex2 = x + 14; ey2 = y + 12;
} else if (gameState.dir.x === -1) { // left
ex1 = x + 4; ey1 = y + 4; ex2 = x + 4; ey2 = y + 12;
} else if (gameState.dir.y === 1) { // down
ex1 = x + 4; ey1 = y + 14; ex2 = x + 12; ey2 = y + 14;
} else { // up
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();
}
});
// Particles
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;
});
// Fire trail
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);
}
}
// --- Shop Logic ---
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);
});
// Add listeners
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)) {
// Equip
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) {
// Buy
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();
}
// Start
window.onload = init;