diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 0000000..8d864fc --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,3 @@ +## 2025-01-24 - Canvas Sprite Caching and Background Clearing +**Learning:** When using an off-screen canvas to cache a static background (like a game grid) in a project that clears the frame by redrawing the background, ensure the background color is filled within the off-screen sprite canvas. Moving the background fill to an `else` block (skipping it when cache is hit) causes "smearing" artifacts because previous frames are never cleared. Additionally, cached sprites with `shadowBlur` require explicit padding to avoid visual clipping. +**Action:** Include the background fill in the cached background sprite and use `{ alpha: false }` for opaque contexts. Standardize `SPRITE_PADDING` for all glow-heavy sprites. diff --git a/snake.html b/snake.html index 78830bb..f7c41b0 100644 --- a/snake.html +++ b/snake.html @@ -331,6 +331,138 @@

🐍 貪吃蛇大冒險

4: { name: '極限模式', speed: 60, obstacles: true, powerUps: true } }; + // 預渲染精靈快取,減少每幀繪製開銷 + const spriteCache = { + grid: null, + snakeHead: null, + snakeBody: null, + food: null, + obstacle: null, + powerUpSpeed: null, + powerUpScore: null + }; + + const SPRITE_PADDING = 20; // 預留空間給陰影效果 (shadowBlur) + + function initSprites() { + // 1. 預渲染背景網格 + const gridCanvas = document.createElement('canvas'); + gridCanvas.width = canvas.width; + gridCanvas.height = canvas.height; + const gCtx = gridCanvas.getContext('2d', { alpha: false }); // 优化性能:不透明画布 + + // 填滿背景色以確保每幀正確清除上一幀 + gCtx.fillStyle = 'rgba(0, 0, 0, 0.8)'; + gCtx.fillRect(0, 0, gridCanvas.width, gridCanvas.height); + + gCtx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; + gCtx.lineWidth = 1; + for (let i = 0; i <= tileCount; i++) { + gCtx.beginPath(); + gCtx.moveTo(i * gridSize, 0); + gCtx.lineTo(i * gridSize, canvas.height); + gCtx.stroke(); + gCtx.beginPath(); + gCtx.moveTo(0, i * gridSize); + gCtx.lineTo(canvas.width, i * gridSize); + gCtx.stroke(); + } + spriteCache.grid = gridCanvas; + + // 2. 預渲染蛇頭 + const headCanvas = document.createElement('canvas'); + headCanvas.width = gridSize; + headCanvas.height = gridSize; + const hCtx = headCanvas.getContext('2d'); + const hGrad = hCtx.createRadialGradient(gridSize/2, gridSize/2, 0, gridSize/2, gridSize/2, gridSize/2); + hGrad.addColorStop(0, '#ff6b6b'); + hGrad.addColorStop(1, '#ee5a52'); + hCtx.fillStyle = hGrad; + hCtx.fillRect(2, 2, gridSize - 4, gridSize - 4); + hCtx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + hCtx.lineWidth = 2; + hCtx.strokeRect(2, 2, gridSize - 4, gridSize - 4); + spriteCache.snakeHead = headCanvas; + + // 3. 預渲染蛇身 + const bodyCanvas = document.createElement('canvas'); + bodyCanvas.width = gridSize; + bodyCanvas.height = gridSize; + const bCtx = bodyCanvas.getContext('2d'); + const bGrad = bCtx.createRadialGradient(gridSize/2, gridSize/2, 0, gridSize/2, gridSize/2, gridSize/2); + bGrad.addColorStop(0, '#4ecdc4'); + bGrad.addColorStop(1, '#44a08d'); + bCtx.fillStyle = bGrad; + bCtx.fillRect(2, 2, gridSize - 4, gridSize - 4); + bCtx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + bCtx.lineWidth = 2; + bCtx.strokeRect(2, 2, gridSize - 4, gridSize - 4); + spriteCache.snakeBody = bodyCanvas; + + // 4. 預渲染食物 + const foodCanvas = document.createElement('canvas'); + foodCanvas.width = gridSize + SPRITE_PADDING * 2; + foodCanvas.height = gridSize + SPRITE_PADDING * 2; + const fCtx = foodCanvas.getContext('2d'); + const fGrad = fCtx.createRadialGradient(gridSize/2 + SPRITE_PADDING, gridSize/2 + SPRITE_PADDING, 0, gridSize/2 + SPRITE_PADDING, gridSize/2 + SPRITE_PADDING, gridSize/2); + fGrad.addColorStop(0, '#ffd700'); + fGrad.addColorStop(1, '#ffb347'); + fCtx.fillStyle = fGrad; + fCtx.shadowColor = '#ffd700'; + fCtx.shadowBlur = 10; + fCtx.beginPath(); + fCtx.arc(gridSize/2 + SPRITE_PADDING, gridSize/2 + SPRITE_PADDING, gridSize/2 - 2, 0, 2 * Math.PI); + fCtx.fill(); + spriteCache.food = foodCanvas; + + // 5. 預渲染障礙物 + const obsCanvas = document.createElement('canvas'); + obsCanvas.width = gridSize; + obsCanvas.height = gridSize; + const oCtx = obsCanvas.getContext('2d'); + const oGrad = oCtx.createLinearGradient(0, 0, gridSize, gridSize); + oGrad.addColorStop(0, '#8b4513'); + oGrad.addColorStop(1, '#654321'); + oCtx.fillStyle = oGrad; + oCtx.fillRect(0, 0, gridSize, gridSize); + oCtx.strokeStyle = '#a0522d'; + oCtx.lineWidth = 2; + oCtx.strokeRect(0, 0, gridSize, gridSize); + spriteCache.obstacle = obsCanvas; + + // 6. 預渲染道具 (Speed) + const speedCanvas = document.createElement('canvas'); + speedCanvas.width = gridSize + SPRITE_PADDING * 2; + speedCanvas.height = gridSize + SPRITE_PADDING * 2; + const sCtx = speedCanvas.getContext('2d'); + const sGrad = sCtx.createRadialGradient(gridSize/2 + SPRITE_PADDING, gridSize/2 + SPRITE_PADDING, 0, gridSize/2 + SPRITE_PADDING, gridSize/2 + SPRITE_PADDING, gridSize/2); + sGrad.addColorStop(0, '#00ff00'); + sGrad.addColorStop(1, '#00cc00'); + sCtx.fillStyle = sGrad; + sCtx.shadowColor = '#00ff00'; + sCtx.shadowBlur = 15; + sCtx.beginPath(); + sCtx.arc(gridSize/2 + SPRITE_PADDING, gridSize/2 + SPRITE_PADDING, gridSize/3, 0, 2 * Math.PI); + sCtx.fill(); + spriteCache.powerUpSpeed = speedCanvas; + + // 7. 預渲染道具 (Score) + const scoreCanvas = document.createElement('canvas'); + scoreCanvas.width = gridSize + SPRITE_PADDING * 2; + scoreCanvas.height = gridSize + SPRITE_PADDING * 2; + const scCtx = scoreCanvas.getContext('2d'); + const scGrad = scCtx.createRadialGradient(gridSize/2 + SPRITE_PADDING, gridSize/2 + SPRITE_PADDING, 0, gridSize/2 + SPRITE_PADDING, gridSize/2 + SPRITE_PADDING, gridSize/2); + scGrad.addColorStop(0, '#ff00ff'); + scGrad.addColorStop(1, '#cc00cc'); + scCtx.fillStyle = scGrad; + scCtx.shadowColor = '#ff00ff'; + scCtx.shadowBlur = 15; + scCtx.beginPath(); + scCtx.arc(gridSize/2 + SPRITE_PADDING, gridSize/2 + SPRITE_PADDING, gridSize/3, 0, 2 * Math.PI); + scCtx.fill(); + spriteCache.powerUpScore = scoreCanvas; + } + // 初始化遊戲 function initGame() { snake = [{x: 10, y: 10}]; @@ -346,6 +478,7 @@

🐍 貪吃蛇大冒險

if (Math.random() < 0.3) { generatePowerUp(); } + initSprites(); } // 生成食物 @@ -414,121 +547,55 @@

🐍 貪吃蛇大冒險

// 繪制遊戲 function drawGame() { - // 清空畫布 - ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - // 繪制網格 - ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; - ctx.lineWidth = 1; - for (let i = 0; i <= tileCount; i++) { - ctx.beginPath(); - ctx.moveTo(i * gridSize, 0); - ctx.lineTo(i * gridSize, canvas.height); - ctx.stroke(); + // 使用預渲染的網格背景 + if (spriteCache.grid) { + ctx.drawImage(spriteCache.grid, 0, 0); + } else { + // Fallback: 如果快取失效,手動繪製背景與網格 + ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.beginPath(); - ctx.moveTo(0, i * gridSize); - ctx.lineTo(canvas.width, i * gridSize); - ctx.stroke(); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.lineWidth = 1; + for (let i = 0; i <= tileCount; i++) { + ctx.beginPath(); + ctx.moveTo(i * gridSize, 0); + ctx.lineTo(i * gridSize, canvas.height); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, i * gridSize); + ctx.lineTo(canvas.width, i * gridSize); + ctx.stroke(); + } } // 繪制蛇 snake.forEach((segment, index) => { - const gradient = ctx.createRadialGradient( - segment.x * gridSize + gridSize/2, - segment.y * gridSize + gridSize/2, - 0, - segment.x * gridSize + gridSize/2, - segment.y * gridSize + gridSize/2, - gridSize/2 - ); - - if (index === 0) { - // 蛇頭 - gradient.addColorStop(0, '#ff6b6b'); - gradient.addColorStop(1, '#ee5a52'); - } else { - // 蛇身 - gradient.addColorStop(0, '#4ecdc4'); - gradient.addColorStop(1, '#44a08d'); + const sprite = index === 0 ? spriteCache.snakeHead : spriteCache.snakeBody; + if (sprite) { + ctx.drawImage(sprite, segment.x * gridSize, segment.y * gridSize); } - - ctx.fillStyle = gradient; - ctx.fillRect(segment.x * gridSize + 2, segment.y * gridSize + 2, gridSize - 4, gridSize - 4); - ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; - ctx.lineWidth = 2; - ctx.strokeRect(segment.x * gridSize + 2, segment.y * gridSize + 2, gridSize - 4, gridSize - 4); }); // 繪制食物 - const foodGradient = ctx.createRadialGradient( - food.x * gridSize + gridSize/2, - food.y * gridSize + gridSize/2, - 0, - food.x * gridSize + gridSize/2, - food.y * gridSize + gridSize/2, - gridSize/2 - ); - foodGradient.addColorStop(0, '#ffd700'); - foodGradient.addColorStop(1, '#ffb347'); - - ctx.fillStyle = foodGradient; - ctx.beginPath(); - ctx.arc(food.x * gridSize + gridSize/2, food.y * gridSize + gridSize/2, gridSize/2 - 2, 0, 2 * Math.PI); - ctx.fill(); + if (spriteCache.food) { + ctx.drawImage(spriteCache.food, food.x * gridSize - SPRITE_PADDING, food.y * gridSize - SPRITE_PADDING); + } - // 食物閃爍效果 - ctx.shadowColor = '#ffd700'; - ctx.shadowBlur = 10; - ctx.fill(); - ctx.shadowBlur = 0; - // 繪制道具 if (powerUp) { - const powerUpGradient = ctx.createRadialGradient( - powerUp.x * gridSize + gridSize/2, - powerUp.y * gridSize + gridSize/2, - 0, - powerUp.x * gridSize + gridSize/2, - powerUp.y * gridSize + gridSize/2, - gridSize/2 - ); - - if (powerUp.type === 'speed') { - powerUpGradient.addColorStop(0, '#00ff00'); - powerUpGradient.addColorStop(1, '#00cc00'); - } else { - powerUpGradient.addColorStop(0, '#ff00ff'); - powerUpGradient.addColorStop(1, '#cc00cc'); + const sprite = powerUp.type === 'speed' ? spriteCache.powerUpSpeed : spriteCache.powerUpScore; + if (sprite) { + ctx.drawImage(sprite, powerUp.x * gridSize - SPRITE_PADDING, powerUp.y * gridSize - SPRITE_PADDING); } - - ctx.fillStyle = powerUpGradient; - ctx.beginPath(); - ctx.arc(powerUp.x * gridSize + gridSize/2, powerUp.y * gridSize + gridSize/2, gridSize/3, 0, 2 * Math.PI); - ctx.fill(); - - ctx.shadowColor = powerUp.type === 'speed' ? '#00ff00' : '#ff00ff'; - ctx.shadowBlur = 15; - ctx.fill(); - ctx.shadowBlur = 0; } // 繪制障礙物 - obstacles.forEach(obstacle => { - const obstacleGradient = ctx.createLinearGradient( - obstacle.x * gridSize, obstacle.y * gridSize, - obstacle.x * gridSize + gridSize, obstacle.y * gridSize + gridSize - ); - obstacleGradient.addColorStop(0, '#8b4513'); - obstacleGradient.addColorStop(1, '#654321'); - - ctx.fillStyle = obstacleGradient; - ctx.fillRect(obstacle.x * gridSize, obstacle.y * gridSize, gridSize, gridSize); - ctx.strokeStyle = '#a0522d'; - ctx.lineWidth = 2; - ctx.strokeRect(obstacle.x * gridSize, obstacle.y * gridSize, gridSize, gridSize); - }); + if (spriteCache.obstacle) { + obstacles.forEach(obstacle => { + ctx.drawImage(spriteCache.obstacle, obstacle.x * gridSize, obstacle.y * gridSize); + }); + } } // 移動蛇