轻松打造快乐世界:Flutter Flame实现多人游戏

  • 🤟 一站式轻松构建小程序、Web网站、移动应用 **:👉在线地址
  • 💅小程序共同学习群,有两百多套小程序源码,可免费培训,一起畅谈摸鱼
    在这里插入图片描述

Flutter 是一个 UI 库,用于构建在任何平台上运行的应用程序,但由于构建在 Flutter 之上的名为Flame 的开源游戏引擎,它还可以构建交互式游戏。 Flame 负责碰撞检测或加载图像精灵等工作,为所有 Flutter 开发人员带来游戏开发。我们还可以更进一步引入实时通讯功能,以便玩家可以实时对战。

在本文中,我们将利用 Flutter、Flame 和 Supabase 的实时特性来构建一个实时多人射击游戏。您可以在此处找到本教程的完整代码。

一场比赛的概述

暂时无法在飞书文档外展示此内容

该游戏是一款简单的射击游戏。每个玩家都有一个 UFO,你可以通过在屏幕上拖动手指来移动它。 UFO会自动向三个方向发射子弹,游戏的目标是在你的UFO被对手的子弹摧毁之前用子弹击中对手。位置和健康点使用 Supabase 提供的低延迟 Web 套接字连接进行同步。

在进入主游戏之前,有一个大厅可以等待其他玩家出现。一旦另一位玩家出现,您就可以点击开始,这将在两端开始游戏。

我们将首先构建用于构建基本 UI 的 Flutter 小部件,然后构建 Flame 游戏,最后处理网络连接以在连接的客户端之间共享数据。

构建应用程序

步骤 1. 创建 Flutter 应用程序

我们将从创建 Flutter 应用程序开始。打开终端并创建一个使用以下命令命名的新应用程序。

flutter create flame_realtime_shooting

使用您最喜欢的 IDE 打开创建的应用程序,让我们开始编码!

步骤 2. 构建 Flutter 小部件

我们将有一个简单的目录结构来构建这个应用程序。由于我们只有几个小部件,因此我们只需将它们添加到main.dart文件中。

├── lib/
|   ├── game/
│   │   ├── game.dart
│   │   ├── player.dart
│   │   └── bullet.dart
│   └── main.dart

创建游戏主页面

我们将为该应用程序创建最小的 Flutter 小部件,因为大部分游戏逻辑稍后将在 Flame Game 类中处理。我们的游戏在游戏开始前和游戏结束后将有一个包含两个对话框的页面。该页面将仅包含GameWidget,同时显示漂亮的背景图像。我们将使其成为 StatefulWidget,因为稍后我们将添加处理发送和接收实时数据的方法。将以下内容添加到main.dart文件中。

import 'package:flame/game.dart';
import 'package:flame_realtime_shooting/game/game.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'UFO Shooting Game',
      debugShowCheckedModeBanner: false,
      home: GamePage(),
    );
  }
}

class GamePage extends StatefulWidget {
  const GamePage({Key? key}) : super(key: key);

  @override
  State<GamePage> createState() => _GamePageState();
}

class _GamePageState extends State<GamePage> {
  late final MyGame _game;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        fit: StackFit.expand,
        children: [
          Image.asset('assets/images/background.jpg', fit: BoxFit.cover),
          GameWidget(game: _game),
        ],
      ),
    );
  }

  @override
  void initState() {
    super.initState();
    _initialize();
  }

  Future<void> _initialize() async {
    _game = MyGame(
      onGameStateUpdate: (position, health) async {
        // TODO: handle gmae state update here
      },
      onGameOver: (playerWon) async {
        // TODO: handle when the game is over here
      },
    );

    // await for a frame so that the widget mounts
    await Future.delayed(Duration.zero);

    if (mounted) {
      _openLobbyDialog();
    }
  }

  void _openLobbyDialog() {
    showDialog(
        context: context,
        barrierDismissible: false,
        builder: (context) {
          return _LobbyDialog(
            onGameStarted: (gameId) async {
              // handle game start here
            },
          );
        });
  }
}

您会看到一些错误,因为我们正在导入一些尚未创建的文件,但不用担心,因为我们很快就会处理它。

创建大厅对话框

大厅对话框类表面上是一个简单的警报对话框,但会保留自己的状态,例如在大厅等待的玩家列表。稍后我们还将添加一些类来处理大厅的存在数据,但现在我们只有一个简单的 AlertDialog。在 main.dart 文件末尾添加以下代码。

class _LobbyDialog extends StatefulWidget {
  const _LobbyDialog({
    required this.onGameStarted,
  });

  final void Function(String gameId) onGameStarted;

  @override
  State<_LobbyDialog> createState() => _LobbyDialogState();
}

class _LobbyDialogState extends State<_LobbyDialog> {
  final List<String> _userids = [];
  bool _loading = false;

  /// TODO: assign unique identifier for the user
  final myUserId = '';

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      title: const Text('Lobby'),
      content: _loading
          ? const SizedBox(
              height: 100,
              child: Center(child: CircularProgressIndicator()),
            )
          : Text('${_userids.length} users waiting'),
      actions: [
        TextButton(
          onPressed: _userids.length < 2
              ? null
              : () async {
                  setState(() {
                    _loading = true;
                  });

                  // TODO: notify the other player the start of the game
                },
          child: const Text('start'),
        ),
      ],
    );
  }
}

步骤 3. 构建 Flame 组件

创建火焰游戏

现在有趣的部分开始了!我们将从创建游戏类开始。我们创建一个MyGame扩展FlameGame类的类。FlameGame负责碰撞检测和泛检测,它也将是我们添加到游戏中的所有组件的父组件。该游戏包含 2 个组件,Player以及Bullet.MyGame是一个类,它包装了游戏的所有组件,并且可以控制子组件。

让我们为我们的应用程序添加火焰。运行以下命令:

flutter pub add flame

然后我们就可以创建MyGame类了。在lib/game.dart文件中添加以下代码。

import 'dart:async';

import 'package:flame/game.dart';
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/image_composition.dart' as flame_image;
import 'package:flame_realtime_shooting/game/bullet.dart';
import 'package:flame_realtime_shooting/game/player.dart';
import 'package:flutter/material.dart';

class MyGame extends FlameGame with PanDetector, HasCollisionDetection {
  MyGame({
    required this.onGameOver,
    required this.onGameStateUpdate,
  });

  static const _initialHealthPoints = 100;

  /// Callback to notify the parent when the game ends.
  final void Function(bool didWin) onGameOver;

  /// Callback for when the game state updates.
  final void Function(
    Vector2 position,
    int health,
  ) onGameStateUpdate;

  /// `Player` instance of the player
  late Player _player;

  /// `Player` instance of the opponent
  late Player _opponent;

  bool isGameOver = true;

  int _playerHealthPoint = _initialHealthPoints;

  late final flame_image.Image _playerBulletImage;
  late final flame_image.Image _opponentBulletImage;

  @override
  Color backgroundColor() {
    return Colors.transparent;
  }

  @override
  Future<void>? onLoad() async {
    final playerImage = await images.load('player.png');
    _player = Player(isMe: true);
    final spriteSize = Vector2.all(Player.radius * 2);
    _player.add(SpriteComponent(sprite: Sprite(playerImage), size: spriteSize));
    add(_player);

    final opponentImage = await images.load('opponent.png');
    _opponent = Player(isMe: false);
    _opponent.add(SpriteComponent.fromImage(opponentImage, size: spriteSize));
    add(_opponent);

    _playerBulletImage = await images.load('player-bullet.png');
    _opponentBulletImage = await images.load('opponent-bullet.png');

    await super.onLoad();
  }

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

  @override
  void update(double dt) {
    super.update(dt);
    if (isGameOver) {
      return;
    }
    for (final child in children) {
      if (child is Bullet && child.hasBeenHit && !child.isMine) {
        _playerHealthPoint = _playerHealthPoint - child.damage;
        final mirroredPosition = _player.getMirroredPercentPosition();
        onGameStateUpdate(mirroredPosition, _playerHealthPoint);
        _player.updateHealth(_playerHealthPoint / _initialHealthPoints);
      }
    }
    if (_playerHealthPoint <= 0) {
      endGame(false);
    }
  }

  void startNewGame() {
    isGameOver = false;
    _playerHealthPoint = _initialHealthPoints;

    for (final child in children) {
      if (child is Player) {
        child.position = child.initialPosition;
      } else if (child is Bullet) {
        child.removeFromParent();
      }
    }

    _shootBullets();
  }

  /// shoots out bullets form both the player and the opponent.
  ///
  /// Calls itself every 500 milliseconds
  Future<void> _shootBullets() async {
    await Future.delayed(const Duration(milliseconds: 500));

    /// Player's bullet
    final playerBulletInitialPosition = Vector2.copy(_player.position)
      ..y -= Player.radius;
    final playerBulletVelocities = [
      Vector2(0, -100),
      Vector2(60, -80),
      Vector2(-60, -80),
    ];
    for (final bulletVelocity in playerBulletVelocities) {
      add((Bullet(
        isMine: true,
        velocity: bulletVelocity,
        image: _playerBulletImage,
        initialPosition: playerBulletInitialPosition,
      )));
    }

    /// Opponent's bullet
    final opponentBulletInitialPosition = Vector2.copy(_opponent.position)
      ..y += Player.radius;
    final opponentBulletVelocities = [
      Vector2(0, 100),
      Vector2(60, 80),
      Vector2(-60, 80),
    ];
    for (final bulletVelocity in opponentBulletVelocities) {
      add((Bullet(
        isMine: false,
        velocity: bulletVelocity,
        image: _opponentBulletImage,
        initialPosition: opponentBulletInitialPosition,
      )));
    }

    _shootBullets();
  }

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

  /// Called when either the player or the opponent has run out of health points
  void endGame(bool playerWon) {
    isGameOver = true;
    onGameOver(playerWon);
  }
}

这里有很多内容,所以让我们来分解一下。在该onLoad方法中,我们加载整个游戏中使用的所有精灵。然后我们添加玩家和对手组件。

在 中onPanUpdate,我们处理用户在屏幕上的拖动。请注意,我们调用onGameStateUpdate回调来传递玩家的位置,以便稍后在处理网络连接时将其共享给对手的客户端。另一方面,我们有方法updateOpponent,用于使用来自网络的信息来更新对手的状态。稍后我们还将添加从 Flutter 小部件调用它的逻辑。

游戏开始后,_shootBullets()玩家和对手都会发射子弹。_shootBullets()是一个递归函数,每 500 毫秒调用一次自身。如果子弹击中玩家,它就会被捕获在该udpate()方法内,该方法在每一帧上都会被调用。在那里我们计算新玩家的生命值。

创建播放器组件

Player组件具有 UFO 精灵并代表玩家和对手。它PositionComponent从火焰延伸而来。添加以下内容lib/player.dart

import 'dart:async';

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame_realtime_shooting/game/bullet.dart';
import 'package:flutter/material.dart';

class Player extends PositionComponent with HasGameRef, CollisionCallbacks {
  Vector2 velocity = Vector2.zero();

  late final Vector2 initialPosition;

  Player({required bool isMe}) : _isMyPlayer = isMe;

  /// Whether it's me or the opponent
  final bool _isMyPlayer;

  static const radius = 30.0;

  @override
  Future<void>? onLoad() async {
    anchor = Anchor.center;
    width = radius * 2;
    height = radius * 2;

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

    add(CircleHitbox());
    add(_Gauge());
    await super.onLoad();
  }

  void move(Vector2 delta) {
    position += delta;
  }

  void updateHealth(double healthLeft) {
    for (final child in children) {
      if (child is _Gauge) {
        child._healthLeft = healthLeft;
      }
    }
  }

  @override
  void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollision(intersectionPoints, other);
    if (other is Bullet && _isMyPlayer != other.isMine) {
      other.hasBeenHit = true;
      other.removeFromParent();
    }
  }

  /// returns the mirrored percent position of the player
  /// to be broadcasted to other clients
  Vector2 getMirroredPercentPosition() {
    final mirroredPosition = gameRef.size - position;
    return Vector2(mirroredPosition.x / gameRef.size.x,
        mirroredPosition.y / gameRef.size.y);
  }
}

class _Gauge extends PositionComponent {
  double _healthLeft = 1.0;

  @override
  FutureOr<void> onLoad() {
    final playerParent = parent;
    if (playerParent is Player) {
      width = playerParent.width;
      height = 10;
      anchor = Anchor.centerLeft;
      position = Vector2(0, 0);
    }
    return super.onLoad();
  }

  @override
  void render(Canvas canvas) {
    super.render(canvas);
    canvas.drawRect(
        Rect.fromPoints(
          const Offset(0, 0),
          Offset(width, height),
        ),
        Paint()..color = Colors.white);
    canvas.drawRect(
        Rect.fromPoints(
          const Offset(0, 0),
          Offset(width * _healthLeft, height),
        ),
        Paint()
          ..color = _healthLeft > 0.5
              ? Colors.green
              : _healthLeft > 0.25
                  ? Colors.orange
                  : Colors.red);
  }
}

你可以看到它有一个_isMyPlayer属性,对于玩家来说是 true,对于对手来说是 false。如果我们看一下该onLoad方法,我们可以使用它将其定位在顶部(如果是对手)或底部(如果是玩家)。我们还可以看到我们添加了一个CircleHitbox,因为我们需要检测它与子弹之间的碰撞。最后,我们将其添加_Gauge为它的子项,这是您在每个玩家顶部看到的健康点计量表。在onCollision回调中,我们检查碰撞对象是否是对手的子弹,如果是,我们将子弹标记为hasBeenHit并将其从游戏中删除。

getMirroredPercentPosition方法在与对手的客户端共享位置时使用。它计算玩家的镜像位置。updateHealth当健康状况发生变化并更新类的条形长度时调用_Gauge

添加项目符号

最后我们将添加Bullet类。它代表从玩家和对手射出的一颗子弹。在onLoad其中添加精灵以应用漂亮的图像,CircleHitbox以便它可以与其他对象发生碰撞。您还可以看到它velocity在构造函数中接收 a ,使用方法内的速度和经过的时间更新位置update。这就是让它以恒定速度沿单一方向移动的方法。

import 'dart:async';

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/image_composition.dart' as flame_image;

class Bullet extends PositionComponent with CollisionCallbacks, HasGameRef {
  final Vector2 velocity;

  final flame_image.Image image;

  static const radius = 5.0;

  bool hasBeenHit = false;

  final bool isMine;

  /// Damage that it deals when it hits the player
  final int damage = 5;

  Bullet({
    required this.isMine,
    required this.velocity,
    required this.image,
    required Vector2 initialPosition,
  }) : super(position: initialPosition);

  @override
  Future<void>? onLoad() async {
    anchor = Anchor.center;

    width = radius * 2;
    height = radius * 2;

    add(CircleHitbox()
      ..collisionType = CollisionType.passive
      ..anchor = Anchor.center);

    final sprite =
        SpriteComponent.fromImage(image, size: Vector2.all(radius * 2));

    add(sprite);
    await super.onLoad();
  }

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;

    if (position.y < 0 || position.y > gameRef.size.y) {
      removeFromParent();
    }
  }
}

步骤4.添加玩家之间的实时通讯

此时,我们已经有了一个可以运行的射击游戏,只是对手不动,因为我们没有添加任何客户端之间进行通信的方式。我们将为此使用 Supabase 的实时功能,因为它为我们提供了一个开箱即用的解决方案来处理玩家之间的低延迟实时通信。如果您尚未创建 Supabase 项目,请转至database.new创建一个项目。

在开始任何编码之前,让我们将 Supabase SDK 安装到我们的应用程序中。我们还将使用uuid 包为用户生成随机的唯一 ID。运行以下命令:

1

flutter pub add supabase_flutter uuid

完成后pub get,让我们初始化 Supabase。我们将重写该main函数来初始化 Supabase。您可以在Project Setting获取您的 Supabase URL 和匿名密钥API。将它们复制并粘贴到Supabase.initialize通话中。

void main() async {
  await Supabase.initialize(
  url: 'YOUR_SUPABASE_URL',
  anonKey: 'YOUR_ANON_KEY',
  realtimeClientOptions: const RealtimeClientOptions(eventsPerSecond: 40),
  );
  runApp(const MyApp());
}

// Extract Supabase client for easy access to Supabase
final supabase = Supabase.instance.client;

RealtimeClientOptions这是一个参数,用于覆盖每个客户端每秒向 Supabase 发送的事件数。默认值为 10,但我们希望覆盖为 40 以提供更加同步的体验。

至此,我们现在就可以开始添加实时功能了。

处理大厅等待其他玩家出现

我们将从重写_Lobby类开始,我们在大厅要做的第一件事是等待并检测也在大厅的其他在线用户。我们可以使用Supabase 中的状态功能来实现这一点。

添加initState并在其中初始化一个RealtimeChannel实例。我们可以这样称呼它_lobbyChannel。如果我们看一下该subscribe()方法,我们可以看到,在成功订阅大厅频道后,我们正在跟踪我们在 uplon 初始化时创建的唯一用户 ID。我们正在监听该sync事件,以便在有人“在场”时收到通知。在回调中,我们提取大厅中所有用户的 userId 并将其设置为状态。当有人点击按钮时,游戏开始Start。如果我们看一下onPressed回调,我们会发现我们正在向大厅频道发送一个广播事件,其中包含两个参与者 ID 和一个随机生成的游戏 ID。广播是 Supabase 的一项功能,用于在客户端之间发送和接收轻量级低延迟数据,当start两端都收到两个参与者(其中一个是点击按钮的人)时,游戏就开始了。我们可以在事件initState回调内部观察到game_start,在接收到广播事件时,它会检查玩家是否是参与者之一,如果是,它将回调回调onGameStarted并弹出导航器以删除对话框。比赛开始了!

class _LobbyDialogState extends State<_LobbyDialog> {
  List<String> _userids = [];
  bool _loading = false;

  /// Unique identifier for each players to identify eachother in lobby
  final myUserId = const Uuid().v4();

  late final RealtimeChannel _lobbyChannel;

  @override
  void initState() {
    super.initState();

    _lobbyChannel = supabase.channel(
      'lobby',
      opts: const RealtimeChannelConfig(self: true),
    );
    _lobbyChannel
        .onPresenceSync((payload, [ref]) {
          // Update the lobby count
          final presenceStates = _lobbyChannel.presenceState();

          setState(() {
            _userids = presenceStates
                .map((presenceState) => (presenceState.presences.first)
                    .payload['user_id'] as String)
                .toList();
          });
        })
        .onBroadcast(
            event: 'game_start',
            callback: (payload, [_]) {
              // Start the game if someone has started a game with you
              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});
            }
          },
        );
  }

  @override
  void dispose() {
    supabase.removeChannel(_lobbyChannel);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      title: const Text('Lobby'),
      content: _loading
          ? const SizedBox(
              height: 100,
              child: Center(child: CircularProgressIndicator()),
            )
          : Text('${_userids.length} users waiting'),
      actions: [
        TextButton(
          onPressed: _userids.length < 2
              ? null
              : () async {
                  setState(() {
                    _loading = true;
                  });

                  final opponentId =
                      _userids.firstWhere((userId) => userId != myUserId);
                  final gameId = const Uuid().v4();
                  await _lobbyChannel.sendBroadcastMessage(
                    event: 'game_start',
                    payload: {
                      'participants': [
                        opponentId,
                        myUserId,
                      ],
                      'game_id': gameId,
                    },
                  );
                },
          child: const Text('start'),
        ),
      ],
    );
  }
}

与对方玩家分享游戏状态

一旦游戏开始,我们需要同步两个客户端之间的游戏状态。在我们的例子中,我们将仅同步玩家的位置和生命值。每当玩家移动或玩家的生命值发生变化时,onGameStateUpdate我们MyGame实例上的回调就会触发,通知更新及其位置和生命值。我们将通过 Supabase广播功能将这些信息广播给对手的客户端。

填写_initialize如下方法来初始化游戏。

class GamePage extends StatefulWidget {
  const GamePage({Key? key}) : super(key: key);

  @override
  State<GamePage> createState() => _GamePageState();
}

class _GamePageState extends State<GamePage> {
  late final MyGame _game;

  /// Holds the RealtimeChannel to sync game states
  RealtimeChannel? _gameChannel;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        fit: StackFit.expand,
        children: [
          Image.asset('assets/images/background.jpg', fit: BoxFit.cover),
          GameWidget(game: _game),
        ],
      ),
    );
  }

  @override
  void initState() {
    super.initState();
    _initialize();
  }

  Future<void> _initialize() async {
    _game = MyGame(
      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);
      },
      onGameOver: (playerWon) async {
        await showDialog(
          barrierDismissible: false,
          context: context,
          builder: ((context) {
            return AlertDialog(
              title: Text(playerWon ? 'You Won!' : 'You lost...'),
              actions: [
                TextButton(
                  onPressed: () async {
                    Navigator.of(context).pop();
                    await supabase.removeChannel(_gameChannel!);
                    _openLobbyDialog();
                  },
                  child: const Text('Back to Lobby'),
                ),
              ],
            );
          }),
        );
      },
    );

    // await for a frame so that the widget mounts
    await Future.delayed(Duration.zero);

    if (mounted) {
      _openLobbyDialog();
    }
  }

  void _openLobbyDialog() {
    showDialog(
        context: context,
        barrierDismissible: false,
        builder: (context) {
          return _LobbyDialog(
            onGameStarted: (gameId) async {
              // await a frame to allow subscribing to a new channel in a realtime callback
              await Future.delayed(Duration.zero);

              setState(() {});

              _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();
            },
          );
        });
  }
}

您可以看到,在 中_openLobbyDialog,有onGameStarted一个游戏开始时的回调。游戏开始后,它会使用游戏 ID 作为通道名称创建一个新通道,并开始侦听来自对手的游戏状态更新。您可以看到,在回调中onGameOver,我们显示了一个简单的对话框。点击 后Back to Lobby,用户将返回大厅对话框,如果愿意,他们可以在其中开始另一个游戏。

将所有这些放在一起,我们就拥有了一款功能齐全的实时多人射击游戏。找个朋友,用 运行应用程序flutter run,并享受它的乐趣!

更多资源

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值