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

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

image02


一、项目简介

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:WebUI Brick 提供的 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 帧对点阵屏完全够用。


六、运行步骤

  1. Arduino App Lab 里打开 Mascot Jump 示例
  2. 点右上角 ** Run**
  3. 浏览器会自动打开游戏页面(或手动访问 http://板子名.local:7000
  4. 空格键 / ↑键 起跳,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 协议。

1 个赞