首页
/ 基于 Flutter、Flame 与 Supabase Realtime 构建实时多人对战射击游戏

基于 Flutter、Flame 与 Supabase Realtime 构建实时多人对战射击游戏

2026-09-06 18:51:55作者:霍妲思

本指南以当前仓库中的真实示例项目 examples/realtime/flutter-multiplayer-shooting-game/README.md 为核心,讲解如何用 Flutter + Flame 游戏引擎 + Supabase Realtime(Broadcast 广播与 Presence 在线状态)实现一款双人实时对战射击游戏。读完本文你将掌握:如何从零创建 Supabase 项目并正确配置 anon 客户端密钥、如何把 Flame 单机游戏接入 Realtime 频道做玩家状态同步、如何实现大厅匹配(Presence 计数 + 广播开房)、以及如何在广播被限流时做重试补偿。该示例不依赖服务端代码,纯客户端即可完成对战,适合作为 Web、Android、iOS 等多端联机游戏的入门范本。

一、示例项目概览

整个示例位于仓库 examples/realtime/ 目录下,与本项目另外两个 Realtime 示例(flutter-figma-clone 多人白板、nextjs-auth-presence 在线状态)并列,展示同一套 Supabase Realtime 能力在不同场景的落地。

pubspec.yaml 可以看出技术栈与版本约束:

name: flame_realtime_shooting
description: Multiplayer shooting game using Flutter, Flame and Supabase.
publish_to: 'none'
version: 1.0.0+1
environment:
  sdk: '>=2.19.0 <4.0.0'
dependencies:
  flutter:
    sdk: flutter
  supabase_flutter: ^2.0.0
  flame: ^1.12.0
  uuid: ^4.2.1
  • supabase_flutter: ^2.0.0:负责 Supabase.initialize、Realtime 频道创建、Presence 跟踪与 Broadcast 收发;
  • flame: ^1.12.0:2D 游戏引擎,负责游戏循环(update/render)、Sprite 渲染、手势(PanDetector)与碰撞检测(HasCollisionDetection);
  • uuid: ^4.2.1:为每位玩家与每局游戏生成唯一 ID(Uuid().v4())。

项目目录结构(省略平台壳工程)如下,游戏资源通过 flutter: assets: 声明加载:

lib/
├── main.dart          # Supabase 初始化、大厅逻辑、游戏频道订阅
└── game/
    ├── game.dart      # FlameGame 主类:游戏循环、开火、胜负判定
    ├── player.dart    # 玩家/对手实体:移动、镜像坐标、血条、碰撞
    └── bullet.dart    # 子弹实体:直线运动、命中即消失
assets/images/         # background / player / opponent / bullet 精灵图

运行环境说明:该示例是一个标准的 Flutter 工程,仓库中同时包含 android/ios/web/ 三个平台壳,因此只要目标环境能跑 Flutter 就能运行(详见下文第五节)。

二、环境准备:创建 Supabase 项目并获取 API 凭据

游戏本身不需要数据库表,其联机能力完全建立在 Supabase Realtime 之上,因此准备工作非常轻量。

  1. 创建项目:登录 supabase.com/dashboard 创建一个新项目,等待数据库初始化完成(即使不建表,也需要一个运行中的项目来提供 Realtime 服务)。
  2. 获取 URL 与 Key:进入 Project Settings(齿轮图标)→ API 标签页,找到 Project URL(形如 https://xxxx.supabase.co)以及 anon / publishable 公钥。
  3. 原 README 对 Key 的说明中曾同时提到 anon key 与 publishable key,二者在客户端 API 语义上等价,即为后续代码中 anonKey 参数要填的客户端公开密钥。它允许用户在未登录时以"匿名访问"方式连接 Realtime 服务;一旦用户完成登录,客户端会切换到用户自己的登录令牌。切勿把 service_role 密钥填到客户端,那会绕过行级安全策略,属于高危泄露。

main.dart 顶部,两处初始化占位符就是要替换的位置:

await Supabase.initialize(
  url: 'supabaseUrl',              // ← 替换为 Project URL
  anonKey: 'supabasePublishableKey', // ← 替换为 anon/publishable 公钥
  realtimeClientOptions: const RealtimeClientOptions(eventsPerSecond: 40),
);

初始化完成后,通过全局单例拿到客户端并驱动整个游戏:

final supabase = Supabase.instance.client;

为什么要设置 eventsPerSecond: 40 这是本示例的一个重要工程细节。对战过程需要在游戏循环中高频上报位置,Supabase Realtime 客户端对每个频道的消息发送速率有限流保护。结合 game.dart 中发送回调的写法可以推断:eventsPerSecond: 40 将发送速率上限调整为每秒 40 条,而发送循环本身对 rateLimited 返回做了重试兜底(见第四节),避免高频上报触发限流后静默丢包。

三、三步跑通:克隆 → 填 Key → flutter run

将仓库克隆到本地(示例位于其中的 examples/realtime/flutter-multiplayer-shooting-game 目录):

git clone <仓库地址>
cd supabase/examples/realtime/flutter-multiplayer-shooting-game

拉取整个仓库后进入该子目录即可,无需单独初始化其他模块。

随后用编辑器打开 lib/main.dart,把 supabaseUrlsupabasePublishableKey 两处占位字符串替换成第二节拿到的真实值。

启动应用:

flutter run

由于工程自带 android/ios/web/ 壳,flutter run 可作用于任何支持 Flutter 的平台。双人对战至少需要两个客户端实例,例如在桌面/模拟器各起一个,或一台真机 + 一个 Web 实例(需使用 flutter run -d chrome),两者加入同一 lobby 大厅后即可开始对战。

四、游戏如何联机:大厅匹配 + 对战同步的双频道架构

整款游戏的网络架构不依赖任何服务端函数,只用到 Supabase Realtime 的两大能力:Broadcast(频道内广播)Presence(在线状态跟踪)。流程分为"大厅"与"对局"两个阶段,分别对应两个 Realtime 频道。

4.1 大厅阶段:Presence 计数 + Broadcast 广播开房

大厅逻辑集中在 _LobbyDialog(见 main.dart)。每个玩家启动时生成唯一 ID 并加入共享频道 lobby

final myUserId = const Uuid().v4();   // 本端玩家身份标识

_lobbyChannel = supabase.channel(
  'lobby',
  opts: const RealtimeChannelConfig(self: true), // 自己的 Presence 也回传给自己
);
_lobbyChannel
    .onPresenceSync((payload, [ref]) {
      // Presence 同步时刷新大厅人数
      final presenceStates = _lobbyChannel.presenceState();
      setState(() {
        _userids = presenceStates
            .map((presenceState) => (presenceState.presences.first)
                .payload['user_id'] as String)
            .toList();
      });
    })
    .onBroadcast(
        event: 'game_start',
        callback: (payload, [_]) {
          // 有人点名本端开局:取出对局 ID,切到对局频道
          final participantIds = List<String>.from(payload['participants']);
          if (participantIds.contains(myUserId)) {
            final gameId = payload['game_id'] as String;
            widget.onGameStarted(gameId);
            Navigator.of(context).pop();
          }
        })
    .subscribe(
      (status, _) async {
        if (status == RealtimeSubscribeStatus.subscribed) {
          await _lobbyChannel.track({'user_id': myUserId});
        }
      },
    );

关键点:

  • RealtimeChannelConfig(self: true)自己也能收到自己 track 的 Presence 状态,这样首个进大厅的玩家也能看到自己的头像计数("N users waiting")。
  • 订阅成功的回调里才调用 _lobbyChannel.track(...) 上报自己的 user_id,这是 Presence 的标准姿势。
  • 大厅界面上"人数 ≥ 2 即可点击 start"。发起者挑选第一个非自己的 userId 作为对手,生成全局唯一的 gameId(同样来自 Uuid().v4()),并向 lobby 频道广播 game_start 事件,payload 携带 participants(含双方 ID)与 game_id
  • 每个客户端在 game_start 回调里检查自己的 myUserId 是否在 participants 中,命中即退出大厅、进入对局——这就是一个"由广播驱动的点名开局"协议。

4.2 对局阶段:独立频道 + 状态广播

任一玩家点击 start 后,双方都会通过 onGameStarted(gameId) 进入同一把对局的专属频道:

_game.startNewGame();

_gameChannel = supabase.channel(gameId,
    opts: const RealtimeChannelConfig(ack: true)); // 开启消息确认

_gameChannel!
    .onBroadcast(
      event: 'game_state',
      callback: (payload, [_]) {
        final position = Vector2(payload['x'] as double, payload['y'] as double);
        final opponentHealth = payload['health'] as int;
        _game.updateOpponent(position: position, health: opponentHealth);

        if (opponentHealth <= 0) {
          if (!_game.isGameOver) {
            _game.isGameOver = true;
            _game.onGameOver(true);
          }
        }
      },
    )
    .subscribe();

此处值得展开的细节:

  • gameId 作为频道名,保证每局对战的广播只在一对玩家间传播,不会串场;
  • RealtimeChannelConfig(ack: true) 开启消息确认:发送方可以感知消息是否送达或被限流(返回 ChannelResponse),这是第四节重试逻辑的前提;
  • 单向镜像同步:双方都只上报自己的状态,收到对端广播后调用 updateOpponent 更新"对手"精灵。

4.3 上报端:移动即广播 + 限流重试

本端状态上报挂在游戏输入与伤害结算上。MyGame 是继承自 Flame 的 FlameGame,混入了 PanDetector(拖拽移动)与 HasCollisionDetection(碰撞检测),见 game.dart

@override
void onPanUpdate(DragUpdateInfo info) {
  _player.move(info.delta.global);
  final mirroredPosition = _player.getMirroredPercentPosition();
  onGameStateUpdate(mirroredPosition, _playerHealthPoint);
  super.onPanUpdate(info);
}

onGameStateUpdate 回调最终落到 main.dart 的发送逻辑,它用 do...while 对限流做了显式补偿:

onGameStateUpdate: (position, health) async {
  ChannelResponse response;
  do {
    response = await _gameChannel!.sendBroadcastMessage(
      event: 'game_state',
      payload: {'x': position.x, 'y': position.y, 'health': health},
    );

    // wait for a frame to avoid infinite rate limiting loops
    await Future.delayed(Duration.zero);
    setState(() {});
  } while (response == ChannelResponse.rateLimited && health <= 0);
},

为什么需要重试 + 镜像坐标?

  • 帧率驱动的输入若每帧都发广播,叠加 4.2 中 eventsPerSecond: 40 的上限,极易返回 ChannelResponse.rateLimited。这里在发送失败后让出一帧再重发,避免死循环占满事件队列;
  • 每个客户端把本机坐标换算为相对自己画布的归一化百分比镜像坐标后再广播(见 player.dartgetMirroredPercentPosition:先 gameRef.size - position 取屏幕镜像点,再除以画布尺寸归一化到 0~1)。对端收到后用 position * size 映射回自己的屏幕坐标系。这样一个消息就能在不同分辨率的设备之间正确还原对手位置,同时把位置信息压缩成两个小数值,显著减小单条广播体积——这是本示例在网络协议设计上最值得借鉴的一招。

接收端还原对手位置的方法同样在 game.dart

void updateOpponent({required Vector2 position, required int health}) {
  _opponent.position = Vector2(size.x * position.x, size.y * position.y);
  _opponent.updateHealth(health / _initialHealthPoints);
}

五、深入游戏侧实现:Flame 单机逻辑与 Realtime 的衔接点

为了让整篇文章可复制、可调试,这里把游戏自身的逻辑也梳理清楚,便于你在调试联机问题时把"单机表现"与"网络同步"分开定位。

5.1 双方实体与初始布局

Player 组件(player.dart)是带圆形碰撞体的精灵:width = height = radius * 2radius = 30.0),anchor 居中。己方初始位于画布 80% 高度处(屏幕下方),对手初始位于 20% 高度处(屏幕上方),由 isMe 区分:

final initialX = gameRef.size.x / 2;
initialPosition = _isMyPlayer
    ? Vector2(initialX, gameRef.size.y * 0.8)
    : Vector2(initialX, gameRef.size.y * 0.2);

每个 Player 还挂了一个 _Gauge 血条子组件,按剩余血量比例从绿→橙→红变色(阈值 50%、25%),用于在本地直观反馈双方扣血。

5.2 子弹、开火与伤害结算

Bullet 组件(bullet.dart)带 passive 圆形碰撞体,每帧按 velocity * dt 位移,越出画布上下边界即自毁。碰撞判定放在 Player.onCollision 中:只有"敌方的子弹"命中"我方的机体"才生效_isMyPlayer != other.isMine),命中后子弹标记 hasBeenHit 并移出场景。

MyGamestartNewGame() 后通过递归定时器每 500ms 开火一轮(见 game.dart_shootBullets):

  • 己方从机身上方朝上发射 3 发呈扇形的子弹(速度 (0,-100)(60,-80)(-60,-80));
  • 对手子弹同样 3 发,方向朝下((0,100)(60,80)(-60,80))——子弹"造型"也镜像对称;
  • 每发子弹伤害固定为 damage = 5,而双方初始生命 _initialHealthPoints = 100,即约 20 发命中才会击落对手。

update 循环里,己方被敌方子弹命中时扣除 damage 并立刻把剩余血量通过 onGameStateUpdate 广播出去,同时刷新血条;当 _playerHealthPoint <= 0 时本端判定自己失败并 endGame(false)。对手的血量则完全由广播驱动(updateOpponent 中的 health 归一化后写入对手血条),只要收到 opponentHealth <= 0 且本端尚未结束,就判定胜利并弹出 You Won! 对话框——这正是"状态最终以对端广播为准"的分布式判定思路。

5.3 重开一局

胜负弹窗点击 "Back to Lobby" 后,会先 supabase.removeChannel(_gameChannel!) 释放对局频道,再重新打开大厅对话框,让两位玩家可以再匹配下一局,形成完整的"大厅 → 对局 → 回大厅"闭环。可以看到 _GamePageState 中对所有频道生命周期都做了管理:_lobbyChanneldispose 时移除,对局频道在结束或退出时移除,避免每次对局积累残留订阅。

六、常见问题与排查建议

以下是结合源码可推导的排障方向,供运行时对照:

现象 可能原因 排查建议
大厅一直显示 "1 users waiting",无法 start 只起了一个客户端,或 Presence 未同步 至少启动两个客户端实例;检查是否在 subscribed 状态后才调用 track;确认 RealtimeChannelConfig(self: true) 已设置
双方位置错乱 / 抖动 分辨率不一致或镜像坐标换算出错 确认广播的是 getMirroredPercentPosition 的归一化结果,接收端用 position * size 还原
高频移动时广播丢失 超过 eventsPerSecond: 40 上限被限流 依赖 do...whilerateLimited 重试;仍丢失可适当提高 eventsPerSecond 并观察服务端限流策略
收不到对手开局广播 participants 与双方 myUserId 不匹配 确认双方处于同一 lobby 频道、事件名一致、payload 的 participants 含本端 ID
对局结束后再次匹配异常 旧频道未释放 确认结束时调用 supabase.removeChannel,必要时在 dispose 中一并清理

七、延伸:Realtime 能力的同类应用

Supabase Realtime 的 Broadcast + Presence 是构建轻量联机能力的通用组合。在本仓库的 examples/realtime 目录下还有两个姊妹示例可对照学习:

三者共享同一套频道 API(channel / onBroadcast / onPresenceSync / track / sendBroadcastMessage),区别只在于业务负载的设计——本示例的镜像归一化坐标与点名开局协议,是其中最具代表性的"把游戏状态压成最小广播"的实践。

八、总结

这个示例项目用最少的外部依赖展示了实时联机游戏的核心链路:

  1. 两段式频道协议:用 lobby(Presence 大厅 + game_start 广播点名)匹配对手,再切换到以 gameId 命名的专属对局频道做高频 game_state 广播,天然隔离多局并发;
  2. 镜像归一化坐标:让不同分辨率的设备能互相同步位置,同时压缩了广播体积;
  3. 限流感知与补偿:通过 eventsPerSecond 配置 + ChannelResponse.rateLimited 检测 + 让帧重试,在高频上报与平台限流之间取得平衡。

如果你的目标是快速验证"Flutter 游戏 + Supabase Realtime"的可行性,或想以此为骨架扩展成子弹更密集、房间人数更多的对战原型,直接在 flutter-multiplayer-shooting-game 目录中填好 URL 与 anon 公钥后 flutter run 即可开始。

登录后查看全文
热门项目推荐
相关项目推荐