diff --git a/snake-game/README.md b/snake-game/README.md new file mode 100644 index 00000000000..280c22067a7 --- /dev/null +++ b/snake-game/README.md @@ -0,0 +1,28 @@ +# 贪吃蛇 Snake + +一个零依赖的原生 HTML/CSS/JS 版贪吃蛇小游戏,支持键盘和移动端方向按钮,带分数、最高分(本地存储)、暂停/重开、速度调节。 + +## 运行 + +在项目目录下开启一个静态服务器即可预览: + +- Python: + +```bash +python3 -m http.server 8000 +``` + +然后在浏览器打开 http://localhost:8000 + +也可使用任意静态服务器(如 `npx serve`)。 + +## 操作 +- 方向键或 WASD 控制方向 +- 空格 暂停/继续 +- R 重开 +- 顶部下拉选择速度 + +## 文件结构 +- `index.html`: 页面结构 +- `styles.css`: 样式 +- `script.js`: 游戏逻辑 \ No newline at end of file diff --git a/snake-game/index.html b/snake-game/index.html new file mode 100644 index 00000000000..a353140628b --- /dev/null +++ b/snake-game/index.html @@ -0,0 +1,61 @@ + + + + + + 贪吃蛇 Snake + + + +
+
+

贪吃蛇

+
+
分数 0
+
最高 0
+
速度 + +
+
+
+ +
+ + + +
+ +
+ +
+ +
+ + +
+ +
+
+ + +
+ + + + \ No newline at end of file diff --git a/snake-game/script.js b/snake-game/script.js new file mode 100644 index 00000000000..283681322c0 --- /dev/null +++ b/snake-game/script.js @@ -0,0 +1,301 @@ +const canvas = document.getElementById("game"); +const ctx = canvas.getContext("2d"); + +const scoreEl = document.getElementById("score"); +const highScoreEl = document.getElementById("highScore"); +const speedSelect = document.getElementById("speedSelect"); +const overlay = document.getElementById("overlay"); +const overlayTitle = document.getElementById("overlayTitle"); +const overlayDesc = document.getElementById("overlayDesc"); +const startBtn = document.getElementById("startBtn"); +const restartBtn = document.getElementById("restartBtn"); +const pauseBtn = document.getElementById("pauseBtn"); + +const dpad = document.querySelector(".dpad"); + +const STORAGE_KEY = "snake_high_score_v1"; + +const GRID_CELL_SIZE = 20; +const GRID_COLS = 24; +const GRID_ROWS = 24; + +const DEVICE_PIXEL_RATIO = Math.max(1, Math.floor(window.devicePixelRatio || 1)); +canvas.width = GRID_COLS * GRID_CELL_SIZE * DEVICE_PIXEL_RATIO; +canvas.height = GRID_ROWS * GRID_CELL_SIZE * DEVICE_PIXEL_RATIO; +ctx.scale(DEVICE_PIXEL_RATIO, DEVICE_PIXEL_RATIO); + +const Direction = Object.freeze({ Up: "Up", Down: "Down", Left: "Left", Right: "Right" }); + +function getInitialState() { + return { + snakeCells: [ { x: 8, y: 12 }, { x: 7, y: 12 }, { x: 6, y: 12 } ], + currentDirection: Direction.Right, + pendingDirection: Direction.Right, + foodCell: generateFoodCell(new Set(["8,12","7,12","6,12"])), + score: 0, + isRunning: false, + isGameOver: false, + msPerStep: Number(speedSelect.value), + timeAccumulatorMs: 0, + inputQueue: [], + }; +} + +/** + * Returns a random food cell not occupied by the snake + */ +function generateFoodCell(occupiedSet) { + while (true) { + const x = Math.floor(Math.random() * GRID_COLS); + const y = Math.floor(Math.random() * GRID_ROWS); + const key = `${x},${y}`; + if (!occupiedSet.has(key)) return { x, y }; + } +} + +function readHighScore() { + try { + const saved = localStorage.getItem(STORAGE_KEY); + return saved ? Number(saved) : 0; + } catch { + return 0; + } +} + +function writeHighScore(value) { + try { + localStorage.setItem(STORAGE_KEY, String(value)); + } catch { + // ignore storage errors + } +} + +let state = getInitialState(); +let highScore = readHighScore(); + +function setScore(value) { + scoreEl.textContent = String(value); +} + +function setHighScore(value) { + highScoreEl.textContent = String(value); +} + +function showOverlay(title, desc) { + overlayTitle.textContent = title; + overlayDesc.textContent = desc; + overlay.classList.remove("hidden"); +} + +function hideOverlay() { + overlay.classList.add("hidden"); +} + +function startGame() { + if (state.isRunning && !state.isGameOver) return; + state = getInitialState(); + state.isRunning = true; + setScore(0); + setHighScore(highScore); + hideOverlay(); +} + +function pauseGame() { + if (!state.isRunning || state.isGameOver) return; + state.isRunning = false; + showOverlay("暂停中", "按空格继续,或点击开始"); +} + +function resumeGame() { + if (state.isRunning || state.isGameOver) return; + state.isRunning = true; + hideOverlay(); +} + +function endGame() { + state.isRunning = false; + state.isGameOver = true; + const newHigh = Math.max(highScore, state.score); + if (newHigh !== highScore) { + highScore = newHigh; + writeHighScore(highScore); + } + setHighScore(highScore); + showOverlay("游戏结束", `得分 ${state.score} · R 重开`); +} + +function handleDirectionInput(next) { + const current = state.pendingDirection; + if (next === current) return; + + const invalid = + (current === Direction.Up && next === Direction.Down) || + (current === Direction.Down && next === Direction.Up) || + (current === Direction.Left && next === Direction.Right) || + (current === Direction.Right && next === Direction.Left); + if (invalid) return; + + state.inputQueue.push(next); +} + +function applyNextDirection() { + if (state.inputQueue.length === 0) return; + state.pendingDirection = state.inputQueue.shift(); +} + +function stepGame() { + applyNextDirection(); + state.currentDirection = state.pendingDirection; + + const head = state.snakeCells[0]; + const delta = + state.currentDirection === Direction.Up ? { x: 0, y: -1 } : + state.currentDirection === Direction.Down ? { x: 0, y: 1 } : + state.currentDirection === Direction.Left ? { x: -1, y: 0 } : { x: 1, y: 0 }; + + const newHead = { x: head.x + delta.x, y: head.y + delta.y }; + + if (newHead.x < 0 || newHead.x >= GRID_COLS || newHead.y < 0 || newHead.y >= GRID_ROWS) { + endGame(); + return; + } + + for (let i = 0; i < state.snakeCells.length; i += 1) { + const c = state.snakeCells[i]; + if (c.x === newHead.x && c.y === newHead.y) { + endGame(); + return; + } + } + + const nextSnake = [newHead, ...state.snakeCells]; + + const ateFood = newHead.x === state.foodCell.x && newHead.y === state.foodCell.y; + if (ateFood) { + state.score += 1; + setScore(state.score); + + const occupied = new Set(nextSnake.map((c) => `${c.x},${c.y}`)); + state.foodCell = generateFoodCell(occupied); + + if (state.msPerStep > 60 && state.score % 5 === 0) { + state.msPerStep = Math.max(60, state.msPerStep - 5); + } + } else { + nextSnake.pop(); + } + + state.snakeCells = nextSnake; +} + +function drawCell(cell, color) { + ctx.fillStyle = color; + ctx.fillRect(cell.x * GRID_CELL_SIZE, cell.y * GRID_CELL_SIZE, GRID_CELL_SIZE, GRID_CELL_SIZE); +} + +function draw() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "#0d1020"; + ctx.fillRect(0, 0, GRID_COLS * GRID_CELL_SIZE, GRID_ROWS * GRID_CELL_SIZE); + + const gridColor = "#141830"; + ctx.strokeStyle = gridColor; + ctx.lineWidth = 1; + for (let x = 0; x <= GRID_COLS; x += 1) { + ctx.beginPath(); + ctx.moveTo(x * GRID_CELL_SIZE + 0.5, 0); + ctx.lineTo(x * GRID_CELL_SIZE + 0.5, GRID_ROWS * GRID_CELL_SIZE); + ctx.stroke(); + } + for (let y = 0; y <= GRID_ROWS; y += 1) { + ctx.beginPath(); + ctx.moveTo(0, y * GRID_CELL_SIZE + 0.5); + ctx.lineTo(GRID_COLS * GRID_CELL_SIZE, y * GRID_CELL_SIZE + 0.5); + ctx.stroke(); + } + + for (let i = state.snakeCells.length - 1; i >= 0; i -= 1) { + const color = i === 0 ? "#6ee7b7" : "#34d399"; + drawCell(state.snakeCells[i], color); + } + + drawCell(state.foodCell, "#60a5fa"); +} + +let lastTimestamp = performance.now(); +function gameLoop(nowMs) { + const dt = nowMs - lastTimestamp; + lastTimestamp = nowMs; + + if (state.isRunning && !state.isGameOver) { + state.timeAccumulatorMs += dt; + while (state.timeAccumulatorMs >= state.msPerStep) { + state.timeAccumulatorMs -= state.msPerStep; + stepGame(); + } + } + + draw(); + requestAnimationFrame(gameLoop); +} + +requestAnimationFrame(gameLoop); + +window.addEventListener("keydown", (e) => { + const key = e.key; + if (key === " " || key === "Spacebar") { + e.preventDefault(); + if (state.isGameOver) return; + if (state.isRunning) pauseGame(); else resumeGame(); + return; + } + if (key === "r" || key === "R") { + startGame(); + return; + } + + switch (key) { + case "ArrowUp": case "w": case "W": handleDirectionInput(Direction.Up); break; + case "ArrowDown": case "s": case "S": handleDirectionInput(Direction.Down); break; + case "ArrowLeft": case "a": case "A": handleDirectionInput(Direction.Left); break; + case "ArrowRight": case "d": case "D": handleDirectionInput(Direction.Right); break; + } +}); + +speedSelect.addEventListener("change", () => { + const base = Number(speedSelect.value); + state.msPerStep = base; +}); + +startBtn.addEventListener("click", () => { + startGame(); +}); + +restartBtn.addEventListener("click", () => { + startGame(); +}); + +pauseBtn.addEventListener("click", () => { + if (state.isRunning) pauseGame(); else resumeGame(); +}); + +function handlePad(dir) { + switch (dir) { + case "up": handleDirectionInput(Direction.Up); break; + case "down": handleDirectionInput(Direction.Down); break; + case "left": handleDirectionInput(Direction.Left); break; + case "right": handleDirectionInput(Direction.Right); break; + } +} + +dpad.addEventListener("click", (e) => { + const target = e.target; + if (!(target instanceof HTMLElement)) return; + const dir = target.getAttribute("data-dir"); + if (!dir) return; + handlePad(dir); +}); + +// Initial UI +setHighScore(highScore); +showOverlay("开始游戏", "按空格或点击开始。方向键/WASD 控制,R 重开。"); \ No newline at end of file diff --git a/snake-game/styles.css b/snake-game/styles.css new file mode 100644 index 00000000000..eef4f8d061a --- /dev/null +++ b/snake-game/styles.css @@ -0,0 +1,81 @@ +:root { + --bg: #0f1220; + --panel: #171a2b; + --border: #2a2f4a; + --text: #e6e8f2; + --muted: #9aa2c7; + --accent: #6ee7b7; + --accent-2: #60a5fa; +} + +* { box-sizing: border-box; } + +html, body { height: 100%; } + +body { + margin: 0; + background: radial-gradient(1200px 800px at 20% 0%, #161a2c 0%, var(--bg) 60%); + color: var(--text); + font: 16px/1.5 system-ui, -apple-system, Segoe UI, Roboto, PingFang SC, Microsoft YaHei, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; +} + +.container { + max-width: 720px; + margin: 0 auto; + padding: 24px 16px 48px; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.title { margin: 0; font-size: 24px; letter-spacing: 1px; } + +.hud { display: flex; gap: 12px; align-items: center; } +.hud-item { background: var(--panel); border: 1px solid var(--border); padding: 6px 10px; border-radius: 8px; color: var(--muted); } +.hud-item span { color: var(--text); font-weight: 700; margin-left: 6px; } + +#speedSelect { background: transparent; color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px; } + +.game-wrap { position: relative; display: grid; place-items: center; margin-top: 16px; } + +#game { + width: min(92vw, 480px); + height: min(92vw, 480px); + background: linear-gradient(180deg, #0e1120 0%, #0f1222 100%); + border-radius: 12px; + border: 1px solid var(--border); + box-shadow: 0 10px 40px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.03); +} + +.overlay { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: rgba(10, 13, 26, 0.6); + backdrop-filter: blur(4px); +} +.overlay.hidden { display: none; } +.overlay-content { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px; text-align: center; width: min(92vw, 420px); } +.overlay-actions { display: flex; justify-content: center; gap: 12px; margin-top: 12px; } + +.controls { display: flex; align-items: center; justify-content: space-between; margin-top: 16px; } + +.btn { background: transparent; color: var(--text); border: 1px solid var(--border); border-radius: 10px; padding: 10px 14px; cursor: pointer; transition: 120ms ease; } +.btn:hover { border-color: var(--accent-2); } +.btn.primary { background: linear-gradient(180deg, #1d253f 0%, #1a2036 100%); border-color: #2f3a63; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.04); } + +.dpad { display: grid; grid-template-rows: auto auto auto; grid-template-columns: 1fr; gap: 6px; } +.pad-middle { display: grid; grid-template-columns: auto auto; gap: 6px; } +.pad { width: 58px; height: 42px; border-radius: 10px; border: 1px solid var(--border); background: #1a1f35; color: var(--text); cursor: pointer; } + +.footer { color: var(--muted); margin-top: 16px; text-align: center; } + +@media (max-width: 540px) { + .header { flex-direction: column; align-items: flex-start; } + .controls { gap: 16px; } +} \ No newline at end of file