From b93e576054a2bde5ded0a7ea16486f86632faf7f Mon Sep 17 00:00:00 2001 From: Mark Wilcock Date: Fri, 31 Oct 2025 20:15:24 +0000 Subject: [PATCH] Replace terminal snake with browser version --- snake_game.css | 150 ++++++++++++++++++++++++++++++++ snake_game.html | 40 +++++++++ snake_game.js | 223 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 413 insertions(+) create mode 100644 snake_game.css create mode 100644 snake_game.html create mode 100644 snake_game.js diff --git a/snake_game.css b/snake_game.css new file mode 100644 index 0000000..b14d1dd --- /dev/null +++ b/snake_game.css @@ -0,0 +1,150 @@ +:root { + color-scheme: dark; + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + --bg: #0f1116; + --card: #171b24; + --accent: #51d06c; + --accent-dark: #3aa653; + --grid: #1f2430; + --snake: #8cf5a9; + --food: #ff6666; + --text: #f5f7fa; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: radial-gradient(circle at top, #202632, var(--bg)); + color: var(--text); + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; +} + +.layout { + width: min(720px, 100%); +} + +.game-card { + background: var(--card); + border-radius: 18px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.45); + padding: 2rem; + display: grid; + gap: 1.5rem; +} + +.game-header h1 { + margin: 0 0 0.25rem; + font-size: clamp(1.6rem, 2vw + 1rem, 2.4rem); +} + +.description { + margin: 0; + line-height: 1.5; + color: rgba(245, 247, 250, 0.78); +} + +.scoreboard { + display: flex; + justify-content: space-between; + align-items: center; + background: rgba(31, 36, 48, 0.9); + border-radius: 12px; + padding: 0.75rem 1rem; + font-weight: 600; + letter-spacing: 0.03em; +} + +.score span { + font-variant-numeric: tabular-nums; + font-weight: 700; +} + +canvas { + width: 100%; + max-width: 480px; + justify-self: center; + background: repeating-linear-gradient( + 0deg, + rgba(255, 255, 255, 0.025), + rgba(255, 255, 255, 0.025) 24px, + rgba(15, 17, 22, 0.1) 24px, + rgba(15, 17, 22, 0.1) 48px + ), + repeating-linear-gradient( + 90deg, + rgba(255, 255, 255, 0.025), + rgba(255, 255, 255, 0.025) 24px, + rgba(15, 17, 22, 0.1) 24px, + rgba(15, 17, 22, 0.1) 48px + ), + var(--grid); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.controls { + display: flex; + gap: 0.75rem; + justify-content: center; +} + +button { + border: none; + border-radius: 999px; + padding: 0.6rem 1.6rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; + background: rgba(255, 255, 255, 0.08); + color: var(--text); +} + +button.primary { + background: linear-gradient(135deg, var(--accent), var(--accent-dark)); + color: #0c150f; + box-shadow: 0 12px 20px rgba(81, 208, 108, 0.35); +} + +button:hover, +button:focus-visible { + transform: translateY(-2px); + box-shadow: 0 14px 22px rgba(0, 0, 0, 0.2); + outline: none; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; + transform: none; + box-shadow: none; +} + +.message { + min-height: 1.5rem; + text-align: center; + font-weight: 600; + letter-spacing: 0.04em; +} + +@media (max-width: 640px) { + body { + padding: 1rem; + } + + .game-card { + padding: 1.5rem; + gap: 1rem; + } + + .scoreboard { + font-size: 0.95rem; + } +} diff --git a/snake_game.html b/snake_game.html new file mode 100644 index 0000000..392c7db --- /dev/null +++ b/snake_game.html @@ -0,0 +1,40 @@ + + + + + + Snake + + + +
+
+
+

Snake

+

+ Use the arrow keys (or WASD) to guide the snake. Eat the glowing fruit to grow + longer. Avoid the walls and your own tail. Press the space bar to pause or restart + after a collision. +

+
+
+
Score: 0
+
Best: 0
+
+ +
+ + +
+ +
+
+ + + diff --git a/snake_game.js b/snake_game.js new file mode 100644 index 0000000..ec2cff8 --- /dev/null +++ b/snake_game.js @@ -0,0 +1,223 @@ +const canvas = document.getElementById("game-canvas"); +const ctx = canvas.getContext("2d"); +const scoreValue = document.getElementById("score-value"); +const bestValue = document.getElementById("best-value"); +const message = document.getElementById("message"); +const startButton = document.getElementById("start-button"); +const pauseButton = document.getElementById("pause-button"); + +const tileCount = 24; +const tileSize = canvas.width / tileCount; +const baseSpeed = 120; + +const directionMap = { + ArrowUp: { x: 0, y: -1 }, + ArrowDown: { x: 0, y: 1 }, + ArrowLeft: { x: -1, y: 0 }, + ArrowRight: { x: 1, y: 0 }, + w: { x: 0, y: -1 }, + s: { x: 0, y: 1 }, + a: { x: -1, y: 0 }, + d: { x: 1, y: 0 }, +}; + +let snake = []; +let direction = { x: 1, y: 0 }; +let pendingDirection = { x: 1, y: 0 }; +let food = null; +let score = 0; +let bestScore = Number(localStorage.getItem("snake-best-score")) || 0; +let intervalId = null; +let isPaused = false; +let isRunning = false; + +bestValue.textContent = bestScore.toString(); + +function resetGame() { + snake = [ + { x: Math.floor(tileCount / 2), y: Math.floor(tileCount / 2) }, + { x: Math.floor(tileCount / 2) - 1, y: Math.floor(tileCount / 2) }, + ]; + direction = { x: 1, y: 0 }; + pendingDirection = { x: 1, y: 0 }; + score = 0; + food = spawnFood(); + updateScore(0); + message.textContent = ""; +} + +function spawnFood() { + while (true) { + const position = { + x: Math.floor(Math.random() * tileCount), + y: Math.floor(Math.random() * tileCount), + }; + + if (!snake.some((segment) => segment.x === position.x && segment.y === position.y)) { + return position; + } + } +} + +function updateScore(delta) { + score += delta; + scoreValue.textContent = score.toString(); + if (score > bestScore) { + bestScore = score; + bestValue.textContent = bestScore.toString(); + localStorage.setItem("snake-best-score", bestScore.toString()); + } +} + +function drawBoard() { + ctx.fillStyle = "#0f1116"; + ctx.fillRect(0, 0, canvas.width, canvas.height); +} + +function drawSnake() { + ctx.fillStyle = "#8cf5a9"; + snake.forEach((segment, index) => { + const radius = index === 0 ? 10 : 8; + drawCell(segment.x, segment.y, radius); + }); +} + +function drawFood() { + if (!food) return; + ctx.fillStyle = "#ff6666"; + drawCell(food.x, food.y, 10); +} + +function drawCell(x, y, radius) { + const centerX = x * tileSize + tileSize / 2; + const centerY = y * tileSize + tileSize / 2; + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); + ctx.fill(); +} + +function step() { + if (isPaused) { + return; + } + + direction = validateDirectionChange(direction, pendingDirection); + + const nextHead = { + x: snake[0].x + direction.x, + y: snake[0].y + direction.y, + }; + + if (hasCollision(nextHead)) { + endGame(); + return; + } + + snake.unshift(nextHead); + + if (food && nextHead.x === food.x && nextHead.y === food.y) { + updateScore(1); + food = spawnFood(); + adjustSpeed(); + } else { + snake.pop(); + } + + draw(); +} + +function validateDirectionChange(current, next) { + if (current.x + next.x === 0 && current.y + next.y === 0) { + return current; + } + return next; +} + +function hasCollision(head) { + const outOfBounds = + head.x < 0 || head.x >= tileCount || head.y < 0 || head.y >= tileCount; + if (outOfBounds) { + return true; + } + + return snake.some((segment) => segment.x === head.x && segment.y === head.y); +} + +function draw() { + drawBoard(); + drawFood(); + drawSnake(); +} + +function startGame() { + resetGame(); + draw(); + isRunning = true; + isPaused = false; + setButtons(); + scheduleNextTick(baseSpeed); +} + +function pauseGame() { + if (!isRunning) { + return; + } + isPaused = !isPaused; + setButtons(); +} + +function endGame() { + clearInterval(intervalId); + intervalId = null; + isRunning = false; + message.textContent = `Game over! Final score: ${score}. Press Space to try again.`; + startButton.disabled = false; + pauseButton.disabled = true; +} + +function scheduleNextTick(speed) { + clearInterval(intervalId); + intervalId = setInterval(step, speed); +} + +function adjustSpeed() { + const speed = Math.max(60, baseSpeed - Math.floor(score / 5) * 10); + scheduleNextTick(speed); +} + +function setButtons() { + startButton.disabled = isRunning; + pauseButton.disabled = !isRunning; + pauseButton.textContent = isPaused ? "Resume" : "Pause"; + message.textContent = isPaused ? "Paused" : ""; +} + +function handleKeydown(event) { + const key = event.key; + if (directionMap[key]) { + event.preventDefault(); + pendingDirection = directionMap[key]; + } else if (key === " " || key === "Spacebar") { + event.preventDefault(); + if (!isRunning) { + startGame(); + } else { + pauseGame(); + } + } else if (key === "Enter" && !isRunning) { + startGame(); + } +} + +startButton.addEventListener("click", () => { + if (!isRunning) { + startGame(); + } +}); + +pauseButton.addEventListener("click", pauseGame); +window.addEventListener("keydown", handleKeydown, { passive: false }); + +draw(); +setButtons(); +message.textContent = "Press Start or Space to begin.";