资讯详情

MATLAB贪吃蛇游戏开发:事件回调、定时器与碰撞检测实战

📅 2026/10/14 17:42:22 | 华诺云谱 👁 阅读
MATLAB贪吃蛇游戏开发:事件回调、定时器与碰撞检测实战
很多人觉得 MATLAB 跟小游戏八竿子打不着其实恰恰相反。我用 MatLabR2018a 做过一个贪吃蛇小游戏代码量不大但把事件回调、状态更新、碰撞检测这些平时被工具箱掩盖住的底层逻辑全给练了一遍。这篇文章就从需求拆解开始把整个项目的设计思路、完整实现和我在 R2018a 环境里踩过的一堆坑都摊开讲清楚。如果你刚学完 MATLAB 基础语法想做一个看得见、能互动、还能拿去演示的小项目这篇正合适。哪怕你完全没写过游戏只要看得懂数组和 if 判断按着步骤也能复现出来。我会把“为什么这样做”也讲清楚而不是只丢一堆代码让你照抄。1. 游戏循环怎么翻译成 MATLAB 的编程模型1.1 先想清楚贪吃蛇到底是个什么东西贪吃蛇本质上不是一个连续运动的物体而是一个每隔固定时间“走一格”的状态机。很多初学者一上来就想着怎么让蛇“动起来”这是思路跑偏了。正确的拆法是这样状态蛇身坐标、当前方向、食物坐标、当前分数、是否结束输入玩家按键更新方向更新每经过一个固定时间步根据方向生成新蛇头再根据是否吃到食物决定蛇尾去留输出把更新后的状态画到窗口上这个模型一旦想清楚后面每个函数都只是填空。蛇身本质上是一个 N×2 的矩阵每行是某个身体段的[x, y]坐标第一行是蛇头。方向也是一个二维向量比如[1 0]表示向右[0 1]表示向上。新蛇头就是snake(1,:) dir就这么简单。在 MATLAB 里这个“每隔固定时间走一格”最自然的实现是timer定时器而不是while pause。为什么因为窗口需要同时响应键盘事件。如果用while循环把主线程阻塞住方向键的KeyPressFcn回调整体响应会变得很迟钝甚至出现按键被吞掉的情况。timer是事件驱动回调之间互不阻塞刚好贴合游戏框架。1.2 用 R2018a 写这个项目边界在哪里R2018a 处于一个比较特别的版本位置App Designer 和uifigure已经存在但当时大量教学和项目还是用传统figure开发交互程序。贪吃蛇这个项目最稳的组合是传统figureaxestimerKeyPressFcn。这套组合在 R2018a 里已经非常成熟就算再老一点的版本只要支持匿名函数和timer基本也能跑。反过来如果你在 R2018a 里改用uifigure会发现键盘回调的支持很别扭图形刷新逻辑和传统figure也不一样没有必要为了“新”去踩这个坑。换句话说这个项目不挑最新版R2018a 反而因为它的“传统”而显得稳妥。还有一个很多人会忽略的点整个项目只需要 MATLAB 基础模块不需要任何工具箱。有些教程习惯用viscircles画食物圆点但那是图像处理工具箱的函数没装的人一跑就报错。用rectangle的Curvature属性画圆就没有任何额外依赖这种细节能帮你省掉很多麻烦。2. 界面搭建和数据模型网格、蛇身和共享状态2.1 用整数坐标而不是像素坐标我把地图设计成 20×20 的网格每个格子是一个单位。蛇身和食物都只用格子坐标表示不碰像素坐标这样逻辑层和显示层彻底分开。一个最常见的错误是坐标轴范围没配对。如果XLim设成[0 20]那你以为的格子中心会在 0.5、1.5、2.5……用rectangle画出来就会整体偏移半格。正确做法是ax axes(Parent, fig, ... XLim, [0.5 20.5], ... YLim, [0.5 20.5], ... DataAspectRatio, [1 1 1]);这样整数坐标 1、2、3…… 正好落在每个格子的中心。蛇头在[5 10]画面里它就在第 5 列第 10 行的格子正中间。把坐标轴范围定好后面所有绘制逻辑都会轻松很多。2.2 画蛇和食物用什么函数画蛇我推荐用rectangle而不是plot。原因是plot画出来的是线条蛇身需要的是实心方块。每个身体段用一个rectanglePosition写成[x-0.5, y-0.5, 1, 1]正好占满一个格子。蛇头用深绿色身体用浅绿色食物用红色圆形形成视觉对比。rectangle(ax, Position, [x-0.5 y-0.5 1 1], ... FaceColor, [0.3 0.7 0.3], EdgeColor, [0 0.3 0]);食物圆形的实现也很简单rectangle加一个Curvature, [1 1]属性矩形就变成内切圆。性能方面完全不用担心20×20 的棋盘最多同时画二十来个对象每帧cla清空重画都毫无压力。2.3 共享状态闭包还是 UserData因为键盘回调和定时器回调都要读写蛇身、方向、分数这些状态所以第一步要决定状态放在哪。我推荐两种方式。第一种是嵌套函数。在snake_game主函数里把所有局部变量定义好再把按键处理、游戏步进、重绘都写成嵌套函数。嵌套函数天然共享主函数的工作区代码短读起来直观。这个规模的项目用这种方式最合适。第二种是把状态写进figure的UserData。回调开头用ud get(fig, UserData)拿出来结束再set(fig, UserData, ud)写回去。好处是状态迁移明确适合功能多、需要拆文件的工程化项目。但对贪吃蛇来说这种写法会多出不少样板代码。有一点要特别提醒不要图省事用全局变量。全局变量在游戏中途想重置状态时会变得非常痛苦清理不干净还会影响后续脚本运行。嵌套函数足够干净而且不会污染工作区。3. 移动、进食、碰撞三步把一帧执行完3.1 一帧里发生的完整流程定时器每触发一次就是“一帧”。这一帧里按顺序做下面几件事根据当前方向计算新蛇头坐标判断新蛇头是否等于食物坐标如果吃到了食物新蛇 新蛇头 旧蛇全部分数加一生成新食物如果没吃到食物新蛇 新蛇头 旧蛇去掉最后一段检测撞墙和撞自己如果发生碰撞则停止游戏重绘蛇、食物、分数这个流程里最关键的一句话是吃食时尾部不动没吃食时尾部前移一格。这正是贪吃蛇“吃一个长一节”的核心规则。很多人把顺序搞反先判断碰撞再更新蛇结果蛇头撞墙了还先加了一节体验很怪。3.2 数组操作头插尾删MATLAB 处理这个更新非常直接用数组拼接就能完成newHead snake(1,:) dir; if all(newHead food) snake [newHead; snake]; score score 1; food generateFood(snake, N); else snake [newHead; snake(1:end-1, :)]; end没吃到食物时snake(1:end-1, :)表示去掉最后一段后的旧蛇然后新蛇头插到最前面。新头进来旧尾离开长度保持不变。吃到食物时snake整体保留再插入新头长度加一。这个操作的时间复杂度是 O(n)对 20×20 的网格完全没问题。如果真的把地图做到几百格可以考虑用循环双端队列来优化但在这个项目里属于过度设计不必纠结。3.3 碰撞检测的一个容易忽略的边界撞墙检测没什么可说的判断新蛇头坐标是否越界if any(newHead 1) || any(newHead N) gameOver(); return; end撞自己这里有一个容易被忽视的细节新蛇头撞到“准备移走的尾巴”算不算死如果这一帧没吃到食物蛇尾会跟着前移一格所以新蛇头进入蛇尾原本所在的格子其实不会发生碰撞。很多教程直接用ismember(newHead, snake, rows)判断会把这种本应存活的情况误判为死亡。严谨一点的做法是先构造出新蛇再判断新蛇头是否出现在“除新蛇头之外的身体”里。因为构造新蛇时没吃食的蛇尾已经被删掉了所以新蛇头撞“旧蛇尾”不会被判死吃食时蛇尾保留就会正确判死。if ismember(newHead, snake(2:end, :), rows) gameOver(); return; end这个边缘情况在地图小、蛇长时偶尔会出现。你把规则定成哪种都行关键是代码要和你认定的规则一致不能稀里糊涂。4. R2018a 环境下的五个经典坑和对应的解法4.1 方向键按了没反应这是我在实际调试中遇到最多的一个问题。现象是窗口出来了蛇也在动但按方向键完全没反应。原因很简单KeyPressFcn必须设置在最外层figure上而不是axes上。MATLAB 的键盘事件是在 figure 层面回调的axes本身不接收键盘事件。你要是图方便把回调挂到axes上那就只能看着蛇一直朝一个方向撞墙。另一个原因是窗口焦点。程序跑起来后焦点可能还在终端窗口需要先用鼠标点一下游戏窗口。如果实在频繁遇到可以在创建figure时把WindowStyle设成normal不要用dockeddock 状态下的键盘焦点行为会更诡异。4.2 蛇身显示错位半格蛇身的错位问题90% 出在坐标轴范围和rectangle的Position上。前面说过XLim要从 0.5 开始整数坐标才是格子中心。如果你把XLim设成[0 20]然后rectangle的Position写[x, y, 1, 1]那蛇身就会整体向右上偏移半格和背景网格对不上。还有一种做法是用plot(x, y, s, MarkerSize, ...)画方块。MarkerSize是按点尺寸算的很难精确填满格子而且蛇身一变长方块之间会出现肉眼可见的缝隙。所以还是建议统一用rectangle位置、大小都直接落在坐标轴单位上。4.3 关窗口后定时器还在跑新手最容易遇见的报错是游戏窗口关了命令窗口却在疯狂刷“无效的 figure 句柄”之类的错误。原因是timer一旦被创建不会跟随窗口关闭自动消失。你人把窗口关了定时器却还在按周期触发回调回调里访问已经消失的 figure自然就报错。解决办法是设计好收尾逻辑。用CloseRequestFcn拦截关闭事件在真正删除窗口前先停掉并删除定时器function closeGame(~, ~) if isvalid(tmr) stop(tmr); delete(tmr); end delete(fig); end同时在定时器回调第一行加保险if ~isvalid(fig), return; end。窗口不存在时直接退出双保险能把这个坑彻底堵上。这个习惯在任何一个带timer的 MATLAB 交互程序里都用得上。4.4 手感不对刷新周期和 drawnow 的配合玩过几个版本之后我的经验是timer的Period设在 0.08 到 0.12 秒之间手感最好。太短蛇像是在飞玩家根本反应不过来太长显得拖沓体验很差。我用 0.1 秒作为默认值也就是每秒走 10 格。ExecutionMode我建议用fixedSpacing。这个模式会在每次回调执行完之后再等一个周期才开始下一次计时不会出现回调还没跑完下一次又触发导致排队堆积的问题。fixedRate虽然名字听起来更“固定”但回调耗时一旦接近周期积累起来的滞后会很讨厌。每次重绘结束记得调用drawnow否则图形刷新不会立刻同步到屏幕。不要在回调里用pause来减速pause会阻塞事件队列按键响应也会跟着卡顿。4.5 食物总是生成在不该在的位置用randi([1 N], 1, 2)生成随机坐标后如果蛇身占了很多格子很容易生成到蛇身上。处理方式就是写一个循环不断重新生成直到不与蛇身重叠function p generateFood(snake, N) while true p randi([1 N], 1, 2); if ~ismember(p, snake, rows) break; end end end看起来简单但有一个极端情况当棋盘快要被蛇占满时这个循环可能变成死循环。所以建议在吃到食物后先判断蛇身长度是否已经达到 N×N如果满了就宣布胜利并直接结束游戏不给死循环留机会。5. 完整代码结构与关键代码解读5.1 一个文件还是多个文件我的习惯是这个规模的项目直接写成单文件snake_game.m主函数加嵌套函数。嵌套函数能共享主函数工作区里的状态变量这是 MATLAB 特有的便利代码读起来非常顺。如果你以后想把功能做大可以拆成initGame.m、updateGame.m、drawGame.m三个函数把状态以结构体方式传来传去。那种写法更工程化但会引入大量参数传递样板代码。对贪吃蛇这个体量来说单文件是最合适的。5.2 核心代码从初始化到一帧逻辑下面是我整理后的完整代码R2018a 直接复制到snake_game.m里运行就可以function snake_game() % 贪吃蛇小游戏 MATLAB R2018a N 20; % 网格边长 dt 0.1; % 每步间隔单位秒 % 创建游戏窗口和坐标轴 fig figure(Name, 贪吃蛇小游戏, ... NumberTitle, off, ... MenuBar, none, ... ToolBar, none, ... Resize, off, ... Position, [200 200 520 520], ... KeyPressFcn, keyPress, ... CloseRequestFcn, closeGame); ax axes(Parent, fig, ... XLim, [0.5 N0.5], ... YLim, [0.5 N0.5], ... XTick, [], YTick, [], ... Box, on, ... Color, [0.95 0.95 0.9], ... DataAspectRatio, [1 1 1]); hold(ax, on); % 初始状态蛇头在中心默认向右 center ceil(N/2); snake [center center; center-1 center; center-2 center]; dir [1 0]; score 0; food generateFood(); % 首次绘制 redraw(); % 创建定时器并启动 tmr timer(ExecutionMode, fixedSpacing, ... Period, dt, ... TimerFcn, gameStep); start(tmr); function gameStep(~, ~) if ~isvalid(fig) return; end % 1. 计算新蛇头 newHead snake(1,:) dir; eat all(newHead food); % 2. 更新蛇身 if eat snake [newHead; snake]; score score 1; if size(snake, 1) N*N win(); return; end food generateFood(); else snake [newHead; snake(1:end-1, :)]; end % 3. 碰撞检测 if any(newHead 1) || any(newHead N) gameOver(); return; end if ismember(newHead, snake(2:end, :), rows) gameOver(); return; end % 4. 重绘 redraw(); end function keyPress(~, ev) switch ev.Key case uparrow if dir(2) ~ -1, dir [0 1]; end case downarrow if dir(2) ~ 1, dir [0 -1]; end case leftarrow if dir(1) ~ 1, dir [-1 0]; end case rightarrow if dir(1) ~ -1, dir [1 0]; end end end function redraw() cla(ax); for k 1:size(snake, 1) x snake(k, 1); y snake(k, 2); if k 1 c [0.1 0.5 0.1]; else c [0.3 0.7 0.3]; end rectangle(ax, Position, [x-0.5 y-0.5 1 1], ... FaceColor, c, EdgeColor, [0 0.3 0]); end rectangle(ax, Position, [food(1)-0.5 food(2)-0.5 1 1], ... FaceColor, [0.8 0.2 0.1], EdgeColor, [0.5 0 0], ... Curvature, [1 1]); title(ax, sprintf(得分: %d, score)); drawnow; end function p generateFood() while true p randi([1 N], 1, 2); if ~ismember(p, snake, rows) break; end end end function gameOver() if isvalid(tmr), stop(tmr); end title(ax, sprintf(游戏结束得分: %d, score)); drawnow; end function win() if isvalid(tmr), stop(tmr); end title(ax, sprintf(你赢了得分: %d, score)); drawnow; end function closeGame(~, ~) if isvalid(tmr) stop(tmr); delete(tmr); end delete(fig); end end这段代码里有几个设计点值得单独说一下。keyPress里的防反转逻辑很关键。比如当前方向向右dir [1 0]如果玩家按下左键条件dir(1) ~ 1为 false这次按键会被直接忽略。这样蛇不会在快速连按时突然掉头穿进自己身体。gameOver里先stop(tmr)再改标题因为定时器停止后gameStep不会再触发画面稳定地停在结束状态。如果你以后想加“重新开始”只需要在结束状态里加一个按键回调重置snake、score、food然后start(tmr)。关于方向这里默认的是坐标轴 y 轴向上所以按上键对应[0 1]按下键对应[0 -1]。如果你更习惯图像坐标那种 y 轴向下的表示改一下方向映射就行其他逻辑不用动。最后说一点个人体会。用 MatLabR2018a 写贪吃蛇最大的收获不是游戏本身而是第一次意识到程序的框架可以拆成“状态”和“事件”两件事。窗口、回调、定时器各自管好自己那一块逻辑就清晰了。做完了这个小项目你可以试着把格子数改大、给蛇加个渐变色、加一个最高分记录其实都是在现有骨架上做增量。如果写的过程中卡在某个函数报错上欢迎留言我尽量帮你一起看。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑