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);
+ });
+ }
}
// 移動蛇