diff --git a/README.md b/README.md index 9f9f33b1295..0ee9934893d 100644 --- a/README.md +++ b/README.md @@ -179,3 +179,35 @@ Licenses for borrowed code can be found in `Settings -> Licenses` screen, and al - Hypertile - tfernd - https://github.com/tfernd/HyperTile - Initial Gradio script - posted on 4chan by an Anonymous user. Thank you Anonymous user. - (You) + +# 贪吃蛇小游戏 + +一个零依赖的浏览器小游戏,使用 HTML5 Canvas + 原生 JavaScript 构建。 + +## 运行 + +在本地启动一个静态服务器即可(推荐 Python 方式): + +```bash +cd /workspace/game +python3 -m http.server 8000 +``` + +打开浏览器访问:`http://localhost:8000` + +如果你的环境有 Node.js,也可以: + +```bash +npx --yes serve -l 8000 . +``` + +## 操作 + +- 键盘:W/A/S/D 或 方向键 控制方向,空格 暂停/继续;在未开始或结束时按空格可重新开始。 +- 触控:滑动控制方向。 + +## 玩法 + +- 吃到食物加 1 分并变长,撞墙或撞到自己则游戏结束。 +- 每 5 分速度略微提升。 +- 最佳分数会自动保存在浏览器的 localStorage。 diff --git a/game/index.html b/game/index.html new file mode 100644 index 00000000000..04f0b907bba --- /dev/null +++ b/game/index.html @@ -0,0 +1,46 @@ + + + + + + 贪吃蛇 Snake + + + + + + +
+
+

贪吃蛇

+
+
分数0
+
最佳0
+
+
+ +
+ + +
+ + +
+ + + + \ No newline at end of file diff --git a/game/main.js b/game/main.js new file mode 100644 index 00000000000..31c7e8846ac --- /dev/null +++ b/game/main.js @@ -0,0 +1,351 @@ +'use strict'; + +(function(){ + const canvas = document.getElementById('game-canvas'); + const ctx = canvas.getContext('2d'); + + const startButton = document.getElementById('start-btn'); + const restartButton = document.getElementById('restart-btn'); + const pauseButton = document.getElementById('pause-btn'); + const overlay = document.getElementById('overlay'); + const overlayTitle = document.getElementById('overlay-title'); + const overlaySub = document.getElementById('overlay-sub'); + const scoreEl = document.getElementById('score'); + const bestScoreEl = document.getElementById('best-score'); + + const BEST_SCORE_KEY = 'snake_best_score_v1'; + + function loadBestScore(){ + try{ return Number(localStorage.getItem(BEST_SCORE_KEY) || '0'); } + catch{ return 0; } + } + function saveBestScore(value){ + try{ localStorage.setItem(BEST_SCORE_KEY, String(value)); } + catch{} + } + + let bestScore = loadBestScore(); + bestScoreEl.textContent = String(bestScore); + + // Grid configuration + const GRID_SIZE = 24; // number of cells per side + let cellSize = Math.floor(canvas.width / GRID_SIZE); + + function resizeCanvasToContainer(){ + const parent = canvas.parentElement; + const rect = parent.getBoundingClientRect(); + const size = Math.min(rect.width, 560); // clamp for readability + const devicePixelRatio = window.devicePixelRatio || 1; + const targetCssSize = Math.max(320, Math.floor(size)); + canvas.style.width = targetCssSize + 'px'; + canvas.style.height = targetCssSize + 'px'; + canvas.width = Math.floor(targetCssSize * devicePixelRatio); + canvas.height = Math.floor(targetCssSize * devicePixelRatio); + cellSize = Math.floor(canvas.width / GRID_SIZE); + ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); + } + + window.addEventListener('resize', resizeCanvasToContainer); + resizeCanvasToContainer(); + + const Direction = Object.freeze({ + Up: {x:0, y:-1, name:'Up'}, + Down: {x:0, y:1, name:'Down'}, + Left: {x:-1, y:0, name:'Left'}, + Right: {x:1, y:0, name:'Right'}, + }); + + function positionsEqual(a,b){ return a.x===b.x && a.y===b.y; } + + function randomInt(min, max){ // inclusive + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + function generateFoodPosition(snake){ + const occupied = new Set(snake.map(p=> p.x + ':' + p.y)); + while(true){ + const pos = { x: randomInt(0, GRID_SIZE-1), y: randomInt(0, GRID_SIZE-1) }; + if(!occupied.has(pos.x + ':' + pos.y)) return pos; + } + } + + function willReverse(currentDir, nextDir){ + return (currentDir.x + nextDir.x === 0 && currentDir.y + nextDir.y === 0); + } + + const initialState = () => ({ + snake: [ + {x: 8, y: 12}, + {x: 7, y: 12}, + {x: 6, y: 12}, + ], + direction: Direction.Right, + pendingDirections: [], + food: {x: 14, y:12}, + score: 0, + isRunning: false, + isPaused: false, + isGameOver: false, + msPerStep: 120, + lastStepAt: 0, + accumulator: 0, + lastFrameTs: 0, + }); + + let state = initialState(); + + function setScore(newScore){ + state.score = newScore; + scoreEl.textContent = String(newScore); + if(newScore > bestScore){ + bestScore = newScore; + bestScoreEl.textContent = String(bestScore); + saveBestScore(bestScore); + } + } + + function startGame(){ + state = initialState(); + state.isRunning = true; + overlay.hidden = true; + scoreEl.textContent = '0'; + } + + function restartGame(){ + startGame(); + } + + function pauseOrResume(){ + if(!state.isRunning || state.isGameOver){ return; } + state.isPaused = !state.isPaused; + overlay.hidden = state.isPaused ? false : true; + overlayTitle.textContent = state.isPaused ? '已暂停' : ''; + overlaySub.textContent = state.isPaused ? '空格继续' : ''; + } + + function gameOver(){ + state.isRunning = false; + state.isGameOver = true; + overlay.hidden = false; + overlayTitle.textContent = '游戏结束'; + overlaySub.textContent = '点“重新开始”或按空格重来'; + } + + function step(deltaMs){ + state.accumulator += deltaMs; + if(state.accumulator < state.msPerStep){ return; } + const steps = Math.floor(state.accumulator / state.msPerStep); + state.accumulator -= steps * state.msPerStep; + + for(let i=0;i= GRID_SIZE || nextHead.y >= GRID_SIZE){ + gameOver(); + return; + } + + // self collision + for(let k=0;k 70 && state.score % 5 === 0){ + state.msPerStep -= 5; + } + state.food = generateFoodPosition(state.snake); + }else{ + state.snake.pop(); + } + } + } + + function drawGrid(){ + const size = canvas.width; // already scaled via DPR + ctx.fillStyle = '#0f1337'; + ctx.fillRect(0, 0, size, size); + + ctx.save(); + ctx.globalAlpha = 0.12; + ctx.strokeStyle = '#7ba8ff'; + ctx.lineWidth = 1; + for(let i=0;i<=GRID_SIZE;i++){ + const p = i * cellSize; + ctx.beginPath(); ctx.moveTo(p, 0); ctx.lineTo(p, size); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(0, p); ctx.lineTo(size, p); ctx.stroke(); + } + ctx.restore(); + } + + function drawSnake(){ + for(let i=0;i{ + if(keyToDir.has(e.code)){ + enqueueDirection(keyToDir.get(e.code)); + e.preventDefault(); + } else if(e.code === 'Space'){ + if(!state.isRunning || state.isGameOver){ + restartGame(); + } else { + pauseOrResume(); + } + e.preventDefault(); + } + }, { passive: false }); + + // Touch controls (swipe) + let touchStart = null; + const SWIPE_THRESHOLD = 18; // px + + canvas.addEventListener('touchstart', (e)=>{ + if(e.touches.length === 1){ + touchStart = { x: e.touches[0].clientX, y: e.touches[0].clientY }; + } + }, { passive: true }); + canvas.addEventListener('touchmove', (e)=>{ + if(!touchStart || e.touches.length !== 1) return; + const dx = e.touches[0].clientX - touchStart.x; + const dy = e.touches[0].clientY - touchStart.y; + if(Math.abs(dx) < SWIPE_THRESHOLD && Math.abs(dy) < SWIPE_THRESHOLD){ + return; + } + if(Math.abs(dx) > Math.abs(dy)){ + enqueueDirection(dx > 0 ? Direction.Right : Direction.Left); + } else { + enqueueDirection(dy > 0 ? Direction.Down : Direction.Up); + } + touchStart = null; + }, { passive: true }); + + // Buttons + startButton.addEventListener('click', ()=>{ + if(!state.isRunning){ startGame(); } + }); + restartButton.addEventListener('click', ()=>{ + restartGame(); + }); + pauseButton.addEventListener('click', ()=>{ + pauseOrResume(); + }); + + // Show initial overlay + overlay.hidden = false; + overlayTitle.textContent = '按开始'; + overlaySub.textContent = '方向键 / WASD 控制,手机支持滑动。'; +})(); \ No newline at end of file diff --git a/game/style.css b/game/style.css new file mode 100644 index 00000000000..ab25c24f500 --- /dev/null +++ b/game/style.css @@ -0,0 +1,95 @@ +:root{ + --bg: #0f1220; + --panel: #151935; + --panel-2: #1b214a; + --text: #e7ecff; + --muted: #9aa3c7; + --accent: #6dd6ff; + --accent-2: #7bffb5; + --danger: #ff6b6b; +} +*{ box-sizing: border-box; } +html,body{ height:100%; } +body{ + margin:0; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Noto Sans, Arial, "Apple Color Emoji", "Segoe UI Emoji"; + background: radial-gradient(1200px 800px at 20% -10%, #1e2350, transparent), + radial-gradient(900px 600px at 120% 10%, #1a3a53, transparent), + var(--bg); + color: var(--text); +} +.container{ + max-width: 720px; + margin: 24px auto; + padding: 16px; +} +.header{ + display:flex; + align-items:center; + justify-content:space-between; + gap: 16px; +} +.header h1{ + margin:0; + font-size: 28px; + letter-spacing: 0.5px; +} +.scores{ display:flex; gap: 12px; } +.score{ + background: linear-gradient(180deg, var(--panel), var(--panel-2)); + padding:8px 12px; + border-radius: 10px; + border: 1px solid #232a5f; +} +.score span{ color: var(--muted); font-size: 12px; } +.score strong{ display:block; font-size: 18px; margin-top:2px; } + +.stage{ + position: relative; + margin-top:16px; + background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01)); + border: 1px solid #232a5f; + border-radius: 16px; + overflow: hidden; + box-shadow: 0 10px 30px rgba(0,0,0,0.35); +} +canvas{ + display:block; + width:100%; + height:auto; + image-rendering: pixelated; + background: repeating-linear-gradient(0deg, #0e1030, #0e1030 16px, #0f1337 16px, #0f1337 32px); +} +.overlay{ + position:absolute; inset:0; display:grid; place-items:center; + backdrop-filter: blur(6px); + background: rgba(13,16,40,0.55); +} +.overlay-content{ + text-align:center; + padding: 18px 20px; + background: linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0.02)); + border: 1px solid #2a3270; + border-radius: 14px; +} +.overlay-actions{ margin-top: 12px; display:flex; justify-content:center; gap:10px; } + +.footer{ margin-top: 10px; color: var(--muted); } +.hint{ margin: 8px 0 0; font-size: 12px; } +.controls{ display:flex; gap:10px; } + +.btn{ + appearance:none; border:none; cursor:pointer; + padding: 10px 14px; border-radius: 12px; color: var(--text); + background: linear-gradient(180deg, #1b2253, #161c43); + border: 1px solid #28327b; +} +.btn.primary{ + background: linear-gradient(180deg, #1b4b7a, #173b5f); + border-color: #2a6aa5; +} +.btn:active{ transform: translateY(1px); } + +@media (max-width: 520px){ + .header{ flex-direction: column; align-items:flex-start; } +} \ No newline at end of file