From b78f78a144b853de9fccd37bd8845c2ab577379b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 10:54:03 +0000 Subject: [PATCH] feat: Enhance game animations and effects This commit introduces a major visual overhaul to the game, focusing on improving animations, details, and effects to create a more polished and engaging user experience. Key improvements include: - **Smoother Snake Movement:** The snake now moves smoothly between grid cells using GSAP tweens, replacing the previous snapping motion. - **Squash and Stretch Effect:** A subtle squash-and-stretch animation has been added to the snake's head when it changes direction, adding a sense of personality and responsiveness. - **Enhanced Particle Effects:** The particle explosion when the snake eats food is now more dynamic, with a greater variety of particle sizes, colors, and lifespans. - **Pulsating Bonus Food:** The bonus food now has a pulsating glow effect to make it more visually appealing and noticeable. - **Improved Game Over Sequence:** The game over sequence has been significantly improved with a new animation where the snake flashes red and then breaks apart into particles. - **Refactored Game Loop:** The main game loop has been refactored to use `gsap.ticker`, which provides a more robust and consistent animation loop. --- public/bonusFood.js | 30 ++++++++++-- public/game.js | 103 ++++++++++++++++++++------------------- public/particles.js | 18 ++++--- public/snake.js | 115 ++++++++++++++++++++++++++++++++++++-------- 4 files changed, 185 insertions(+), 81 deletions(-) diff --git a/public/bonusFood.js b/public/bonusFood.js index e1ad3f1..4b49856 100644 --- a/public/bonusFood.js +++ b/public/bonusFood.js @@ -6,17 +6,38 @@ export class BonusFood extends Food { super(gridSize, canvasSize); this.active = false; this.expiresAt = 0; + this.glow = 1; + this._tween = null; } spawn(gridSize, canvasSize, lifetimeMs = 8000) { this.respawn(gridSize, canvasSize); this.active = true; this.expiresAt = performance.now() + lifetimeMs; + + if (this._tween) this._tween.kill(); + this.glow = 1; + this._tween = gsap.to(this, { + glow: 1.5, + duration: 0.7, + ease: 'power1.inOut', + yoyo: true, + repeat: -1 + }); + } + + deactivate() { + this.active = false; + if (this._tween) { + this._tween.kill(); + this._tween = null; + } + this.glow = 1; } maybeExpire(now) { if (this.active && now >= this.expiresAt) { - this.active = false; + this.deactivate(); } } @@ -28,14 +49,15 @@ export class BonusFood extends Food { const size = gridSize - 4; // Glow + const glowSize = size * 1.2 * this.glow; const glow = ctx.createRadialGradient( x + size/2 + 2, y + size/2 + 2, 0, - x + size/2 + 2, y + size/2 + 2, size * 1.6 + x + size/2 + 2, y + size/2 + 2, glowSize ); - glow.addColorStop(0, 'rgba(255, 215, 0, 0.35)'); + glow.addColorStop(0, 'rgba(255, 215, 0, 0.45)'); glow.addColorStop(1, 'rgba(255, 215, 0, 0)'); ctx.fillStyle = glow; - ctx.fillRect(x - size/2, y - size/2, size * 2, size * 2); + ctx.fillRect(x + size/2 + 2 - glowSize, y + size/2 + 2 - glowSize, glowSize * 2, glowSize * 2); // Main golden square const gradient = ctx.createLinearGradient(x, y, x + size, y + size); diff --git a/public/game.js b/public/game.js index fe5217d..8963b8f 100644 --- a/public/game.js +++ b/public/game.js @@ -62,7 +62,6 @@ class Game { closeLeaderboardBtn.addEventListener('click', () => { leaderboardModal.style.display = 'none'; this.isPaused = false; - requestAnimationFrame(this.gameLoop); }); if (closeHighScoreBtn) { @@ -116,6 +115,7 @@ class Game { this.gridSize = 20; this.isPaused = false; + this.isGameOver = false; // Create game objects this.snake = new Snake(); @@ -137,6 +137,7 @@ class Game { this.interval = 200; // update interval in ms this.accumulator = 0; this._lastFrameTs = 0; + this._lastGameTimestamp = 0; // Intro animation (respect reduced motion) if (!this.shouldReduceMotion()) { @@ -194,7 +195,7 @@ class Game { this.overlay.appendChild(this.pauseLayer); this.scheduleBonus(); - requestAnimationFrame(this.gameLoop); + gsap.ticker.add(this.gameLoop); this.drawGrid(); // Mark player as playing now (presence) this.onlinePlayersManager.updatePlayerStatus(true); @@ -219,34 +220,27 @@ class Game { } } - gameLoop(timestamp) { - if (this.isPaused) return; + gameLoop(time, deltaTime, frame) { + if (this.isPaused && !this.isGameOver) return; - if (!this.lastTime) this.lastTime = timestamp; - const delta = timestamp - this.lastTime; - this.accumulator += delta; - const frameDelta = timestamp - (this._lastFrameTs || timestamp); - this._lastFrameTs = timestamp; - - if (this.accumulator >= this.interval) { + // Update game logic at a fixed interval + if (!this.isGameOver && time - this._lastGameTimestamp >= this.interval) { this.update(); - this.accumulator = 0; + this._lastGameTimestamp = time; } - // Update particles every frame - this.particles.update(frameDelta); + this.particles.update(deltaTime); this.draw(); - this.lastTime = timestamp; - requestAnimationFrame(this.gameLoop); } update() { if (this.isPaused) return; - this.snake.update(); + this.snake.update(this.interval); // Check if snake ate normal food - if (this.snake.body[0].x === this.food.position.x && this.snake.body[0].y === this.food.position.y) { + const head = this.snake.body[0]; + if (head.x === this.food.position.x && head.y === this.food.position.y) { this.snake.grow(); this.scoreManager.incrementScore(); this.food.respawn(this.gridSize, { width: this.canvas.width, height: this.canvas.height }); @@ -274,7 +268,7 @@ class Game { for (let i = 0; i < 5; i++) this.snake.grow(); this.scoreManager.score += 5; this.updateScoreUI(); - this.bonusFood.active = false; + this.bonusFood.deactivate(); if (this.vibrationEnabled && navigator.vibrate) navigator.vibrate([20, 40, 20]); this.spawnEatParticles('#ffd700'); this.showFloatingText('+5', '#ffd700'); @@ -310,7 +304,6 @@ class Game { if (e.key === ' ') { this.isPaused = !this.isPaused; if (this.pauseLayer) this.pauseLayer.style.display = this.isPaused ? 'flex' : 'none'; - if (!this.isPaused) requestAnimationFrame(this.gameLoop); return; } @@ -343,47 +336,59 @@ class Game { } async gameOver() { + if (this.isGameOver) return; + this.isGameOver = true; this.isPaused = true; // Update player status in presence this.onlinePlayersManager.updatePlayerStatus(false); - if (!this.shouldReduceMotion()) { - gsap.to(this.canvas, { - duration: 0.5, - opacity: 0.5, - scale: 0.95, - ease: "power2.out" - }); - } if (this.vibrationEnabled && navigator.vibrate) navigator.vibrate(120); - // If needed, save high score - if (this.scoreManager.score > 0 && this.leaderboardManager.isHighScore(this.scoreManager.score)) { - try { - const rank = await this.leaderboardManager.saveScore( - this.leaderboardManager.currentPlayer, - this.scoreManager.score - ); - if (rank) { - // Show non-blocking modal instead of alert - const modal = document.getElementById('highScoreModal'); - const body = document.getElementById('highScoreBody'); - if (modal && body) { - body.innerHTML = `#${rank} — ${this.escapeHtml(this.leaderboardManager.currentPlayerName || 'Player')} — ${this.scoreManager.score}`; - modal.style.display = 'flex'; + const proceed = async () => { + // If needed, save high score + if (this.scoreManager.score > 0 && this.leaderboardManager.isHighScore(this.scoreManager.score)) { + try { + const rank = await this.leaderboardManager.saveScore( + this.leaderboardManager.currentPlayer, + this.scoreManager.score + ); + if (rank) { + // Show non-blocking modal instead of alert + const modal = document.getElementById('highScoreModal'); + const body = document.getElementById('highScoreBody'); + if (modal && body) { + body.innerHTML = `#${rank} — ${this.escapeHtml(this.leaderboardManager.currentPlayerName || 'Player')} — ${this.scoreManager.score}`; + modal.style.display = 'flex'; + } else { + this.handleRestart(); + } } else { this.handleRestart(); } - } else { + } catch (error) { + console.error('Failed to save score:', error); this.handleRestart(); } - } catch (error) { - console.error('Failed to save score:', error); - this.handleRestart(); + } else { + // Otherwise restart silently after a delay + setTimeout(() => this.handleRestart(), 500); } + }; + + if (this.shouldReduceMotion()) { + proceed(); } else { - // Otherwise restart silently - this.handleRestart(); + this.snake.flash(() => { + this.snake.breakApart(); + gsap.to(this.canvas, { + duration: 0.5, + opacity: 0.5, + scale: 0.95, + ease: "power2.out", + delay: 0.5, + onComplete: proceed + }); + }); } } @@ -393,6 +398,7 @@ class Game { this.food.respawn(this.gridSize, { width: this.canvas.width, height: this.canvas.height }); this.interval = 200; this.isPaused = false; + this.isGameOver = false; this.onlinePlayersManager.updatePlayerStatus(true); if (!this.shouldReduceMotion()) { gsap.to(this.canvas, { @@ -404,7 +410,6 @@ class Game { } document.querySelector('.score-value').textContent = '0'; document.getElementById('leaderboardModal').style.display = 'none'; - requestAnimationFrame(this.gameLoop); } updateScoreUI() { diff --git a/public/particles.js b/public/particles.js index 5e20b12..c8d7abe 100644 --- a/public/particles.js +++ b/public/particles.js @@ -11,8 +11,8 @@ export class Particle { this.color = options.color || 'rgba(255,100,100,1)'; this.life = 0; this.maxLife = options.maxLife || 600; // ms - this.gravity = options.gravity || 0.0015; - this.friction = options.friction || 0.98; + this.gravity = options.gravity || 0.0015 + Math.random() * 0.001; + this.friction = options.friction || 0.97 + Math.random() * 0.02; } update(dt) { @@ -42,16 +42,18 @@ export class ParticleSystem { this.particles = []; } - spawnExplosion(x, y, count = 20, color = 'rgba(255,100,100,1)') { + spawnExplosion(x, y, count = 30, color = 'rgba(255,100,100,1)') { for (let i = 0; i < count; i++) { - const angle = (i / count) * Math.PI * 2 + Math.random() * 0.5; - const speed = 0.7 + Math.random() * 2.5; + const angle = Math.random() * Math.PI * 2; + const speed = 0.5 + Math.random() * 3.5; + const colorVariation = `rgba(${Math.floor(Math.random() * 50 + 205)},${Math.floor(Math.random() * 50 + 80)},${Math.floor(Math.random() * 50 + 80)},1)`; + this.particles.push(new Particle(x, y, { angle, speed, - size: 1.5 + Math.random() * 2.5, - color, - maxLife: 500 + Math.random() * 500 + size: 2 + Math.random() * 3, + color: i % 2 === 0 ? color : colorVariation, + maxLife: 600 + Math.random() * 600 })); } } diff --git a/public/snake.js b/public/snake.js index 3cbd690..b1beae1 100644 --- a/public/snake.js +++ b/public/snake.js @@ -6,49 +6,87 @@ export class Snake { } reset() { + if (this.tweens) { + this.tweens.forEach(t => t.kill()); + } + gsap.killTweensOf(this); + this.tweens = []; + this.body = [ - { x: 10, y: 10 } + { x: 10, y: 10, renderX: 10, renderY: 10 } ]; this.direction = 'right'; this.newDirection = 'right'; this.pendingGrowth = 0; + + // Restore original colors + gsap.killTweensOf(this.colors); this.colors = { head: '#50c878', body: '#3cb371', border: '#2e8b57' }; + this.headScaleX = 1; + this.headScaleY = 1; + this.isDead = false; } - update() { + update(interval) { + if (this.isDead) return; + const directionChanged = this.newDirection !== this.direction; + // Apply queued direction this.direction = this.newDirection; const head = this.body[0]; - let newHead = { ...head }; + + // The new head starts at the same render position as the old head + let newHead = { x: head.x, y: head.y, renderX: head.renderX, renderY: head.renderY }; switch(this.direction) { - case 'right': - newHead.x += 1; - break; - case 'left': - newHead.x -= 1; - break; - case 'up': - newHead.y -= 1; - break; - case 'down': - newHead.y += 1; - break; + case 'right': newHead.x += 1; break; + case 'left': newHead.x -= 1; break; + case 'up': newHead.y -= 1; break; + case 'down': newHead.y += 1; break; } - // Add new head this.body.unshift(newHead); - // Trim tail when no growth pending if (this.pendingGrowth > 0) { this.pendingGrowth--; } else { this.body.pop(); } + + // Animate everything to its new logical position + if (this.tweens) { + this.tweens.forEach(t => t.kill()); + } + this.tweens = []; + this.body.forEach((segment) => { + const tween = gsap.to(segment, { + duration: interval / 1000, + renderX: segment.x, + renderY: segment.y, + ease: 'linear' + }); + this.tweens.push(tween); + }); + + if (directionChanged) { + this.triggerTurnAnimation(interval); + } + } + + triggerTurnAnimation(interval) { + gsap.killTweensOf(this); + this.headScaleX = this.direction === 'left' || this.direction === 'right' ? 1.3 : 0.7; + this.headScaleY = this.direction === 'up' || this.direction === 'down' ? 1.3 : 0.7; + gsap.to(this, { + duration: interval / 2000, + headScaleX: 1, + headScaleY: 1, + ease: 'elastic.out(1, 0.5)' + }); } grow() { @@ -58,9 +96,18 @@ export class Snake { draw(ctx, gridSize) { // Draw each segment this.body.forEach((segment, index) => { - const x = segment.x * gridSize; - const y = segment.y * gridSize; - const size = gridSize - 2; // Slightly smaller for padding effect + const x = segment.renderX * gridSize; + const y = segment.renderY * gridSize; + const size = gridSize - 2; + + ctx.save(); + + if (index === 0) { + // Apply squash and stretch to head + ctx.translate(x + size/2, y + size/2); + ctx.scale(this.headScaleX, this.headScaleY); + ctx.translate(-(x + size/2), -(y + size/2)); + } // Create gradient for segment const gradient = ctx.createRadialGradient( @@ -100,6 +147,8 @@ export class Snake { ); ctx.fill(); } + + ctx.restore(); }); } @@ -107,4 +156,30 @@ export class Snake { const [head, ...body] = this.body; return body.some(segment => segment.x === head.x && segment.y === head.y); } + + flash(onComplete) { + gsap.to(this.colors, { + head: '#ff0000', + body: '#ff0000', + duration: 0.1, + yoyo: true, + repeat: 3, + onComplete + }); + } + + breakApart() { + this.isDead = true; + this.body.forEach((segment, i) => { + const delay = i * 0.02; + gsap.to(segment, { + duration: 0.6 + Math.random() * 0.4, + renderX: segment.renderX + (Math.random() - 0.5) * 20, + renderY: segment.renderY + (Math.random() - 0.5) * 20, + opacity: 0, + ease: 'power2.out', + delay + }); + }); + } }