拓冰建站拓冰建站
首页 / 资讯中心 / 正文

用Flask和JavaScript从零实现俄罗斯方块网页游戏,学会前后端交互

不用框架也能写游戏但用上后端的游戏玩起来就有意思了。这个项目用 Flask 做服务把俄罗斯方块搬进浏览器前后端一配合游戏逻辑、分数、等级全都能动态处理。适合刚学完 Python 基础、想尝试 Web 开发又不想一上来就啃 Vue、React 这类重框架的人。做出来的东西能跑、能玩、能扩展拿来当课程设计或者练手项目都很划算。1. 项目思路拆解与方案选型1.1 为什么用 Flask HTML 这套组合很多初学者一听到“网页游戏”下意识觉得得上 Canvas、WebGL 或者游戏引擎。其实俄罗斯方块这种经典游戏核心是二维数组的变换用 HTML CSS JavaScript 就能把界面和交互做得干干净净。而 Flask 在这里扮演的角色不是游戏引擎而是“游戏服务端”负责页面路由、用户信息记录、最高分存储甚至后续扩展排行榜、多人在线对战都有了依托。我选择 Flask 而不是 Django是因为这个项目规模不大Flask 的轻量特性让整个代码结构非常透明。你写一个 app.py 文件里面定义几个路由再用 render_template 返回 HTML 页面前后端的边界非常清晰。更重要的是Flask 的学习曲线很平缓你不需要先弄懂 ORM、Admin 后台、中间件这些概念就能把项目跑起来。对于纯前端实现俄罗斯方块来说其实一个 HTML 文件就能搞定所有逻辑。但加上 Flask 之后整个项目的层次感完全不一样你可以把游戏数据提交给后端保存下次打开页面还能继续或者看到历史最高分。这就有了“系统”的感觉而不只是散落一地的代码。1.2 游戏整体架构与运行流程这个项目的基本架构是浏览器端负责游戏页面的渲染和交互Flask 后端负责路由分发和数据持久化。请求一来一回流程大概是这样的浏览器访问根路径/Flask 返回index.html页面。浏览器加载页面时同时加载 CSS 和 JavaScript 文件。JavaScript 里实现了完整的俄罗斯方块逻辑包括方块的生成、旋转、碰撞检测、消行和得分。游戏结束后JavaScript 通过 AJAX 请求把得分 POST 给 Flask 的 API 接口。Flask 接收数据存入文件或者数据库下次加载首页时再把历史最高分传给前端展示。这样就实现了前端游戏逻辑与后端数据管理的分离。你改前端样式的时候不用动后端改存储方式的时候也不用碰游戏逻辑对于新手理解前后端交互是一个非常好的示例。1.3 Flask 项目的目录结构规划我收到过很多初学者写的 Flask 项目所有代码都堆在一个文件里页面、路由、数据库操作混在一起几百行代码之后就完全没法维护了。这个项目规模不大但还是建议从一开始就养成好习惯目录结构可以这样设计tetris_flask/ ├── app.py # Flask 主文件路由和 API ├── requirements.txt # 项目依赖 ├── static/ │ ├── css/ │ │ └── style.css │ └── js/ │ └── tetris.js └── templates/ └── index.htmlstatic文件夹放静态资源templates文件夹放模板文件这是 Flask 的固定约定。你把index.html放在templates下Flask 才能用render_template(index.html)找到它这一点新手经常搞混。2. 前端游戏逻辑设计与核心实现2.1 游戏面板的数据模型俄罗斯方块最核心的数据结构是一个二维数组。我习惯用10x20的格子来作为游戏面板也就是 10 列 20 行。在 JavaScript 里可以这样初始化const COLS 10; const ROWS 20; let board []; function createBoard() { board []; for (let i 0; i ROWS; i) { board.push(new Array(COLS).fill(0)); } }board里每个格子用0表示空用非 0 值表示已经有方块固定。有些实现会用不同的数字代表不同颜色我这里用统一的数字1就够了等做界面美化的时候再扩展颜色系统。七种标准方块的形状我用一个对象来存储。每种方块用一个二维数组表示它的形状const SHAPES { I: [[1, 1, 1, 1]], O: [[1, 1], [1, 1]], T: [[0, 1, 0], [1, 1, 1]], S: [[0, 1, 1], [1, 1, 0]], Z: [[1, 1, 0], [0, 1, 1]], J: [[1, 0, 0], [1, 1, 1]], L: [[0, 0, 1], [1, 1, 1]] };每次新方块出现时从SHAPES中随机取一个。这里建议直接用随机索引而不是伪随机算法因为我们是要做教学示例随机性够用代码也简单。2.2 方块的移动、旋转与碰撞检测方块的当前位置用currentPiece对象来维护包括它的形状、当前坐标行、列。移动的逻辑就是修改坐标然后重新渲染。但移动前必须做碰撞检测否则方块会飞出边界或者堆叠在已有方块上。碰撞检测的核心思路是尝试把当前方块放到新位置然后检查每一个为 1 的格子是否超出了面板边界或者与面板上已有的非 0 格子重叠function collides(shape, rowOffset, colOffset) { for (let r 0; r shape.length; r) { for (let c 0; c shape[0].length; c) { if (shape[r][c]) { const newRow currentPiece.row r rowOffset; const newCol currentPiece.col c colOffset; if (newRow ROWS || newCol 0 || newCol COLS) { return true; } if (newRow 0 board[newRow][newCol]) { return true; } } } } return false; }这里有个细节容易忽略当方块还在面板顶部上方时比如 newRow 小于 0我们不能直接判断为碰撞否则方块一出生就被卡住。所以上面只对newRow 0的格子检查 board 是否已有值边界检查只针对下边界、左边界和右边界。旋转功能的实现是把方块的二维数组顺时针旋转 90 度。操作方法也简单先转置再翻转每一行function rotate(matrix) { const N matrix.length; const M matrix[0].length; const newMatrix []; for (let i 0; i M; i) { newMatrix[i] []; for (let j 0; j N; j) { newMatrix[i][j] matrix[N - 1 - j][i]; } } return newMatrix; }旋转之后不能直接用还要再跑一次碰撞检测。如果旋转后的位置会碰墙我通常会加一个“踢墙”机制也就是尝试左右偏移 1 到 2 格看能不能塞进去这个细节做了之后游戏手感会好非常多。2.3 消除行与游戏结束判断消行的逻辑很直接从下往上遍历每一行如果这一行所有格子都不为 0就删除这一行并在数组头部添加一个全 0 的新行function clearRows() { let cleared 0; for (let row ROWS - 1; row 0; ) { if (board[row].every(cell cell ! 0)) { board.splice(row, 1); board.unshift(new Array(COLS).fill(0)); cleared; } else { row--; } } return cleared; }游戏结束的判断点是当一个新方块生成后如果它出现的位置就已经和面板上的方块发生碰撞说明面板已经堆到顶部游戏结束。这个判断放在spawnPiece()函数里比较合适function spawnPiece() { const shape SHAPES[Object.keys(SHAPES)[Math.floor(Math.random() * 7)]]; currentPiece { shape: shape, row: 0, col: Math.floor((COLS - shape[0].length) / 2) }; if (collides(currentPiece.shape, 0, 0)) { gameOver(); return false; } return true; }2.4 渲染方式DOM 操作还是 Canvas这里我遇到过一个选择用 Canvas 渲染还是用 DOM 格子渲染。两个方案都能实现但各有优劣。对于教学项目我更推荐 DOM 方式因为每一块砖头都是一个div元素你可以在浏览器开发者工具里直接看到结构出了问题也好调试。我的做法是在index.html里放一个容器游戏开始时用 JavaScript 动态生成一个20x10的网格网格const container document.getElementById(board-container); container.innerHTML ; for (let r 0; r ROWS; r) { for (let c 0; c COLS; c) { const cell document.createElement(div); cell.className cell; cell.id cell-${r}-${c}; container.appendChild(cell); } }每次状态更新就直接修改对应div的background-color。这种方式的优点是逻辑直观缺点也很明显频繁操作 DOM 会有性能压力。但对于 20x10 的网格来说浏览器完全扛得住实测下来帧率稳定。3. Flask 后端的衔接与数据持久化3.1 路由设计与页面渲染Flask 端代码不长但每个部分都要写清楚。最基础的路由是首页from flask import Flask, render_template, request, jsonify import json import os app Flask(__name__) SCORE_FILE highscore.json app.route(/) def index(): highscore load_highscore() return render_template(index.html, highscorehighscore)这里用render_template传入highscore在 HTML 模板里可以直接用{{ highscore }}来显示上次的最高分。这是 Flask 的模板引擎语法初学者不用刻意学 Jinja2 的完整语法会几个常见标签就够用了。还有一个比较细节的地方如果highscore.json文件还不存在load_highscore函数要能正常返回 0而不是直接报错。这个用文件读写的时候要加上异常处理。3.2 历史最高分的读取与更新最高分的保存我选择了 JSON 文件。为什么不选数据库因为这个项目只有一个维度需要保存数据数据库那套配置对初学者来说太重JSON 文件读出来就是一个 dict写入也就是json.dump两行代码完全满足需求。后续如果要做账号系统、多玩家数据再迁移 SQLite 也不迟。下面是读写函数def load_highscore(): if not os.path.exists(SCORE_FILE): return 0 try: with open(SCORE_FILE, r, encodingutf-8) as f: data json.load(f) return data.get(highscore, 0) except (json.JSONDecodeError, IOError): return 0 def save_highscore(score): with open(SCORE_FILE, w, encodingutf-8) as f: json.dump({highscore: score}, f, ensure_asciiFalse, indent2)这里的try-except非常重要因为文件可能在游戏过程中被外部手动编辑或者上次写入时程序被强制终止导致文件内容不完整不做异常处理的话程序会在读取时直接崩溃这个坑我踩过不止一次。3.3 接收前端得分数据的 API游戏结束后前端用 fetch 发送 POST 请求到后端接口后端接收分数并把最高分保存下来app.route(/api/score, methods[POST]) def submit_score(): data request.get_json() score data.get(score, 0) highscore load_highscore() new_record False if score highscore: save_highscore(score) new_record True return jsonify({ status: ok, highscore: max(highscore, score), new_record: new_record })这里有个设计细节即使当前分数没有打破最高纪录接口也返回一个有效的highscore给前端前端可以根据返回值提示玩家“历史最高分是XXX”。这样就不需要前端再额外存储历史记录了。前端对应调用async function submitScore(score) { const response await fetch(/api/score, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ score: score }) }); const data await response.json(); if (data.new_record) { alert(新纪录恭喜); } document.getElementById(highscore).innerText data.highscore; }用fetch而不是原始的XMLHttpRequest是因为fetch基于 Promise写起来更简洁、可读性更强而且现代浏览器都支持不需要兼容老 IE。3.4 模板中的变量渲染在index.html中需要用 Flask 传入的变量来初始化显示。比如在页面加载时显示当前最高分可以这样写div classscore-panel span历史最高分/span span idhighscore{{ highscore }}/span /div这里{{ highscore }}会被 Flask 的模板引擎替换成实际的值。如果你刷新页面这个值每次都会从 JSON 文件里重新读取所以上一局创造的最高纪录刷新之后依然能看到。4. 阻塞在路上的那些 Bug 和排查实录4.1 页面白屏不知道为什么什么都没有这种情况十有八九是 JavaScript 文件加载失败或者 JavaScript 运行时抛了异常。第一步打开浏览器开发者工具F12切到 Console 标签页看有没有红色报错信息。常见的错误有三类引入文件的路径写错了比如把tetris.js放进static/js但 HTML 里写成了/js/tetris.js。JavaScript 代码里有语法错误这个通常在 Console 里会提示第几行出错。DOM 元素还没加载完脚本就执行了导致document.getElementById(board-container)返回null。第三个问题最常见。解决办法是把script标签放在/body之前或者用window.onload包一层。我的经验是直接放到 body 底部最省心。4.2 方块直接穿过底部边界出现这个问题大概率是碰撞检测里没有对newRow ROWS做判断或者判断了但提前 return 了。我会在每次移动下落之前手动打印一句日志确认当前方块的位置和面板状态console.log(current at row${currentPiece.row}, col${currentPiece.col}); console.log(collision check result: ${collides(currentPiece.shape, 1, 0)});看到日志之后再对照面板就知道是碰撞检测逻辑不对还是调用时机不对。方块的落底判定应该是在下落过程中检测collides(shape, 1, 0)一旦为 true就立刻把当前方块“固定”到board上然后生成新方块。4.3 分数计算分不清计分规则俄罗斯方块的计分规则不复杂但如果你只是简单记个“消一行加一百分”玩家会觉得很单调。我采用了经典规则消 1 行得 100 分消 2 行得 300 分消 3 行得 500 分消 4 行得 800 分。一次性消掉的越多单行分越高。消除少的时候不明显一旦你能连续消四行分数直接飙升这种正反馈对游戏体验非常关键。计分公式不要直接写死在 JavaScript 里建议做成一个独立函数function getScoreByRows(rows) { return [0, 100, 300, 500, 800][rows] || 1000; }这样后面想调整计分规则只改这一处就行。4.4 Flask 端口被占用怎么办这是 Flask 开发中特别常见的问题。你上一次运行程序没有正常退出这次又运行就报Port 5000 is already in use。处理方式不复杂Windows 上可以在终端执行netstat -ano | findstr :5000看进程然后taskkill /PID 进程号 /F强制关闭。macOS / Linux 用lsof -i :5000找进程再用kill -9 PID杀掉。如果你不想每次手动处理也可以把 app.py 的启动端口改成一个不常用的比如5050app.run(debugTrue, port5050)顺手提醒一点debugTrue开发时确实好用改代码自动重启但真正上线部署的时候一定要关掉否则会有安全隐患。4.5 分数提交到后端后刷新页面却没有更新排查方向有两个一是接口有没有真的被调用二是文件有没有真的写入成功。先在浏览器开发者工具 Network 面板里找有没有/api/score这个请求看它的状态码和响应体。如果请求存在但响应里的highscore是旧值那就是后端读取文件时读到了缓存。其实 Python 默认每次open都会重新读磁盘不会缓存所以这种情况比较少见更可能是save_highscore写入了非预期路径。检查一下路径是不是相对于根目录的建议在文件读写时打印一下绝对路径print(os.path.abspath(SCORE_FILE))看到实际路径之后去检查文件内容问题一般就真相大白。5. 体验优化与拓展方向5.1 控制台记录与游戏状态机游戏不能一直跑下去得有一个清晰的状态控制。我用了一个状态变量gameState取值分别是ready、playing、paused、over。按键处理逻辑会根据当前状态做出不同反应比如按空格只有在playing状态才触发硬降按 P 键切换暂停function handleKeyPress(event) { if (gameState over) return; if (event.key p || event.key P) { togglePause(); return; } if (gameState ! playing) return; // 其他移动和旋转逻辑 }引入状态机之后代码的复杂度不会增加太多但系统的健壮性提升巨大不会出现游戏结束后还能移动方块的尴尬局面。5.2 界面样式与动画反馈一个游戏好不好玩除了逻辑视觉反馈也很重要。我在 CSS 中做了两个关键处理一个是消除行时对整行闪烁一下再消失另一个是新方块出现时轻微的缩放动画。闪烁效果不用 jQuery用 CSS 动画最方便在消除的行上临时加一个 class.row-clearing { animation: flash 0.3s ease-out; } keyframes flash { 0% { opacity: 1; } 50% { opacity: 0.2; } 100% { opacity: 0; } }JavaScript 在消除前给对应格子加上这个 class等动画播放完再用setTimeout更新board数据。这样用户眼睛看到的是平滑动画而不是瞬间刷新整个游戏会精致得多。布局方面我用 Flex 布局把游戏面板、得分区域、操作提示横向排列中间用合理的间距隔开整体风格保持简洁不要搞很多花里胡哨的渐变和阴影不然用户注意力会被分散。5.3 增加等级与下落速度控制俄罗斯方块的爽感来源于节奏越来越快的那种压迫感。这里做了一个简单设计每消除 10 行提升一个等级每升一级下落间隔减少 50 毫秒初始间隔是 800 毫秒最低值限制在 100 毫秒避免后期速度快到完全没法操作那就不是挑战是折磨了。let level 1; let dropInterval 800; function updateLevel(rowsCleared) { totalLines rowsCleared; level Math.floor(totalLines / 10) 1; dropInterval Math.max(100, 800 - (level - 1) * 50); }下落逻辑用setInterval还是requestAnimationFrame我纠结过一段时间。setInterval的写法简单但浏览器切换标签页的时候会不准确。requestAnimationFrame更平滑但需要自己控制每次调用的时间差。对于初学者我还是推荐setInterval够用且好理解。如果你后续想加入“按左键加速下落”这种复杂交互再换成requestAnimationFrame会更好。5.4 拓展排行榜、区块链和多人对战这个项目想继续深挖的话扩展空间非常大。举几个方向供参考排行榜把highscore.json改成 SQLite 或纯 JSON 列表前端加一个表格展示前 10 名。用户系统增加登录注册每个账号有独立的最高分记录。多人对战用 WebSocket 实现实时对战两人共用一套方块序列看谁坚持得更久或者得分更高。题目重建你可以把这个套路迁移到别的游戏上比如五子棋、贪吃蛇前端逻辑不同但 Flask 的结构几乎不用改。我见过有人在这个基础上加了“幽灵方块”也就是投影到底部的提示还在网页端加了音效。这些都不是很难的技术活但每加一个功能你理解这个系统的深度就会多一层。6. 部署上线时需要额外留意的几件事本地开发得很顺畅但你要把项目部署到服务器上有几个坑需要提前绕开。第一点是静态文件的处理。生产环境要由 Nginx 这类 Web 服务器来托管静态文件而不是靠 Flask 自己。虽然在app.run()下 Flask 能处理静态文件但性能很差。你可以在 Nginx 里把/static/路径独立映射出来让 Flask 只负责接口和页面渲染。第二点是关闭调试模式、换启动方式。线上跑python app.py只适合测试真正要长期运行建议用 Gunicorngunicorn -w 2 -b 0.0.0.0:8080 app:app-w 是 worker 进程数-b 是绑定地址和端口。这样 Flask 应用就能在服务器上作为正式服务运行。第三点是 JSON 文件的并发写问题。如果同时有多个玩家提交分数两个请求同时写同一个文件可能导致数据丢失或者文件损坏。最简单的处理方式是加一个线程锁from threading import Lock score_lock Lock() def submit_score(): score request.get_json().get(score, 0) with score_lock: highscore load_highscore() if score highscore: save_highscore(score)加锁之后同一时间只有一个请求能写文件问题基本能解决。如果数据量再大一点就该换数据库了。我在实际部署中还遇到过中文乱码问题。解决办法是render_template时让 HTML 模板里明确设置meta charsetutf-8后端 JSON 文件读取时也指定encodingutf-8。写文件时再顺手加上ensure_asciiFalse这样 JSON 里存的就是中文而不是\uXXXX转义序列调试起来舒服很多。游戏做到这个程度已经是一个“麻雀虽小、五脏俱全”的全栈小项目了。从前端游戏逻辑到后端接口再到数据持久化一条链路完整跑通每个环节都有东西可学。我自己当初做这个项目时最大的收获不是学会写一个俄罗斯方块而是第一次体会到“前端负责展现、后端负责逻辑”的分工协作这种认知对之后学任何框架都有帮助。可以这样说花一个周末把这个项目搭起来你收获的不只是一个能玩的网页而是从零到一完成一个 Web 应用的完整思路。就冲这个这份功夫下得值。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门