06.Arduino UNO Q 像素跑酷——浏览器交互与LED矩阵实时联动

一、项目简介
Mascot Jump 是 Arduino UNO Q 官方示例中的一个互动项目,灵感来自 Chrome 断网时的小恐龙跑酷。
在浏览器里按空格键控制像素角色跳跃,躲避电阻、晶体管、芯片等电子元件障碍物,分数越高速度越快。与此同时,UNO Q 板子上的 8×13 LED 点阵屏 会实时显示角色的动画——跑动、跳跃、失败,和浏览器画面联动。
二、硬件准备
| 硬件 | 数量 |
|---|---|
| Arduino UNO Q | ×1 |
| USB 数据线 | ×1 |
三、系统架构
整个项目分为三层,UNO Q 的双芯片(Linux MPU + MCU)各司其职:
| 层级 | 跑在哪 | 干什么 |
|---|---|---|
| 前端 | 浏览器 | Canvas 画游戏画面、捕获键盘 |
| 后端 | UNO Q Linux 侧 (Python) | 管物理、碰撞、计分,60FPS 循环 |
| 硬件 | UNO Q MCU 侧 (C++) | 驱动 LED 矩阵显示角色动画 |
通信方式:
- 浏览器 ↔ Python:
WebUIBrick 提供的 WebSocket,双向 JSON 消息 - Python ↔ Arduino:
RouterBridge跨芯片调用,Arduino 每 50ms 问一次 “现在什么状态?”
四、完整代码
4.1 Python 后端 —— main.py
# SPDX-FileCopyrightText: Copyright (C) Arduino s.r.l. and/or its affiliated companies
#
# SPDX-License-Identifier: MPL-2.0
from arduino.app_utils import *
from arduino.app_bricks.web_ui import WebUI
import time
import random
import threading
import json
# ============================================================
# 常量配置
# ============================================================
GAME_WIDTH = 800 # 画布宽
GAME_HEIGHT = 300 # 画布高
GROUND_Y = 240 # 地面 Y 坐标
FPS = 60 # 目标帧率
MASCOT_WIDTH = 44 # 角色碰撞盒宽
MASCOT_HEIGHT = 48 # 角色碰撞盒高
MASCOT_X = 80 # 角色固定 X 位置
OBSTACLE_WIDTH = 18 # 障碍物统一宽度
# 三种障碍物
OBSTACLE_TYPES = [
{'name': 'resistor', 'height': 28}, # 电阻(小)
{'name': 'transistor', 'height': 38}, # 晶体管(中)
{'name': 'microchip', 'height': 48} # 芯片(大)
]
# 物理参数
JUMP_VELOCITY = -12.5 # 跳跃初速度(负 = 向上)
GRAVITY = 0.65 # 重力加速度
BASE_SPEED = 6.0 # 初始速度
# 障碍物生成间隔 (ms)
SPAWN_MIN_MS = 900
SPAWN_MAX_MS = 1500
# ============================================================
# GameState —— 游戏核心状态管理
# ============================================================
class GameState:
def __init__(self):
self.reset()
self.high_score = 0
def reset(self):
self.mascot_y = GROUND_Y - MASCOT_HEIGHT
self.velocity_y = 0.0
self.on_ground = True
self.obstacles = []
self.score = 0
self.game_over = False
self.speed = BASE_SPEED
self.last_spawn_time = time.time()
self.next_spawn_delay = random.uniform(
SPAWN_MIN_MS/1000, SPAWN_MAX_MS/1000
)
def update_physics(self, dt):
"""更新物理:重力 + 位置 + 落地检测"""
if not self.on_ground:
# dt*60 保证不同帧率下物理表现一致(帧无关)
self.velocity_y += GRAVITY * dt * 60
self.mascot_y += self.velocity_y * dt * 60
# 落地
if self.mascot_y >= GROUND_Y - MASCOT_HEIGHT:
self.mascot_y = GROUND_Y - MASCOT_HEIGHT
self.velocity_y = 0.0
self.on_ground = True
def update_obstacles(self, dt):
"""移动障碍物 + 生成新障碍物"""
current_time = time.time()
# 所有障碍物向左移动
for obstacle in self.obstacles:
obstacle['x'] -= self.speed * dt * 60
# 移除飞出屏幕的
self.obstacles = [
obs for obs in self.obstacles
if obs['x'] > -OBSTACLE_WIDTH - 10
]
# 时间到了就生成新障碍物
if current_time - self.last_spawn_time >= self.next_spawn_delay:
self.spawn_obstacle()
self.last_spawn_time = current_time
self.next_spawn_delay = random.uniform(
SPAWN_MIN_MS/1000, SPAWN_MAX_MS/1000
)
def spawn_obstacle(self):
"""随机生成一个障碍物(从右侧入场)"""
obstacle_type = random.choice(OBSTACLE_TYPES)
height = obstacle_type['height']
obstacle = {
'x': GAME_WIDTH + 30,
'y': GROUND_Y - height,
'width': OBSTACLE_WIDTH,
'height': height,
'type': obstacle_type['name']
}
self.obstacles.append(obstacle)
def check_collisions(self):
"""AABB 碰撞检测"""
mascot_rect = {
'x': MASCOT_X,
'y': self.mascot_y,
'width': MASCOT_WIDTH,
'height': MASCOT_HEIGHT
}
for obstacle in self.obstacles:
if self.rectangles_intersect(mascot_rect, obstacle):
self.game_over = True
self.high_score = max(self.high_score, self.score)
return True
return False
def rectangles_intersect(self, rect1, rect2):
"""判断两个轴对齐矩形是否相交"""
return not (
rect1['x'] + rect1['width'] < rect2['x'] or
rect2['x'] + rect2['width'] < rect1['x'] or
rect1['y'] + rect1['height'] < rect2['y'] or
rect2['y'] + rect2['height'] < rect1['y']
)
def jump(self):
"""跳跃(仅在地面且未结束时有效)"""
if self.on_ground and not self.game_over:
self.velocity_y = JUMP_VELOCITY
self.on_ground = False
return True
return False
def to_dict(self):
"""序列化为字典,发给前端"""
return {
'mascot_y': self.mascot_y,
'velocity_y': self.velocity_y,
'on_ground': self.on_ground,
'obstacles': self.obstacles,
'score': self.score,
'high_score': self.high_score,
'game_over': self.game_over,
'speed': self.speed
}
# ============================================================
# 全局实例
# ============================================================
game = GameState()
ui = WebUI()
game_running = True
game_started = False # 惰性启动:第一次跳跃才真正开始
# ============================================================
# LED 状态查询 —— 供 Arduino 调用
# ============================================================
def get_led_state():
"""返回当前状态字符串,Arduino 据此显示对应动画"""
global game_started
if game.game_over:
return "game_over"
elif not game_started and game.score == 0:
return "idle"
elif not game.on_ground:
return "jumping"
else:
return "running"
# ============================================================
# 游戏主循环 —— 60FPS
# ============================================================
def game_loop():
global game_running, game_started
last_update = time.time()
while game_running:
current_time = time.time()
dt = current_time - last_update
if not game.game_over:
game.update_physics(dt)
game.update_obstacles(dt)
game.check_collisions()
game.score += int(60 * dt) # 每秒约 60 分
game.speed = BASE_SPEED + (game.score / 1500.0) # 越跑越快
# 广播状态给浏览器
ui.send_message('game_update', game.to_dict())
last_update = current_time
# 控制帧率
sleep_time = max(0, (1/FPS) - (time.time() - current_time))
time.sleep(sleep_time)
# ============================================================
# WebSocket 事件处理
# ============================================================
def on_player_action(client_id, data):
"""处理玩家操作:跳跃 / 重开"""
global game_started
action = data.get('action')
if action == 'jump':
game_started = True
if game.jump():
ui.send_message('jump_confirmed', {'success': True})
elif action == 'restart':
game.reset()
game_started = True
ui.send_message('game_reset', {'state': game.to_dict()})
def on_client_connected(client_id, data):
"""新客户端连上来,发送初始状态和画布配置"""
ui.send_message('game_init', {
'state': game.to_dict(),
'config': {
'width': GAME_WIDTH,
'height': GAME_HEIGHT,
'ground_y': GROUND_Y,
'mascot_x': MASCOT_X,
'mascot_width': MASCOT_WIDTH,
'mascot_height': MASCOT_HEIGHT
}
})
# ============================================================
# 注册 + 启动
# ============================================================
ui.on_message('player_action', on_player_action)
ui.on_message('client_connected', on_client_connected)
Bridge.provide("get_led_state", get_led_state)
App.run(user_loop=game_loop)
关键代码说明
| 代码 | 干了什么 |
|---|---|
JUMP_VELOCITY = -12.5 |
负值 = 向上。按空格瞬间给一个向上的速度,然后重力把它拉回来 |
GRAVITY = 0.65 |
重力大小。越大下落越快,手感越"沉" |
velocity_y += GRAVITY * dt * 60 |
乘 dt*60 的作用:即使帧率波动(比如掉到 30 帧),物理效果也不变 |
rectangles_intersect() |
判断两个矩形有没有重叠。任何一边不重叠就算没撞上 |
speed = BASE_SPEED + score/1500 |
每 1500 分速度 +1,线性变难 |
Bridge.provide("get_led_state", ...) |
把 Python 函数暴露给 Arduino 调用——UNO Q 双芯片通信的核心 API |
4.2 Arduino 固件 —— sketch.ino
// SPDX-FileCopyrightText: Copyright (C) Arduino s.r.l. and/or its affiliated companies
//
// SPDX-License-Identifier: MPL-2.0
#include <Arduino_RouterBridge.h>
#include <Arduino_LED_Matrix.h>
#include "game_frames.h"
Arduino_LED_Matrix matrix;
int animationFrame = 0;
unsigned long lastFrameTime = 0;
const unsigned long ANIMATION_DELAY = 200; // 每帧停留 200ms
void setup() {
matrix.begin();
matrix.setGrayscaleBits(3); // 3-bit 灰度:每个像素 0~7 级亮度
Bridge.begin();
}
void loop() {
String gameState;
// 通过 Bridge 调用 Python 端的 get_led_state()
bool ok = Bridge.call("get_led_state").result(gameState);
if (ok) {
if (gameState == "running") {
// 跑动:4帧循环动画,200ms 切一帧
unsigned long currentTime = millis();
if (currentTime - lastFrameTime > ANIMATION_DELAY) {
animationFrame = (animationFrame + 1) % 4;
lastFrameTime = currentTime;
}
switch(animationFrame) {
case 0: matrix.draw(running_frame1); break;
case 1: matrix.draw(running_frame2); break;
case 2: matrix.draw(running_frame3); break;
case 3: matrix.draw(running_frame4); break;
}
} else if (gameState == "jumping") {
matrix.draw(jumping);
animationFrame = 0;
} else if (gameState == "game_over") {
matrix.draw(game_over);
animationFrame = 0;
} else {
// idle 或未知状态
matrix.draw(idle);
animationFrame = 0;
}
} else {
// 通信失败,显示 idle
matrix.draw(idle);
}
delay(50); // ~20Hz 刷新率
}
关键代码说明
| 代码 | 干了什么 |
|---|---|
Bridge.call("get_led_state").result(gameState) |
Arduino 通过 SPI 总线调用 Python 函数,拿到当前状态字符串 |
animationFrame = (animationFrame + 1) % 4 |
4 帧跑步动画循环切换,%4 保证 0→1→2→3→0 |
ANIMATION_DELAY = 200 |
每帧 200ms,4 帧一个循环 800ms,模拟走路节奏 |
matrix.setGrayscaleBits(3) |
开启 3-bit 灰度,每个像素 8 级亮度而非简单的开/关 |
delay(50) |
主循环 20Hz,不需要像游戏那样 60 帧(LED 矩阵刷新够用) |
game_frames.h包含了 7 帧 8×13 像素数组(running_frame1~4、jumping、game_over、idle),每帧 104 个字节,值 0~7 代表亮度。篇幅原因不展开,可在 App Lab 中查看。
4.3 前端渲染
前端由 index.html + app.js 组成(App Lab 自动管理),主要做这些事:
- 用 HTML5 Canvas 画游戏画面
- 预加载 6 张 PNG 精灵图(4 种跑动 + 跳跃 + 倒地)
- 通过 WebSocket 收
game_update→ 刷新画面 - 按键事件(空格/↑ 跳,R 重开)→ 发
player_action给后端 - 三种障碍物各有不同外观:电阻带色环、晶体管带三引脚、芯片标 IC555
五、核心机制解析
5.1 物理模型
跳跃过程就是最简单的匀加速运动:
按空格 → 给一个向上的速度 (-12.5)
↓
每帧加重力 (速度 +0.65)
↓
位置向上 → 到达最高点 → 开始下落
↓
碰到地面 → 速度归零,停止
- 从起跳到落地大约 0.64 秒,最高能跳约 120 像素
- 滞空期间,障碍物相对移动约 230 像素
- 障碍物间距约 324~540 像素 → 一次跳跃刚好能越过一个障碍物(参数精心平衡过的)
5.2 碰撞检测
用的是最常见的 AABB(轴对齐包围盒) 方法:
两矩形相交 = X轴有重叠 AND Y轴有重叠
X轴有重叠? A的右边 > B的左边 AND B的右边 > A的左边
Y轴有重叠? A的下边 > B的上边 AND B的下边 > A的上边
代码里的 not (A or B or C or D) 写法是"排除不相交的情况",和上面说的逻辑等价,只是写法不同。角色的碰撞盒(44×48)比精灵图略小一圈,给玩家一点"擦边不死"的宽容度。
5.3 难度递增
speed = 6.0 + score / 1500
| 分数 | 速度 | 感觉 |
|---|---|---|
| 0 | 6.0 | 轻松上手 |
| 1500 | 7.0 | 开始有感觉 |
| 3000 | 8.0 | 需要专注 |
| 6000 | 10.0 | 手忙脚乱 |
| 15000 | 16.0 | 神仙操作 |
线性递增,不会突然变难,高手可以跑很久。分母 1500 决定了增速——改大增速慢,改小增速快。
5.4 三层时序
Python 主循环 (60Hz): 物理→碰撞→广播 │ 物理→碰撞→广播 │ ...
↓ ↓
浏览器 Canvas: 收状态→重绘 收状态→重绘
Arduino LED (20Hz): 查询→显示 查询→显示 查询→显示
三层各跑各的,互不阻塞。Python 60 帧保证游戏流畅,LED 20 帧对点阵屏完全够用。
六、运行步骤
- 在 Arduino App Lab 里打开 Mascot Jump 示例
- 点右上角 ** Run**
- 浏览器会自动打开游戏页面(或手动访问
http://板子名.local:7000) - 空格键 / ↑键 起跳,R 键 重来
七、参数调整
想改手感和难度?动 main.py 顶部这几个数就行:
| 参数 | 默认 | 调大 | 调小 | 建议范围 |
|---|---|---|---|---|
GRAVITY |
0.65 | 下落快,手感重 | 轻飘飘 | 0.3~1.2 |
JUMP_VELOCITY |
-12.5 | 跳更高 | 跳更矮 | -8~-18 |
BASE_SPEED |
6.0 | 开局就快 | 开局慢 | 3~10 |
SPAWN_MIN_MS |
900 | 障碍物更稀疏 | 更密集 | 500~2000 |
分母 1500 |
1500 | 变难更慢 | 变难更快 | 500~5000 |
LED 动画想自定义?改 game_frames.h 里的 8×13 数组,值 0~7 控制亮度:0=灭,4=中等,7=最亮。
八、总结
Mascot Jump 代码量不大(不到 300 行),但涉及的技术点很全面:
| 技术 | 实现方式 |
|---|---|
| 游戏循环 | 60FPS + sleep 帧率控制 |
| 物理引擎 | 匀加速运动 + 帧无关 dt 缩放 |
| 碰撞检测 | AABB 矩形相交 |
| 难度系统 | 速度随分数线性递增 |
| 实时通信 | WebSocket 双向 JSON 消息 |
| 跨芯片通信 | Bridge RPC(SPI 总线) |
| 硬件驱动 | LED 矩阵 3-bit 灰度动画 |
| 前端渲染 | Canvas 2D + 精灵图 |
对想学习 Arduino UNO Q 双芯片架构 或 嵌入式交互应用开发 的朋友来说,是个很好的入门案例。
本案例基于 Arduino UNO Q 官方示例 Mascot Jump Game,代码采用 MPL-2.0 协议。

