我会说去吧。我在学习 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