【问题标题】:Suggestion to Build a multiplayer Texas Holdem Poker game using NodeJS, Flutter使用 NodeJS、Flutter 构建多人德州扑克游戏的建议
【发布时间】:2022-01-06 00:22:57
【问题描述】:

我一直在使用 Android 构建基本和复杂的移动应用程序,Flutter 也了解 NodeJS。我已经为德州扑克构建了一个基本的 NodeJs 多人游戏服务器。多个房间和桌子逻辑仍然存在。

我想在 Flutter 中开发一个客户端移动应用,因为我对 Flutter 有很深的了解。

我一直在探索将在 Flutter 中使用的包和方便的工具,但仍然一无所知。

Flutter 中的这个游戏开发对我来说是一个新的挑战,所以我想问一下这种游戏的技术栈好不好?

我应该考虑从 Flutter 切换到其他东西吗?

【问题讨论】:

    标签: node.js flutter sockets multiplayer poker


    【解决方案1】:

    我会说去吧。我在学习 Flutter 时自己创建了一个纸牌游戏 - 只需要一个有趣的项目来看看它。

    和你一样,我在后端使用了 node.js,让我可以和朋友一起玩游戏。

    我最终将 GraphQL 用于我的后端服务(使用 AWS AppSync 服务)。由于 GraphQL 将通过 websocket 为您提供推送通知,因此非常适合在玩游戏的不同客户端之间发送数据。使用 Dynamo DB 部署在 AWS Lambda 上的 Node.js 可以正常工作。

    我最后还做了一个 AI 算法(使用蒙特卡洛树搜索),使我能够与 AI 进行游戏。最后一点 - 我将我的后端 node.js 接口移植到 dart,这样我就可以完全离线玩与 AI 的游戏了。

    我认为我花了最多时间研究如何制作卡片动画,特别是因为我希望卡片移动顺序发生。 AI有时会走得太快,所以有多张牌同时飞来飞去。一旦完成 - 剩下的就很容易了。

    如果您需要更多详细信息,请告诉我。我很高兴与您分享部分代码。

    编辑:这里有一些代码细节。我将尝试编辑您不需要的部分代码,因此其中一些可能一开始无法编译......请记住 - 有些事情在我的代码中可能过于复杂 - 因为我得到了这些东西启动并运行我会继续前进,所以这里有很多需要改进的地方......

    首先是后端。正如我所提到的,我使用 AWS:Dynamo DB 来存储游戏数据;一个 lambda 函数来完成所有工作;和 AppSync API:仅仅因为它向客户端提供消息推送 - 您无需继续轮询后端以查看是否有任何更改。最后 - 我使用 Cognito 用户池对用户进行身份验证。在我的应用中,您可以创建您的帐户,通过电子邮件等方式进行验证。

    我使用 AWS Amplify 设置后端:这是一个 AWS 框架,即使您对 AWS 不太了解,也可以轻松部署后端。它实际上适用于不需要或不想详细了解 AWS 的应用程序开发人员。即使您熟悉 AWS - 它仍然非常有用,因为它会为您自动执行大量安全布线,并帮助您自动执行部署。

    目前有官方的amplify-flutter包;在我这样做的时候,我使用了 3rd 方包https://pub.dev/packages/amazon_cognito_identity_dart_2

    现在我假设您已完成后端设置:您在 Lambda 中部署了 node.js 代码,现在您需要 AppSync 架构。这是我的:

    type Match @aws_iam @aws_cognito_user_pools {
        matchId: Int
        matchData: AWSJSON!
    }
    
    type PlayEvent @aws_iam @aws_cognito_user_pools {
        matchId: Int!
        success: Boolean!
        playerId: Int!
        eventType: String!
        inputData: AWSJSON
        eventData: AWSJSON
        matchData: AWSJSON
    }
    
    input PlayEventInput {
        matchId: Int!
        eventType: String!
        eventData: AWSJSON
        playerId: Int
    }
    
    
    
    type Query {
      getMatch(matchId: Int): Match @function(name: "tresetaFv2-${env}") @aws_iam @aws_cognito_user_pools
    }
    
    type Mutation {
      playEvent(input: PlayEventInput!): PlayEvent @function(name: "tresetaFv2-${env}") @aws_iam @aws_cognito_user_pools
    }
    
    type Subscription {
      onPlayEvent(matchId: Int, success: Boolean): PlayEvent @aws_subscribe(mutations: ["playEvent"])
    }
    

    如您所见,我经常使用 AWSJSON 数据类型 - 而不是在 GraphQL 中编码我的整个游戏架构,我只是简单地来回传递 JSON。

    需要了解的几件事:

    • MatchData 类型保存您的游戏状态:每个玩家持有什么牌、牌组中的牌、桌子上的牌、轮到谁玩等等。
    • 从活动匹配列表中选择匹配后,客户端应用程序将触发查询 getMatch。当您加入游戏时,这就是您获取游戏状态的方式。
    • Mutation playEvent 是您下棋的方式:您传递 PlayEventInput 类型 - 指定 matchID 和 playerID、事件类型(在您的情况下,您可以玩牌、弃牌、跟注...)。 playEvent Mutation 将返回 PlayEvent - 告诉你移动是否成功(当时合法吗?轮到你玩了吗?),它会返回 MatchData - 移动后的新游戏状态。
    • 最后 - 订阅 onPlayEvent。每个客户端加入比赛后(通过比赛 ID),都会订阅这个订阅。从订阅定义中可以看出——它会订阅 playEvent 突变:每次玩家下一个动作,它都会通知所有其他人该动作——并传递突变的结果,这样每个人都会得到完整的游戏状态以刷新其 UI。一个不错的技巧是 - 您订阅了成功 = true 的突变,因此您不会为失败的移动推送任何消息。

    您还会看到一些注释 - 这就是您告诉 Amplify 为您连接东西的方式:

    • @function(name: "tresetaFv2-${env}"): 你告诉它调用名为 "tresetaFv2-${env}" 的 Lambda 函数来实际完成工作
    • @aws_iam @aws_cognito_user_pools - 这就是你告诉它这个 API 由 Cognito 用户池保护的方式。

    现在我们有了 AWS 后端设置。那么您如何实际运行查询并订阅来自后端的事件呢?

    首先,我在代码中使用了这个 UserService:https://github.com/furaiev/amazon-cognito-identity-dart-2/blob/main/example/lib/user_service.dart

    这是我的 AppSync 服务 - 这只是调用 AppSync 的一种通用方式:

    import 'dart:convert';
    
    import 'package:amazon_cognito_identity_dart_2/cognito.dart';
    import 'package:flutter/foundation.dart';
    import 'package:http/http.dart' as http;
    import 'package:treseta_app/auth/auth_services.dart';
    import 'package:web_socket_channel/web_socket_channel.dart';
    import 'package:web_socket_channel/io.dart';
    import 'package:web_socket_channel/web_socket_channel.dart';
    
    
    
    class AppSyncImpl {
      final String _endPoint;
    
      String get _wsEndPoint => _endPoint.replaceAll('https', 'wss').replaceAll('appsync-api', 'appsync-realtime-api');
    
      String get _host => _endPoint.replaceAll('https://', '').replaceAll('/graphql', '');
    
      UserService _userService;
      set userService(UserService userService) => this._userService = userService;
    
      AppSyncImpl(this._endPoint, this._userService);
    
      Future<http.Response> getHttpResponse(Map<String, String> query) async {
        CognitoUserSession session = await _userService.getSession();
    
        return http.post(
          _endPoint,
          headers: {
            'Authorization': session.getAccessToken().getJwtToken(),
            'Content-Type': 'application/json',
          },
          body: json.encode(query),
        );
      }
    
      Future<Map<String, dynamic>> glQueryMutation(Map<String, String> query) async {
        http.Response response;
        try {
          response = await getHttpResponse(query);
        } catch (e) {
          print(e);
        }
    
        return json.decode(response.body);
      }
    
      int wsTimeoutInterval;
    
      void disconnectWebSocketChannel(WebSocketChannel wsChannel, String uniqueKey) {
        wsChannel.sink.add(json.encode({'type': 'stop', 'id': uniqueKey}));
      }
    
      Future<WebSocketChannel> wsSubscription(
          {@required Map<String, String> query,
          @required String uniqueKey,
          Function(Map<String, dynamic>) listener}) async {
        var jwtToken = (await _userService.getSession()).getIdToken().getJwtToken();
    
        var header = {'host': _host, 'Authorization': jwtToken};
    
    
        var encodedHeader = Base64Codec().encode(Utf8Codec().encode(json.encode(header)));
        // Note 'e30=' is '{}' in base64
        var wssUrl = '$_wsEndPoint?header=$encodedHeader&payload=e30=';
    
        var channel = IOWebSocketChannel.connect(wssUrl, protocols: ['graphql-ws']);
    
        channel.sink.add(json.encode({"type": "connection_init"}));
        channel.stream.listen((event) {
          var e = json.decode(event);
    
          switch (e['type']) {
            case 'connection_ack':
              wsTimeoutInterval = e['payload']['connectionTimeoutMs'];
    
              var register = {
                'id': uniqueKey,
                'payload': {
                  'data': json.encode(query),
                  'extensions': {'authorization': header}
                },
                'type': 'start'
              };
              var payload = json.encode(register);
              channel.sink.add(payload);
              break;
            case 'data':
              listener(e);
              break;
            case 'ka':
              print('Reminder: keep alive is not yet implemented!!!');
              break;
            case 'start_ack':
              print('Ws Channel: Subscription started');
              break;
            default:
              print('Unknown event $event');
          }
        }, onError: (error, StackTrace stackTrace) {
          print('Ws Channel: $error');
        }, onDone: () {
          channel.sink.close();
          print('Ws Channel: Done!');
        });
    
        return channel;
      }
    }
    

    这是真正的后端服务;注意 GraphQL Schema(getMatchData、playEvent、subscribeWS)中的每个查询、突变和订阅是如何对应的函数:

    import 'dart:async';
    import 'dart:convert';
    import 'package:flutter/foundation.dart';
    import 'package:flutter/widgets.dart';
    
    import 'package:treseta_app/appsync/app_sync.dart';
    import 'package:treseta_app/auth/auth_services.dart';
    import 'package:treseta_app/models/api_models.dart' as api;
    
    import 'package:meta/meta.dart';
    import 'package:treseta_app/models/game_model.dart';
    import 'package:web_socket_channel/web_socket_channel.dart';
    
    class AwsBackendService extends ChangeNotifier {
      final subscriptionStreamCtrl = StreamController<api.PlayEvent>.broadcast();
      Stream get subscriptionStream => subscriptionStreamCtrl.stream;
    
      UserService userService;
      String endPoint;
    
      AppSyncImpl _appSyncImpl;
      BuildContext context;
    
      AwsBackendService({@required this.context, this.userService, this.endPoint})
          : _appSyncImpl = AppSyncImpl(endPoint, userService);
    
      @override
      Future<api.Match> getMatchData(int matchId) async {
        final query = {
          'operationName': 'GetMatch',
          'query': '''query GetMatch { getMatch(matchId:$matchId){matchData, playerId}}'''
        };
    
        User user = await userService.getCurrentUser();
        String username = user.name;
    
        var matchData = await _appSyncImpl.glQueryMutation(query);
    
        var match = api.Match.fromJson(matchData['data']['getMatch']);
        match.matchData.username = username;
    
        return match;
      }
    
      Future<api.PlayEvent> playEvent(int matchId, int playerId, String eventType, api.PlayEventInputData eventData) async {
        var encoded = json.encode(eventData.toJson()).replaceAll("\"", "\\\"");
        final query = {
          'operationName': 'PlayEvent',
          'query':
              '''mutation PlayEvent { playEvent(input:{matchId:$matchId, eventType:"$eventType", eventData:"$encoded"}){matchId, eventData, success, playerId, eventType, inputData, matchData}}''',
        };
    
        api.PlayEvent result =
            await _appSyncImpl.glQueryMutation(query).then((value) => api.PlayEvent.fromJson(value['data']['playEvent']));
    
        User user = await userService.getCurrentUser();
        String username = user.name;
    
        result.matchData.username = username;
    
        return result;
      }
    
      WebSocketChannel _wsClient;
      String _wsUniqeKey;
      Future<void> subscribeWs(int matchId, int playerId, MatchData model) async {
        final query = {
          'operationName': 'OnPlayEvent',
          'query':
              'subscription OnPlayEvent { onPlayEvent(matchId:$matchId, success:true) {matchId, success,playerId,eventType,inputData,eventData,matchData}}',
        };
    
        _wsUniqeKey = UniqueKey().toString();
        _wsClient = await _appSyncImpl.wsSubscription(
            query: query,
            uniqueKey: _wsUniqeKey,
            listener: (Map<String, dynamic> message) {
              var playEvent = api.PlayEvent.fromJson(message['payload']['data']['onPlayEvent']);
              subscriptionStreamCtrl.sink.add(playEvent);
            });
        return;
      }
    
      void disconnect() {
        try {
          if (_wsClient != null) {
            _appSyncImpl.disconnectWebSocketChannel(_wsClient, _wsUniqeKey);
          }
        } catch (e) {
          print(e);
        }
      }
    
      @override
      void dispose() {
        super.dispose();
        subscriptionStreamCtrl.close();
        disconnect();
      }
    }
    

    您会看到我们从 AppSync 订阅接收到的每个事件都只是被注入到一个 Stream 对象中。

    最后,保存匹配数据并通知 UI 的主要 ChangeNotifier Provider 是这样的 - 您将看到传入的订阅事件是如何处理的,并且 UI 被通知有一个新事件要动画化 - 一张卡片被抛出,或新手等。

    import 'dart:async';
    import 'package:flutter/widgets.dart';
    import 'package:treseta_app/backend/backend_service.dart';
    import 'package:treseta_app/models/api_models.dart';
    import 'package:treseta_app/models/game_model.dart';
    
    class Treseta extends ChangeNotifier {
      MatchData model;
    
      int playerId;
    
      AwsBackendService _backendService;
      StreamSubscription backendServiceSubscription;
    
      AwsBackendService get backendService => _backendService;
    
      set backendService(BackendService value) {
        if (backendServiceSubscription != null) backendServiceSubscription.cancel();
        _backendService = value;
    
        // this is where we process the incoming subscription messages:
        backendServiceSubscription = _backendService.subscriptionStream.listen((inEvent) {
          PlayEvent playEvent = inEvent as PlayEvent;
          playCard(
              inData: playEvent,
              playerId: playEvent.playerId,
              card: playEvent.inputData.card);
        });
      }
    
      int _matchId;
      int get matchId => _matchId;
      set matchId(int value) {
        _matchId = value;
        subscribeAndGetMatchData();
      }
    
      Future<void> disconnect() async => backendService.disconnect();
    
      Future<MatchData> getMatchData() async {
        var match = await backendService.getMatchData(matchId);
        playerId = match.playerId;
    
        model = match.matchData;
    
        return model;
      }
    
      Future<void> subscribeAndGetMatchData() async {
        if (matchId != null) {
          disconnect();
          await getMatchData();
          await subscribe();
        }
        return;
      }
    
      Future<void> subscribe() async {
        backendService.subscribeWs(matchId, playerId, this.model);
      }
    
      void playCard(
          {GameCard card,
          GlobalKey sourceKey,
          GlobalKey targetKey,
          int playerId = 0,
          PlayEvent inData}) async {
        // Animation notifier logic goes here...
      }
    
      @override
      void dispose() {
        disconnect();
        backendServiceSubscription.cancel();
        super.dispose();
      }
    }
    

    你去 - 我希望这会有所帮助。 还有一点关于如何为卡片制作动画 - 最大的挑战是按顺序运行动画 - 即使玩家足够快可以非常快速地玩卡片。如果你愿意,我也可以发布一些关于它的细节。

    *** 再编辑一次 **** 我用一个简单的动画示例创建了一个 repo。如果我有时间,我会在 Readme 文件中写一些 cmets...

    https://github.com/andrija78/cards_demo

    【讨论】:

    • 感谢您的反馈,我会努力的。
    • 能否请您提供一些用于多人演示的flutter + node js代码。这样我才能有基础。创建节点 js 服务器后,我有点迷失了。对于单挑,这将非常有帮助。
    • 当然,我今天或明天会在这里发布一些内容。我将尝试逐步解释基础知识。
    • 我用后端通信部分更新了我的答案。接下来我会尝试分享一些动画创意...
    • 这很棒。我很高兴能做到这一点,并等待您的动画创意。非常感谢。
    猜你喜欢
    • 2012-01-07
    • 2012-05-28
    • 1970-01-01
    • 2014-02-22
    • 2012-05-07
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多