Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -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.
265 changes: 166 additions & 99 deletions snake.html
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,138 @@ <h1 class="title">🐍 貪吃蛇大冒險</h1>
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}];
Expand All @@ -346,6 +478,7 @@ <h1 class="title">🐍 貪吃蛇大冒險</h1>
if (Math.random() < 0.3) {
generatePowerUp();
}
initSprites();
}

// 生成食物
Expand Down Expand Up @@ -414,121 +547,55 @@ <h1 class="title">🐍 貪吃蛇大冒險</h1>

// 繪制遊戲
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);
});
}
}

// 移動蛇
Expand Down