这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » DIY与开源设计 » 电子DIY » 用SeeeduinoWioTerminal跑一个俄罗斯方块

共1条 1/1 1 跳转至页

用SeeeduinoWioTerminal跑一个俄罗斯方块

高工
2026-10-19 02:27:33     打赏

简介

手里这块 Wio Terminal 自带 320×240 TFT、5 向摇杆和三个侧键,平时都是当个串口小终端或者传感器面板用。这次想换个思路:让它上电就玩俄罗斯方块。目标是用一个完整的 Arduino 工程把所有逻辑收进去,按键、显示、节奏控制全部在 MCU 上跑。

工程结构故意保持精简:游戏逻辑放在 game.h 里以 BlocksGame 结构体实现,主程序 tetris.ino 只负责按键扫描、屏幕渲染、串口交互。

整体架构

整个工程分三层:

层文件职责




游戏逻辑game.h方块定义、旋转、碰撞、7 袋抽签、消行、计分、升级
输入与主循环tetris.ino按键扫描、消抖、自动重发、计时下落、按键→动作映射
渲染与外设tetris.ino(draw 系列)TFT_eSprite 双缓冲、调色板、棋盘、幽灵方块、下一个预览
串口辅助tetris.ino + capture-screen.ps1

T 自检、S 

触发原始 RGB565 抓屏

主循环里没有 delay 阻塞节奏,全靠 millis() 比较 fallAt、drawAt、repeatAt[] 等几个时间戳推进游戏。

核心数据结构与算法(game.h)

七种方块用 16 位掩码存进 shapes[7]:

const uint16_t shapes[7]={0x00F0,0x0066,0x0072,0x0071,0x0074,0x0036,0x0063};

每位对应一个 4×4 方格里的一个格子。I(0x00F0)和 O(0x0066)特殊处理:I 占满 4 行、O 占 3×3 的中心方阵,其余方块形状定义后填到 3×3 子区域。

旋转通过原地交换实现,把 (rotation & 3) 次的 px ↔ py 交换串起来,等价于把 (px, py) 反复绕中心旋转 90°:

bool cell(uint8_t t,uint8_t r,int px,int py) const {
  if(t==1) return (shapes[t]>>(py*4+px))&1;
  int size = t==0?4:3;
  if(px>=size||py>=size) return false;
  for(int i=0;i<(r&3);i++){int o=px; px=py; py=size-1-o;}
  return (shapes[t]>>(py*4+px))&1;
}

这套实现的代价是没有专门的旋转表,但省了 7×4 个 4×4 常量;在 Flash 紧张的 MCU 上很划算。

碰撞用 fits(nx, ny, nr) 遍历 4×4 子区域里被 cell() 命中的格子,检查是否越界或落到已有方块上。turn() 实现的是简化的墙踢:

bool turn(){
  if(over||paused) return false;
  uint8_t nr=(rotation+1)%4;
  const int kicks[]={0,-1,1,-2,2};
  for(int dy=0;dy>=-2;dy--)
    for(int kick:kicks)
      if(fits(x+kick,y+dy,nr)){x+=kick;y+=dy;rotation=nr;return true;}
  return false;
}

即在 ±2 行的纵向偏移内尝试 5 种水平偏移。实际玩下来 I 件和 T 件靠这套基本够用,方块被两面墙夹死那种极端情况会失败一次,但不会破坏游戏节奏。

7 袋随机(seven-bag)抽签用 Fisher–Yates 洗牌:

uint8_t take(){
  if(bagPos==7){
    for(int i=0;i<7;i++) bag[i]=i;
    for(int i=6;i>0;i--){
      int j=randomValue()%(i+1);
      uint8_t a=bag[i]; bag[i]=bag[j]; bag[j]=a;
    }
    bagPos=0;
  }
  return bag[bagPos++];
}

随机数用经典的 xorshift32(rng ^= rng<<13; rng ^= rng>>17; rng ^= rng<<5;),重启时用 micros()^analogRead(WIO_LIGHT) 混合作种子,避免每次冷启动第一袋完全一样。

clear() 不用额外 buffer:直接从底向上扫,命中整行就把所有行往下"挤一格"再让指针 row++ 重新检查这一格——因为下一行其实已经是被挤下来的旧内容,必须再判定一次。

升级规则是固定每 10 行升一级:

uint32_t level() const { return lines/10+1; }
uint32_t interval() const {
  int speed=700-(int)(level()-1)*55;
  return speed<90?90:speed;
}

起始 700ms/格,每级减 55ms,下限 90ms。计分沿用经典 100/300/500/800 系数。

输入处理

Wio Terminal 的 5 向摇杆和 A/B/C 三个键都是低有效,因此全部用 INPUT_PULLUP。按键索引直接对应一个动作:

const uint8_t keys[]={
  WIO_5S_LEFT,WIO_5S_RIGHT,WIO_5S_DOWN,WIO_5S_UP,
  WIO_5S_PRESS,WIO_KEY_A,WIO_KEY_B,WIO_KEY_C
};

scan() 做两件事:25ms 消抖确认稳定状态,以及只对方向键(左/右/下)启用自动重发。重复节奏做了区分——左右每 75ms 重发一次,"下"则是每 45ms 重发一次,让长按下时有加速下降的体验:

if(i<=2 && stable[i] && (int32_t)(now-repeatAt[i])>=0){
  action(i);
  repeatAt[i] = now + (i==2?45:75);
}

B/C 是事件型按键,没有自动重发。B 切换暂停,C 直接重置游戏并把 BEST 取一次最大。

action(i) 是按键到游戏动作的单一映射点:摇杆按下 = 硬降,B = 暂停,C = 重开。这样改键位只需要改这个表,不用动游戏逻辑。

渲染层

主屏用了 TFT_eSprite 全屏 sprite 做双缓冲,先在内存画完再 pushSprite(0,0) 一次性推出去,避免边画边闪。

调色板是一个紧凑的 8 元素数组:

const uint16_t palette[]={0x0863,0x363F,0xFFE8,0xBB7F,0x5BFF,0xFD68,0x5F55,0xFAAB};

palette[0] 是背景,palette[1] 是空格的浅点(0x363F),palette[2..7] 对应七种方块(注意索引要 +1 才能和 board 里的 type+1 对上)。

每个落格都用 block(bx, by, color) 画:先填 10×10,再在顶边画一条 7 像素的高光线 TFT_WHITE 做"凸起"效果,这一笔让方块看起来比纯色块立体得多,但只多了几行代码。

幽灵方块(落地轮廓)的实现是单独调用一次 ghost() 算出最终位置,再用同一组 4×4 模板画一遍,但走 outline=true 分支,只画 9×9 矩形边框、不填实色、也不画高光。这样玩家能清楚看到当前方块会落在哪一行,而不会和真正下落中的方块混淆:

if(!game.over){
  int gy=game.ghost();
  for(int j=0;j<4;j++)for(int i=0;i<4;i++)
    if(game.cell(game.type,game.rotation,i,j)){
      block(game.x+i, gy+j, 0x532D, true);
      block(game.x+i, game.y+j, palette[game.type+1]);
    }
}

右侧边栏用 TFT_eSPI 的内置 font(4 号大字、2 号中字、1 号小字)拼出 SCORE / LEVEL / LINES / NEXT / BEST 几块。NEXT 预览用 4×4 子区域画在 201 像素起的区域,每格 10×10。screen.fillRoundRect(..., 2, ...) 的 2 像素圆角让预览看起来比棋盘主体更柔和。

PAUSED / GAME OVER 用一个圆角矩形遮罩盖在棋盘中央,避免一行文字直接糊在背景上:

screen.fillRoundRect(17,86,100,62,6,0x1927);
text(game.over?"GAME OVER":"PAUSED", 23, 98, 2, TFT_WHITE);

主循环里把绘制节流到 now-drawAt>=25,即最快 40fps,避免按键一帧画一次的浪费。

串口抓屏(capture-screen.ps1 + 'S')

Wio Terminal 的 USB-CDC 是 115200 串口,理论上可以把它当成图像采集器以省一个屏幕拍照步骤:

  • 上位机发 S;

  • MCU 把当前 sprite 画一遍,再串口打印 FRAME 320 240;

  • 每行 320 像素 × 每像素 RGB565 的 4 位十六进制(共 1280 字符)+ 行尾换行;

  • 每行末尾上位机回一个 K 作流控,没等够 3000ms 就超时退出。

tetris.ino 里这一段:

void capture(){
  draw();
  Serial.println("FRAME 320 240");
  delay(200);
  const char* hex="0123456789ABCDEF";
  char row[1281]; row[1280]='\n';
  for(int y=0;y<240;y++){
    for(int x=0;x<320;x++){
      uint16_t p=screen.readPixel(x,y);
      for(int n=0;n<4;n++) row[x*4+n]=hex[(p>>(12-n*4))&15];
    }
    Serial.write((uint8_t*)row,sizeof(row));
    uint32_t start=millis();
    while(!Serial.available() && millis()-start<3000) delay(1);
    if(!Serial.available() || Serial.read()!='K') return;
  }
}

screen.readPixel() 在双缓冲 sprite 里是 16-bit 直接读,效率远高于重新计算。PowerShell 端逐行 ReadLine(),把十六进制解回 RGB565 再换算成 RGB888:

$pixel=[Convert]::ToInt32($rows[$y].Substring($x*4,4),16)
$r=(($pixel -shr 11)-band 31)*255/31
$g=(($pixel -shr 5) -band 63)*255/63
$b=( $pixel          -band 31)*255/31
$bitmap.SetPixel($x,$y,[Drawing.Color]::FromArgb([int]$r,[int]$g,[int]$b))

band 是按位与操作的 PowerShell 别名,所以拆位简洁一些。脚本默认串口是 COM11,可以根据实际端口改:

powershell -ExecutionPolicy Bypass -File .\capture-screen.ps1 -Port COM11

如果机器的 PowerShell 策略是 RemoteSigned 又不想放宽,可以直接跑仓库里另一个包好的 capture-now.bat(逻辑完全相同),绕过 .ps1 文件签名检查:

cmd //c .\capture-now.bat

抓一张图实际需要约 30 秒到 1 分钟出图(240 行 × 115200 波特率上行 + 行间 K 回环往返延迟)。

下面是这一脚本通过 COM11 在 Wio Terminal 实机抓到的帧,状态是 PAUSED,能看到 HUD 各项有意义的数值、棋盘里悬浮的红色 Z 件、左下角已落下的绿色 J/L 件、右下角显示的幽灵方块落地轮廓,以及右下 NEXT 预览区域的圆角矩形:

image.png

自检模式

调试时我留了个不接游戏状态的自检入口,发 T 就跑:

bool selfTest(){
  BlocksGame test; test.reset(123);
  for(int t=0;t<7;t++)
    for(int r=0;r<4;r++){
      int n=0;
      for(int y=0;y<4;y++) for(int x=0;x<4;x++)
        n += test.cell(t,r,x,y);
      if(n!=4) return false;
    }
  for(int x=0;x<10;x++){ test.board[19][x]=1; test.board[18][x]=2; }
  test.board[17][0]=3;
  if(test.clear()!=2 || test.board[19][0]!=3) return false;
  // ...fits / drop / paused 等更多用例
  return true;
}

它会校验:

  • 每种方块每种旋转都恰好 4 格;

  • 消行后行内容正确下移;

  • 出生在越界位置应判 false;

  • step() 在暂停时不前进;

  • 硬降能得分。

跑通会回 SELFTEST_OK,否则 SELFTEST_FAILED。这套用例不进游戏主路径,纯逻辑回归——以后改 game.h 时不用手动玩一局确认有没有改坏。实测在 COM11 上回 SELFTEST_OK,自检全部通过。


关键实现里值得说的几个细节

  1. L/R 自动重发节流 75ms,下 45ms:普通按一下方向键移动一格,长按横移不会瞬间滑到底,但长按下时下落感更明显,是手工调出来的折中值。

  2. draw() 里把"绘制时间戳"和"游戏节拍时间戳"分开:按键事件只把 dirty=true,真正画屏幕的动作放在主循环节流(25ms)里。意味着快速连按时也最多重画一次,避免按一下画一帧的闪烁。

  3. screen.createSprite(320,240) 失败时降级到 8-bit 色深:理论上 16-bit sprite 要 153KB SRAM,Wio Terminal 的 SAMD51 实际有 192KB RAM 留给用户,应该够用,但加一层 8-bit 兜底可以让低内存分支也能勉强看到画面。

  4. BEST 不存 Flash:README 里写得很明确,BEST 是本次上电的最高分,断电清零。这样不会写坏用户的 Flash,也不会因为不带 RTC 而出现奇怪的时间基准问题。


截屏脚本

@echo off
setlocal
set "PORT=COM11"
set "OUT=C:\Users\fc3\Documents\Arduino\sketch_oct11a\tetris\screen-now.png"
powershell -NoProfile -Command "param([string]$Port='%PORT%',[string]$OutputPath='%OUT%') $ErrorActionPreference='Stop'; Add-Type -AssemblyName System.Drawing; $serial=[IO.Ports.SerialPort]::new($Port,115200); $serial.ReadBufferSize=262144; $serial.DtrEnable=$true; $serial.ReadTimeout=45000; $serial.WriteTimeout=1000; try { $serial.Open(); Start-Sleep -Milliseconds 300; $serial.DiscardInBuffer(); $serial.Write('S'); do { $line=$serial.ReadLine().Trim() } while ($line -ne 'FRAME 320 240'); Write-Output ('Header OK: ' + $line); $rows=New-Object string[] 240; $badRows=@(); for($y=0;$y -lt 240;$y++){ $rows[$y]=$serial.ReadLine().Trim(); if($rows[$y].Length -ne 1280){ $badRows+=$y; Write-Output ('Row ' + $y + ' length=' + $rows[$y].Length); break }; $serial.Write('K') }; if($badRows.Count -gt 0){ throw ('Incomplete row ' + $badRows[0]) }; $bitmap=New-Object Drawing.Bitmap 320,240; try { for($y=0;$y -lt 240;$y++){ for($x=0;$x -lt 320;$x++){ $pixel=[Convert]::ToInt32($rows[$y].Substring($x*4,4),16); $r=(($pixel -shr 11)-band 31)*255/31; $g=(($pixel -shr 5)-band 63)*255/63; $b=($pixel -band 31)*255/31; $bitmap.SetPixel($x,$y,[Drawing.Color]::FromArgb([int]$r,[int]$g,[int]$b)) } }; $bitmap.Save($OutputPath,[Drawing.Imaging.ImageFormat]::Png) } finally { $bitmap.Dispose() }; Write-Output ('Saved device screen: ' + $OutputPath) } finally { $serial.Dispose() }"
endlocal

总结

这套实现把游戏逻辑放到了 tetris.ino 和 game.h 里,完整覆盖了:7 种方块 + 4 种旋转、7 袋抽签、碰撞、墙踢、幽灵方块、消行、计分、升级、暂停、重开。配合 capture-screen.ps1(或在受限环境下用 capture-now.bat),可以在不依赖任何 IDE 截图工具的前提下从 MCU 拉出真实屏幕帧用作发布素材。最后说一下:这个截图脚本是非常好用的一个工具,通常在使用屏幕做效果的时候都需要用手机来拍照现在直接截屏脚本搞定


附件

tetris.zip





关键词: Wio-terminal     俄罗斯方块    

共1条 1/1 1 跳转至页

回复

匿名不能发帖!请先 [ 登陆 注册 ]