☰
用Matlab矩阵思维实现贪吃蛇:GUI编程与游戏循环实战
2026/10/13 11:44:48 网站建设 项目流程

最近在整理手头的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 > gridSize+0.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, [k+0.5, k+0.5], [0.5, gridSize + 0.5], 'Color', [0.28 0.28 0.28]); plot(ax, [0.5, gridSize + 0.5], [k+0.5, k+0.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

3.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, [k+0.5, k+0.5], [0.5, gridSize + 0.5], 'Color', [0.28 0.28 0.28]); plot(ax, [0.5, gridSize + 0.5], [k+0.5, k+0.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 end

4. 运行效果、排障与扩展玩法

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名称和主方向键一致,但为了兼容性,还是建议用主键盘的方向键操作。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询