用Matlab矩阵思维实现贪吃蛇:GUI编程与游戏循环实战
最近在整理手头的Matlab脚本时突然想用MatlabR2018a复刻一个贪吃蛇小游戏。很多人听到用Matlab做游戏第一反应都是“这玩意儿不是用来做矩阵运算的吗”但实际写下来我发现贪吃蛇这种网格类游戏和Matlab的矩阵思维简直绝配。蛇身是一串坐标点棋盘是一张逻辑网格方向控制就是一个二维向量整件事用Matlab表达起来非常自然。这篇文章我从方案设计、核心数据模型讲起最后给出一份可以直接复制运行的完整代码。不管你是刚接触Matlab的初学者还是想给GUI编程找点乐子的老手都可以跟着跑一遍跑通之后稍作改动就能解锁不少新玩法。1. 项目概述与方案选型1.1 为什么偏偏选Matlab做贪吃蛇很多人会觉得Matlab不是做游戏的材料写贪吃蛇应该用Python、JavaScript甚至C语言。但实际动手以后我发现贪吃蛇这个游戏有一个非常特殊的地方它的整个世界本质上是一张二维网格蛇的身体可以抽象成一个坐标序列食物就是一个坐标点碰撞检测就是判断坐标是否重复。这类问题恰好是Matlab的主场。Matlab的矩阵索引、比较运算、数组拼接都极其顺手。比如蛇移动一步传统语言可能需要循环更新每个身体节点的位置Matlab里只需要把新蛇头插到矩阵第一行再把最后一行的蛇尾删掉一行代码就完成了。再加上Matlab自带的rectangle、plot、cla这些高效绘图函数我们完全不用关心底层的像素渲染问题只需要把数据画到坐标区里就行。R2018a这个版本也是一个很稳的分水岭图形对象体系已经很成熟KeyPressFcn回调、msgbox、drawnow这些接口都稳定可用。更高版本的Matlab跑这一段代码基本也没问题反而不太老的R2016a、R2017a也能兼容。所以用R2018a写这个项目既兼顾了GUI编程的完整流程又不会因为版本太老而缺特性。1.2 界面布局与运行流程设计这个项目我采用单文件函数的结构入口函数叫snakeGame()所有子功能都用嵌套函数写在同一个.m文件里。这样做的好处是变量作用域统一回调函数可以直接读写主循环里的方向变量、蛇数据不用设计复杂的数据传递接口对新手来说更友好。界面结构分成两部分一个figure窗口和窗口内的一个axes坐标区。整个棋盘在这个坐标区里绘制棋盘大小我默认是20×20每个格子的大小是1个单位。蛇、食物、网格线、分数文本全都在坐标区上用矩形和文本来画。用户的交互只有一种按键盘方向键控制蛇头方向系统监听按键后修改内部的方向向量游戏主循环再根据这个方向向量每帧移动一步。运行流程可以概括成初始化状态 → 创建窗口 → 主循环移动、吃食物、碰撞检测、重绘→ 结束弹窗。整个过程没有用到第三方工具箱纯基础Matlab因此任何安装了R2018a及以上版本的机器都能直接跑。2. 核心实现细节2.1 蛇身的数据结构与移动算法蛇身是整个游戏最核心的数据。我用一个snake矩阵来存蛇每一行代表一个身体节点第1行永远是蛇头。比如初始蛇长为3坐标是[10,10; 9,10; 8,10]表示蛇头在(10,10)身体向左延伸。可能有人会问为什么不直接用二维数组标记棋盘上的格子因为我需要频繁在开头插入新坐标、在末尾删除坐标用 N×2 坐标矩阵配合拼接操作要比逐格遍历棋盘高效且直观得多。方向也用向量表示[1 0]向右[-1 0]向左[0 1]向上[0 -1]向下。移动的时候只需要用蛇头坐标加上方向向量就能得到下一个新蛇头的位置。核心逻辑是% 新蛇头 原蛇头 方向向量 newHead snake(1,:) direction; % 不管吃不吃到食物先把新蛇头插到最前面 snake [newHead; snake]; % 吃到食物就保留新增长度没吃到就把尾部删掉 if isequal(newHead, food) score score 10; food generateFood(); else snake(end, :) []; end这里有一个很关键的理解蛇移动的本质是“头部前进、尾部收缩”。头部前进是必然动作所以先把新头加进去至于尾部要不要去掉取决于这一步有没有吃到食物。如果吃到身体长度加一本来就不该缩尾如果没吃到整体长度不变就把最后一个节点弹掉。就好比一支队伍排队往前挪最前面的人迈出新的一步队尾的人必须立刻补上队伍总长度才保持不变。2.2 食物生成与三个碰撞判定食物的生成逻辑也不复杂在[1, gridSize]范围内随机取一个坐标如果这个坐标恰好落在蛇身上就重新随机取直到取到一个空白位置。用randi(gridSize, 1, 2)可以一次生成两个整数然后用ismember判断是否在蛇身矩阵中function pos generateFood() while true pos randi(gridSize, 1, 2); if ~ismember(pos, snake, rows) break; end end end碰撞检测需要覆盖三种情况撞墙、撞身体、吃到食物。撞墙检测很简单新蛇头的 x 或 y 坐标超出[1, gridSize]就结束游戏。撞身体的检测稍微有一点讲究新蛇头不应该和“除了尾部以外的任何身体节点”重合。为什么可以忽略尾部因为如果蛇正在向前移动而尾部恰好让出了一个位置新头走到那个位置是合法动作这不会导致自撞。当然如果蛇吃到食物后尾部队列不变直接判断尾部也可以但上述写法在封闭绳结等极端情况下更稳妥。% 撞墙检测 if newHead(1) 1 || newHead(1) gridSize || ... newHead(2) 1 || newHead(2) gridSize isGameOver true; break; end % 撞身体检测蛇长度大于1时检查 if size(snake, 1) 1 ismember(newHead, snake(1:end-1, :), rows) isGameOver true; break; end很多第一次写贪吃蛇的人会把撞墙写成newHead gridSize0.5或者别的边界值其实完全没有必要。既然坐标范围是1到20只要检测新头是否小于1或者大于20就行等于20仍然在边界内等于21才算越界。2.3 键盘回调与游戏循环的配合Matlab的图形窗口支持KeyPressFcn回调只要窗口处于焦点状态用户按下键盘上的任一按键就会触发该回调函数。回调函数会接收到两个参数第二个参数event的结构体里有一个Key字段保存按键名称。方向键的名称是uparrow、downarrow、leftarrow、rightarrow。这里最需要留意的是回调函数本身并不直接移动蛇它只修改direction这个方向向量。真正驱动游戏前进的是主循环。主循环每执行一次pause(speed)就把CPU让回给图形系统让键盘事件有机会被处理。如果少了pause(speed)整个循环会疯狂占用CPU按键回调即便触发了也无法得到有效响应。function onKeyPress(~, event) switch event.Key case uparrow if direction(2) ~ -1 direction [0 1]; end case downarrow if direction(2) ~ 1 direction [0 -1]; end case leftarrow if direction(1) ~ 1 direction [-1 0]; end case rightarrow if direction(1) ~ -1 direction [1 0]; end end end同时我加了反向限制。如果蛇当前向上按向下的方向键会被忽略这样能避免蛇瞬间180度掉头直接撞到自己。这是贪吃蛇的基本规则也是防呆设计的关键。3. 完整代码与逐段讲解3.1 初始化窗口和状态变量我把初始化分成三类第一类是参数包括棋盘大小、速度、初始蛇长第二类是运行时数据包括蛇的坐标矩阵、方向向量、食物、分数、游戏结束标志第三类是UI资源包括窗口和坐标区。窗口创建时要注意KeyPressFcn必须在创建figure时直接绑定避免后续忘记设置。同时把MenuBar和ToolBar关掉让界面更干净。坐标区的背景色我调成深色这样可以突出绿色的蛇和红色的食物。3.2 绘制函数把蛇画成一个个小矩形drawScene()是我用来重绘画面的函数。每次调用先cla(ax)把坐标区清空再依次绘制底板、网格线、食物、蛇身和分数。蛇头用更亮的绿色蛇身体从深绿渐变到微微发黄视觉上能明显分出主次。每个格子对应的矩形位置是[x-0.4, y-0.4, 0.8, 0.8]也就是只占格子中间80%的区域边缘留白这样蛇身之间能看出缝隙画面更精致。function drawScene() cla(ax); % 底板 rectangle(ax, Position, [0.5, 0.5, gridSize, gridSize], ... FaceColor, [0.08 0.08 0.08], EdgeColor, [0.5 0.5 0.5], ... LineWidth, 1.2); % 网格线 for k 1 : gridSize - 1 plot(ax, [k0.5, k0.5], [0.5, gridSize 0.5], Color, [0.28 0.28 0.28]); plot(ax, [0.5, gridSize 0.5], [k0.5, k0.5], Color, [0.28 0.28 0.28]); end % 食物 rectangle(ax, Position, [food(1)-0.4, food(2)-0.4, 0.8, 0.8], ... FaceColor, [1, 0.3, 0.3], EdgeColor, [1, 1, 0], LineWidth, 1.5); % 蛇身 for i 1 : size(snake, 1) if i 1 color [0.1, 0.85, 0.3]; else color [0.1, 0.55, 0.1] 0.03 * (i - 1) / size(snake, 1); end rectangle(ax, Position, [snake(i,1)-0.4, snake(i,2)-0.4, 0.8, 0.8], ... FaceColor, color, EdgeColor, [0.95 0.95 0.95], ... LineWidth, 1.2); end % 分数 text(ax, gridSize - 1, gridSize 0.8, sprintf(Score: %d, score), ... Color, [1 1 1], FontSize, 16, ... FontWeight, bold, HorizontalAlignment, right); drawnow; end3.3 主循环移动、吃、判定、结束主循环是游戏的心脏。每一帧执行四件事计算新蛇头、做各种碰撞检测、更新蛇身、调用drawScene重绘。循环条件用while ~isGameOver一旦检测到撞墙或撞身就置isGameOver true并跳出循环。循环结束后我选择close(fig)关闭窗口再用msgbox弹出一个得分提示框。有些玩家希望游戏结束后窗口不关闭方便截图那也可以把close(fig)注释掉具体看个人偏好。这里有几个实现细节想特别说明。一是ismember(newHead, snake(1:end-1,:), rows)中的rows参数一定不能少否则比较的是向量并列而不是逐行匹配。二是isequal(newHead, food)判断吃到食物时用的也是单个坐标点进行比较不需要写循环。三是pause(speed)的位置必须放在drawScene之后以保证画面刷新完成后再进入下一帧。3.4 完整代码保存后直接运行下面给出完整代码。新建一个snakeGame.m文件粘贴保存在命令窗口输入snakeGame即可启动。运行环境是MATLAB R2018a我这边实测没有报错。function snakeGame() % 贪吃蛇小游戏 - MATLAB R2018a % 使用方向键控制蛇头吃到食物加10分 clc; close all; % 可调参数 gridSize 20; % 棋盘大小 speed 0.15; % 每帧间隔秒越小越快 initLen 3; % 初始蛇身长度 % % 蛇的数据每行是 [x y]第1行是蛇头 center round(gridSize/2); snake [center center]; for i 1 : initLen - 1 snake [snake; center - i, center]; end direction [1 0]; % 初始向右 food generateFood(); score 0; isGameOver false; % 创建图形窗口 fig figure(Name, MatLab贪吃蛇小游戏, NumberTitle, off, ... Color, [0.2 0.2 0.2], ... KeyPressFcn, onKeyPress, ... MenuBar, none, ToolBar, none, ... Position, [120, 80, 640, 640]); ax axes(Parent, fig, Position, [0.06, 0.06, 0.86, 0.86]); axis(ax, equal); axis(ax, off); xlim(ax, [0.5, gridSize 0.5]); ylim(ax, [0.5, gridSize 0.5]); hold(ax, on); set(ax, XTick, [], YTick, [], Color, [0.08 0.08 0.08]); % 画初始画面 drawScene(); % 游戏主循环 while ~isGameOver % 计算新蛇头 newHead snake(1, :) direction; % 撞墙检测 if newHead(1) 1 || newHead(1) gridSize || ... newHead(2) 1 || newHead(2) gridSize isGameOver true; break; end % 撞身体检测 if size(snake, 1) 1 ismember(newHead, snake(1:end-1, :), rows) isGameOver true; break; end % 移动新蛇头插到最前面 snake [newHead; snake]; % 是否吃到食物 if isequal(newHead, food) score score 10; food generateFood(); else snake(end, :) []; end % 重绘 drawScene(); pause(speed); end % 游戏结束 close(fig); msgbox(sprintf(游戏结束\n最终得分%d, score), 贪吃蛇, warn); % 嵌套函数定义 function onKeyPress(~, event) switch event.Key case uparrow if direction(2) ~ -1 direction [0 1]; end case downarrow if direction(2) ~ 1 direction [0 -1]; end case leftarrow if direction(1) ~ 1 direction [-1 0]; end case rightarrow if direction(1) ~ -1 direction [1 0]; end end end function pos generateFood() while true pos randi(gridSize, 1, 2); if ~ismember(pos, snake, rows) break; end end end function drawScene() cla(ax); rectangle(ax, Position, [0.5, 0.5, gridSize, gridSize], ... FaceColor, [0.08 0.08 0.08], EdgeColor, [0.5 0.5 0.5], ... LineWidth, 1.2); for k 1 : gridSize - 1 plot(ax, [k0.5, k0.5], [0.5, gridSize 0.5], Color, [0.28 0.28 0.28]); plot(ax, [0.5, gridSize 0.5], [k0.5, k0.5], Color, [0.28 0.28 0.28]); end rectangle(ax, Position, [food(1)-0.4, food(2)-0.4, 0.8, 0.8], ... FaceColor, [1, 0.3, 0.3], EdgeColor, [1, 1, 0], ... LineWidth, 1.5); for i 1 : size(snake, 1) if i 1 color [0.1, 0.85, 0.3]; else color [0.1, 0.55, 0.1] 0.03 * (i - 1) / size(snake, 1); end rectangle(ax, Position, [snake(i,1)-0.4, snake(i,2)-0.4, 0.8, 0.8], ... FaceColor, color, EdgeColor, [0.95 0.95 0.95], ... LineWidth, 1.2); end text(ax, gridSize - 1, gridSize 0.8, sprintf(Score: %d, score), ... Color, [1 1 1], FontSize, 16, ... FontWeight, bold, HorizontalAlignment, right); drawnow; end end4. 运行效果、排障与扩展玩法4.1 运行效果和参数调整运行成功后会弹出一个深灰色窗口里面画着一块20×20的棋盘蛇是一条绿色的小链子食物是红色方块。按方向键后蛇开始移动吃到食物以后尾部保留身体变长分数加10。如果撞到墙壁或自己的身体窗口关闭并弹出最终得分。个人建议把speed初始值设在0.1到0.2之间。0.15是比较舒服的节奏既不会快到看不清也不会慢到无聊。如果想要挑战手感可以每吃5个食物就加速一次在吃到食物的分支后面追加一行speed max(0.05, speed - 0.005);。gridSize改成15以后游戏节奏更紧凑改成30以后棋盘空间变大操作余地更多但食物相对难找各有各的乐趣。4.2 新手常踩的坑和对应排查方案我在调试这个游戏过程中遇到过几个典型问题整理成表格方便大家对照排查。现象常见原因解决办法按方向键完全没有反应窗口没有焦点或者回调没有绑定成功先鼠标点击一下窗口确认KeyPressFcn写在figure创建参数中蛇的移动方向与按键相反坐标轴方向理解偏差检查是否用了axis equal以及绘图时是否混淆 x 和 y蛇不动但命令窗口没报错主循环里缺少pause(speed)CPU被占满在drawScene后加上pause(speed)食物偶尔生成在蛇身上使用了网格标记法但比较逻辑错误确认使用ismember(pos, snake, rows)而不是普通数组游戏结束后窗口画布混乱回调函数访问了已关闭的图形对象在循环外先判断isvalid(fig)再做绘制蛇吃食物后瞬间变很长删除尾部的代码被放在了if外面确认snake(end,:)[]只在没吃到食物时执行还有一个小坑很容易被忽略在回调函数中为了调试临时写的disp(event.Key)如果忘记删掉每次按键都会刷新命令窗口导致游戏运行卡顿明显。调试完一定要记得清理。4.3 扩展玩法建议代码跑通之后改造空间非常大我列几条自己做过的尝试供大家参考。第一增加难度选择。在游戏开始前调用menu函数弹出一个选择框让用户选“简单、中等、困难”不同难度对应不同的初始speed然后把选择结果传入主循环。这个改动只需要在初始化参数阶段加几行非常容易。第二加入障碍物。初始化时可以生成一个障碍物坐标矩阵然后在while循环里把新蛇头和障碍物做一次ismember判断碰到障碍物同样设置isGameOver。障碍物可以随机生成也可以从外部文件读取玩法立刻丰富起来。第三实现最高分保存。利用save和load把最高分存到本地mat文件里。游戏结束时读取旧记录如果当前分数更高就覆盖保存。这样每次玩都有个追赶目标。第四把绘制接口换成patch或者image。用image配合颜色矩阵可以画出更平滑的蛇身颜色甚至可以实现蛇身渐变的动画效果视觉效果会完全不同。我在实际跑这套代码的时候有一点印象非常深Matlab的回调机制和游戏循环之间的配合是整个项目最容易翻车也最值得理解的地方。很多人习惯在GUI程序里用input或者waitfor等待输入但在游戏循环里这种做法会直接把主线程堵死。正确思路是“持续轮询 事件驱动”主循环不断刷新画面和状态键盘回调只负责更新最小的方向变量。最后再分享一个小技巧如果你在别的机器上运行发现方向键名称对不上可以先在onKeyPress里加一行disp(event.Key)按下你想要的按键命令窗口会立刻显示该按键在Matlab里的标准名称。实际使用中我发现部分系统下小键盘方向键的event.Key名称和主方向键一致但为了兼容性还是建议用主键盘的方向键操作。