根据最近的一项调查,只有五分之二的初创企业能够盈利。MVP(最小可行产品)大大增加了初创企业盈利的机会,因为它允许此类企业收集早期用户反馈,而无需将全部预算花在具有完整功能的应用程序上。
通过 MVP,您可以在短期内以有限的预算构建具有基本功能的应用程序,收集用户反馈,并根据此反馈与您的开发团队继续扩展解决方案。
MVP 在游戏行业越来越受欢迎。今天,我们将探索使用 Flutter 和 Flame 快速开发游戏 MVP 的来龙去脉,Flutter 和 Flame 是构建跨平台最小可行产品的绝佳组合。
Flutter 是一款功能丰富且安全的跨平台开发平台,它已席卷移动应用领域,其影响力远远超出了 UI。借助 Flame(一款基于Flutter构建的强大开源游戏引擎),您可以制作出在 Android、iOS、Web 和桌面设备上流畅运行的精彩 2D 游戏。
Flutter 还成为构建游戏 MVP 的热门解决方案,因为它具有便于快速开发解决方案的集成功能,这些解决方案可在不同设备上呈现基本功能。具体来说,Flutter 的各种优势和集成功能允许:
Flutter 不会消耗太多的计算资源,并且有助于简单地设置跨平台应用程序。
基于 Flutter 和Flame组合的应用 MVP 是一种可靠且相对简单的开发解决方案。它直接编译为本机代码,确保流畅的游戏体验和响应能力。您可以开发一次游戏 MVP 并将其部署到不同的平台上,从而节省时间和资源。Flutter 和 Flame 可以在后台处理平台差异。
此外,这两种技术都拥有活跃的社区,提供大量文档、教程和代码示例。这意味着您永远不会因找不到答案或灵感而苦恼。
Flame 提供了一整套工具,可用于在短时间内创建 MVP 游戏功能,且无需花费过多资源。这个跨平台建模框架为各种不同的用例提供了工具:
上述大多数功能对于许多游戏来说都是必不可少的,即使在 MVP 开发阶段也不应该被忽视。真正重要的是,Flame 大大加快了开发上述功能的速度,让您即使在最早的产品版本中也能发布这些功能。
现在,我们不再讨论 Flame,而是用这个框架创建一个包含我们自己的游戏基本功能的 MVP。在开始之前,您必须安装 Flutter 3.13 或更高版本、您最喜欢的 IDE 和用于测试的设备。
这款游戏的灵感来自 Chrome Dino。啊,著名的 Dino Run!它不仅仅是一款来自 Chrome 的游戏。它是隐藏在浏览器离线模式中的心爱复活节彩蛋。
我们的项目会有以下玩法:
它的名字是“森林奔跑!”
像每次启动新应用时一样,创建一个空的 Flutter 项目。首先,我们需要在 pubspec.yaml 中为项目设置依赖项。撰写本文时,Flame 的最新版本是 1.14.0。此外,现在让我们定义所有资产路径,这样以后就无需返回此文件。并将图像放入目录 assets/images/。我们需要将其放在这里,因为 Flame 将准确扫描此路径:
environment: sdk: '>=3.2.3 <4.0.0' flutter: '>=3.13.0' dependencies: flutter: sdk: flutter flame: ^1.14.0 flutter: uses-material-design: true assets: - assets/images/ - assets/images/character/ - assets/images/background/ - assets/images/forest/ - assets/images/font/
请记住将所有图像放在 assets/images/ 下,因为 Flame 不会解析其他目录。
任何游戏都需要大量图像。但是如果你不擅长设计怎么办?幸运的是,有很多开源资产可用于你的项目。此游戏的资产取自itch.io 。我们将在我们的项目中使用这些资源:
您可以访问这些链接,或者只需下载为此项目准备好的资产(链接到资产档案)并将所有内容复制到您的项目中。
Flame 的理念与 Flutter 类似。在 Flutter 中,一切都是 Widget;在 Flame 中,一切都是 Component,甚至整个 Game 也是如此。每个 Component 都可以重写 2 个方法:onLoad() 和 update()。当 Component 被挂载到 ComponentTree 中时,onLoad() 只会被调用一次,并且 update() 会在每一帧触发。与 Flutter 中 StatefulWidget 中的 initState() 和 build() 非常相似。
现在,让我们编写一些代码。创建一个扩展 FlameGame 的类并将所有资产加载到缓存中。
class ForestRunGame extends FlameGame { @override Future<void> onLoad() async { await super.onLoad(); await images.loadAllImages(); } }
接下来,在 main.dart 中使用 ForestRunGame。此外,您还可以使用 Flame.device 中的方法来配置设备方向。还有 GameWidget,它充当小部件和组件之间的桥梁。
Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); await Flame.device.fullScreen(); await Flame.device.setLandscape(); runApp(GameWidget(game: ForestRunGame())); }
此时,我们已经可以启动游戏了,但只会出现黑屏。因此,我们需要添加我们的组件。
我们将森林分为两个部分:背景和前景。首先,我们来处理背景。你滚动页面时有没有感觉动态?好像你同时滚动了多个视图?这是一种视差效果,当页面的不同元素以不同的速度移动时,就会产生这种效果,从而产生 3D 深度效果。
正如您所想,我们将使用视差作为背景。扩展 ParallaxComponent 并使用 ParallaxImageData 设置图像堆栈。此外,还有 baseVelocity 用于初始图层的速度和 velocityMultiplierDelta,它代表图层之间的相对速度差异。最后,配置优先级字段 (z-index) 以将其移动到其他组件后面。
class ForestBackground extends ParallaxComponent<ForestRunGame> { @override Future<void> onLoad() async { priority = -10; parallax = await game.loadParallax( [ ParallaxImageData('background/plx-1.png'), ParallaxImageData('background/plx-2.png'), ParallaxImageData('background/plx-3.png'), ParallaxImageData('background/plx-4.png'), ParallaxImageData('background/plx-5.png'), ], baseVelocity: Vector2.zero(), velocityMultiplierDelta: Vector2(1.4, 1.0), ); } }
背景已经完成;现在是时候添加前景了。扩展 PositionComponent,以便我们可以将地面与屏幕底部对齐。我们还需要 HasGameReference mixin 来访问游戏缓存。
要创建地面,您只需将地面块图像多次对齐即可。在 Flame 中,图像组件称为精灵。精灵是可在画布中渲染的图像区域。它可能代表整个图像,也可能是精灵表包含的部分之一。
另外,请记住,X 轴是向右的,Y 轴是向下的。轴的中心位于屏幕的左上角。
class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> { static final blockSize = Vector2(480, 96); late final Sprite groundBlock; late final Queue<SpriteComponent> ground; @override void onLoad() { super.onLoad(); groundBlock = Sprite(game.images.fromCache('forest/ground.png')); ground = Queue(); } @override void onGameResize(Vector2 size) { super.onGameResize(size); final newBlocks = _generateBlocks(); ground.addAll(newBlocks); addAll(newBlocks); y = size.y - blockSize.y; } List<SpriteComponent> _generateBlocks() { final number = 1 + (game.size.x / blockSize.x).ceil() - ground.length; final lastBlock = ground.lastOrNull; final lastX = lastBlock == null ? 0 : lastBlock.x + lastBlock.width; return List.generate( max(number, 0), (i) => SpriteComponent( sprite: groundBlock, size: blockSize, position: Vector2(lastX + blockSize.x * i, y), priority: -5, ), growable: false, ); } }
最后,将这些组件添加到我们的 ForestRunGame 中。
class ForestRunGame extends FlameGame { late final foreground = ForestForeground(); late final background = ForestBackground(); @override Future<void> onLoad() async { await super.onLoad(); await images.loadAllImages(); add(foreground); add(background); } }
现在,尝试启动游戏。此时我们已经有了森林。
森林看起来不错,但此刻它只是一张图片。因此,我们将创建杰克,他将在玩家的指导下穿过这片森林。与树木和地面不同,玩家需要动画才能感受到活力。我们对地面方块使用了 Sprite,但我们将对杰克使用 SpriteAnimation。这是如何工作的?好吧,一切都很简单,你只需要循环播放一系列精灵。例如,我们的跑步动画有 8 个精灵,它们以很小的时间间隔相互替换。
杰克可以奔跑、跳跃和闲置。为了表示他的状态,我们可以添加一个 PlayerState 枚举。然后创建一个扩展 SpriteAnimationGroupComponent 的 Player,并将 PlayerState 作为通用参数传递。此组件有一个动画字段,其中存储了每个 PlayerState 的动画,还有一个当前字段,表示需要动画的玩家的当前状态。
enum PlayerState { jumping, running, idle } class Player extends SpriteAnimationGroupComponent<PlayerState> { @override void onLoad() { super.onLoad(); animations = { PlayerState.running: SpriteAnimation.fromFrameData( game.images.fromCache('character/run.png'), SpriteAnimationData.sequenced( amount: 8, amountPerRow: 5, stepTime: 0.1, textureSize: Vector2(23, 34), ), ), PlayerState.idle: SpriteAnimation.fromFrameData( game.images.fromCache('character/idle.png'), SpriteAnimationData.sequenced( amount: 12, amountPerRow: 5, stepTime: 0.1, textureSize: Vector2(21, 35), ), ), PlayerState.jumping: SpriteAnimation.spriteList( [ Sprite(game.images.fromCache('character/jump.png')), Sprite(game.images.fromCache('character/land.png')), ], stepTime: 0.4, loop: false, ), }; current = PlayerState.idle; } }
玩家状态已准备就绪。现在,我们需要为玩家提供屏幕上的尺寸和位置。我将把他的尺寸设置为 69x102 像素,但您可以随意更改它。对于位置,我们必须知道地面的坐标。通过添加 HasGameReference mixin,我们可以访问前景字段并获取其坐标。现在,让我们重写 onGameResize 方法,该方法在每次更改应用程序大小时都会被调用,并在那里设置 Jack 的位置。
class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame> { static const startXPosition = 80.0; Player() : super(size: Vector2(69, 102)); double get groundYPosition => game.foreground.y - height + 20; // onLoad() {...} with animation setup @override void onGameResize(Vector2 size) { super.onGameResize(size); x = startXPosition; y = groundYPosition; } }
正如之前所做的那样,将玩家添加到我们的游戏中。
class ForestRunGame extends FlameGame { // Earlier written code here... late final player = Player(); @override Future<void> onLoad() async { // Earlier written code here... add(player); } }
如果你开始游戏,你会看到杰克已经在森林里了!
我们的游戏有三个状态:介绍、游戏和游戏结束。因此,我们将添加代表这些状态的枚举 GameState。要让杰克奔跑,我们需要速度和加速度变量。此外,我们还需要计算行进的距离(稍后会用到)。
如前所述,组件有两个主要方法:onLoad() 和 update()。我们已经多次使用了 onLoad 方法。现在,让我们讨论一下 update()。此方法有一个名为 dt 的参数。它表示自上次调用 update() 以来经过的时间。
为了计算当前速度和行进距离,我们将使用 update() 方法和一些基本的运动学公式:
enum GameState { intro, playing, gameOver } class ForestRunGame extends FlameGame { static const acceleration = 10.0; static const maxSpeed = 2000.0; static const startSpeed = 400.0; GameState state = GameState.intro; double currentSpeed = 0; double traveledDistance = 0; // Earlier written code here... @override void update(double dt) { super.update(dt); if (state == GameState.playing) { traveledDistance += currentSpeed * dt; if (currentSpeed < maxSpeed) { currentSpeed += acceleration * dt; } } } }
实际上,我们将使用一个技巧来简化开发:杰克将保持稳定,但森林将向杰克移动。因此,我们需要我们的森林应用游戏速度。
对于视差背景,我们只需要传递游戏速度。它会自动处理其余部分。
class ForestBackground extends ParallaxComponent<ForestRunGame> { // Earlier written code here... @override void update(double dt) { super.update(dt); parallax?.baseVelocity = Vector2(game.currentSpeed / 10, 0); } }
对于前景,我们需要移动每个地面块。另外,我们需要检查队列中的第一个块是否已经离开屏幕。如果是,则将其移除并放在队列末尾;
class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> { // Earlier written code here... @override void update(double dt) { super.update(dt); final shift = game.currentSpeed * dt; for (final block in ground) { block.x -= shift; } final firstBlock = ground.first; if (firstBlock.x <= -firstBlock.width) { firstBlock.x = ground.last.x + ground.last.width; ground.remove(firstBlock); ground.add(firstBlock); } } }
一切准备就绪,只差一个触发器。我们希望在点击时开始运行。我们的目标既是移动设备,也是桌面设备,因此我们希望处理屏幕点击和键盘事件。
幸运的是,Flame 有办法做到这一点。只需为您的输入类型添加一个 mixin。对于键盘,它是 KeyboardEvents,而对于屏幕点击,它是 TapCallbacks。这些 mixin 让您可以覆盖相关方法并提供您的逻辑。
如果用户按下空格键或点击屏幕,游戏必须开始。
class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks { // Earlier written code here... @override KeyEventResult onKeyEvent( RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed, ) { if (keysPressed.contains(LogicalKeyboardKey.space)) { start(); } return KeyEventResult.handled; } @override void onTapDown(TapDownEvent event) { start(); } void start() { state = GameState.playing; player.current = PlayerState.running; currentSpeed = startSpeed; traveledDistance = 0; } }
结果,Jack 点击后就可以跑了。
现在,我们想在路上设置障碍物。在我们的例子中,它们将被表示为有毒灌木丛。灌木丛没有动画,所以我们可以使用 SpriteComponent。此外,我们需要一个游戏引用来访问它的速度。还有一件事;我们不想一个接一个地生成灌木丛,因为这种方法可能会导致杰克根本无法通过跳跃越过一排灌木丛的情况。它是一个范围内的随机数,取决于当前的游戏速度。
class Bush extends SpriteComponent with HasGameReference<ForestRunGame> { late double gap; Bush() : super(size: Vector2(200, 84)); bool get isVisible => x + width > 0; @override Future<void> onLoad() async { x = game.size.x + width; y = -height + 20; gap = _computeRandomGap(); sprite = Sprite(game.images.fromCache('forest/bush.png')); } double _computeRandomGap() { final minGap = width * game.currentSpeed * 100; final maxGap = minGap * 5; return (Random().nextDouble() * (maxGap - minGap + 1)).floor() + minGap; } @override void update(double dt) { super.update(dt); x -= game.currentSpeed * dt; if (!isVisible) { removeFromParent(); } } }
谁在种植灌木?当然是大自然。让我们创造大自然来管理我们的灌木的产生。
class Nature extends Component with HasGameReference<ForestRunGame> { @override void update(double dt) { super.update(dt); if (game.currentSpeed > 0) { final plant = children.query<Bush>().lastOrNull; if (plant == null || (plant.x + plant.width + plant.gap) < game.size.x) { add(Bush()); } } } }
现在,让我们将自然添加到我们的森林前景中。
class ForestForeground extends PositionComponent with HasGameReference<ForestRunGame> { // Earlier written code here... late final Nature nature; @override void onLoad() { // Earlier written code here... nature = Nature(); add(nature); }
现在,我们的森林里有灌木丛。但是等一下,杰克正在穿过灌木丛。为什么会发生这种情况?这是因为我们还没有实现击打。
在这里,Hitbox 会帮助我们。Hitbox 是 Flame 组件库中的另一个组件。它封装了碰撞检测,并让您能够使用自定义逻辑来处理它。
为 Jack 添加一个。请记住,组件的位置将位于其左上角,而不是中心。使用尺寸,您可以处理其余的事情。
class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame> { // Earlier written code here... @override void onLoad() { // Earlier written code here... add( RectangleHitbox( position: Vector2(2, 2), size: Vector2(60, 100), ), ); } }
还有一个用于灌木丛。在这里,我们将碰撞类型设置为被动,以进行一些优化。默认情况下,类型是主动的,这意味着 Flame 将检查此碰撞框是否与其他所有碰撞框发生碰撞。我们只有一个玩家和灌木丛。由于玩家已经具有主动碰撞类型,并且灌木丛不能相互碰撞,我们可以将类型设置为被动。
class Bush extends SpriteComponent with HasGameReference<ForestRunGame> { // Earlier written code here... @override void onLoad() { // Earlier written code here... add( RectangleHitbox( position: Vector2(30, 30), size: Vector2(150, 54), collisionType: CollisionType.passive, ), ); } }
很酷,但我看不出命中框的位置是否调整正确。我该如何测试?
好吧,您可以将 Player 和 Bush 的 debugMode 字段设置为 true。这样您就可以看到命中框的位置。紫色定义组件的大小,黄色表示命中框。
现在,我们想要检测玩家和灌木丛之间何时发生碰撞。为此,您需要将 HasCollisionDetection mixin 添加到 Game,然后为需要处理碰撞的组件添加 CollisionCallbacks。
class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { // Earlier written code here... }
现在,只需在检测到碰撞时暂停游戏。
class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame>, CollisionCallbacks { // Earlier written code here... @override void onCollisionStart( Set<Vector2> intersectionPoints, PositionComponent other, ) { super.onCollisionStart(intersectionPoints, other); game.paused = true; } }
如果杰克想避开那些灌木丛,他就需要跳跃。让我们教他。对于此功能,我们需要重力常数和杰克跳跃的初始垂直速度。这些值是通过眼睛选择的,因此请随意调整它们。
那么,重力是如何起作用的?基本上,它是相同的加速度,但朝向地面。因此,我们可以对垂直位置和速度使用相同的公式。因此,我们的跳跃将有 3 个步骤:
class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame>, CollisionCallbacks { static const gravity = 1400.0; static const initialJumpVelocity = -700.0; double jumpSpeed = 0; // Earlier written code here... void jump() { if (current != PlayerState.jumping) { current = PlayerState.jumping; jumpSpeed = initialJumpVelocity - (game.currentSpeed / 500); } } void reset() { y = groundYPos; jumpSpeed = 0; current = PlayerState.running; } @override void update(double dt) { super.update(dt); if (current == PlayerState.jumping) { y += jumpSpeed * dt; jumpSpeed += gravity * dt; if (y > groundYPos) { reset(); } } else { y = groundYPos; } } }
现在让我们通过点击 ForestRunGame 来触发跳跃
class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { // Earlier written code here... @override KeyEventResult onKeyEvent( RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed, ) { if (keysPressed.contains(LogicalKeyboardKey.space)) { onAction(); } return KeyEventResult.handled; } @override void onTapDown(TapDownEvent event) { onAction(); } void onAction() { switch (state) { case GameState.intro: case GameState.gameOver: start(); break; case GameState.playing: player.jump(); break; } } }
现在,杰克可以处理灌木丛了。
游戏结束后,我们想在屏幕上显示文字。Flame 中的文字与 Flutter 不同。您必须先创建字体。在底层,它只是一个映射,其中 char 是键,而 sprite 是值。几乎总是,游戏的字体是一张图片,其中收集了所有需要的符号。
对于这个游戏,我们只需要数字和大写字母。所以,让我们创建我们的字体。为此,您必须传递源图像和字形。什么是字形?字形是有关字符、其大小和源图像中的位置的信息的联合。
class StoneText extends TextBoxComponent { static const digits = '123456789'; static const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; StoneText({ required Image source, required super.position, super.text = '', }) : super( textRenderer: SpriteFontRenderer.fromFont( SpriteFont( source: source, size: 32, ascent: 32, glyphs: [ _buildGlyph(char: '0', left: 480, top: 0), for (var i = 0; i < digits.length; i++) _buildGlyph(char: digits[i], left: 32.0 * i, top: 32), for (var i = 0; i < letters.length; i++) _buildGlyph( char: letters[i], left: 32.0 * (i % 16), top: 64.0 + 32 * (i ~/ 16), ), ], ), letterSpacing: 2, ), ); static Glyph _buildGlyph({ required String char, required double left, required double top, }) => Glyph(char, left: left, top: top, height: 32, width: 32); }
现在,我们可以创建游戏结束面板并在游戏中使用它。
class GameOverPanel extends PositionComponent with HasGameReference<ForestRunGame> { bool visible = false; @override Future<void> onLoad() async { final source = game.images.fromCache('font/keypound.png'); add(StoneText(text: 'GAME', source: source, position: Vector2(-144, -16))); add(StoneText(text: 'OVER', source: source, position: Vector2(16, -16))); } @override void renderTree(Canvas canvas) { if (visible) { super.renderTree(canvas); } } @override void onGameResize(Vector2 size) { super.onGameResize(size); x = size.x / 2; y = size.y / 2; } }
现在,当杰克撞到灌木丛时,我们可以显示面板。另外,让我们修改 start() 方法,这样我们就可以在单击时重新启动游戏。此外,我们需要清除森林中的所有灌木丛。
class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { // Earlier written code here... late final gameOverPanel = GameOverPanel(); @override Future<void> onLoad() async { // Earlier written code here... add(gameOverPanel); } void gameOver() { paused = true; gameOverPanel.visible = true; state = GameState.gameOver; currentSpeed = 0; } void start() { paused = false; state = GameState.playing; currentSpeed = startSpeed; traveledDistance = 0; player.reset(); foreground.nature.removeAll(foreground.nature.children); gameOverPanel.visible = false; } }
现在,我们需要更新播放器中的碰撞回调。
class Player extends SpriteAnimationGroupComponent<PlayerState> with HasGameReference<ForestRunGame>, CollisionCallbacks { // Earlier written code here... @override void onCollisionStart( Set<Vector2> intersectionPoints, PositionComponent other, ) { super.onCollisionStart(intersectionPoints, other); game.gameOver(); } }
现在,当杰克撞到灌木丛时,你会看到游戏结束。只需再次点击即可重新开始游戏。
以及最终的触摸分数计算。
class ForestRunGame extends FlameGame with KeyboardEvents, TapCallbacks, HasCollisionDetection { late final StoneText scoreText; late final StoneText highText; late final StoneText highScoreText; int _score = 0; int _highScore = 0; // Earlier written code here... @override Future<void> onLoad() async { // Earlier written code here... final font = images.fromCache('font/keypound.png'); scoreText = StoneText(source: font, position: Vector2(20, 20)); highText = StoneText(text: 'HI', source: font, position: Vector2(256, 20)); highScoreText = StoneText( text: '00000', source: font, position: Vector2(332, 20), ); add(scoreText); add(highScoreText); add(highText); setScore(0); } void start() { // Earlier written code here... if (_score > _highScore) { _highScore = _score; highScoreText.text = _highScore.toString().padLeft(5, '0'); } _score = 0; } @override void update(double dt) { super.update(dt); if (state == GameState.playing) { traveledDistance += currentSpeed * dt; setScore(traveledDistance ~/ 50); if (currentSpeed < maxSpeed) { currentSpeed += acceleration * dt; } } } void setScore(int score) { _score = score; scoreText.text = _score.toString().padLeft(5, '0'); } }
就这些了,朋友们!
现在,尝试一下,并尝试打破我的最高分。我的最高分是 2537 分!
虽然内容很多,但我们做到了。我们为一款移动游戏创建了一个最小可行产品,其中包含物理、动画、得分计算等功能。总有改进的空间,而且,就像任何其他 MVP 一样,我们的产品未来有望推出新功能、新机制和新游戏模式。
另外,还有一个 flame_audio 包,您可以使用它来添加一些背景音乐、跳跃或击打的声音等。
目前,我们的主要目标是在短时间内以有限的资源配置创建基本的产品功能。事实证明,Flutter 和 Flame 的组合非常适合构建游戏 MVP,可用于收集用户反馈并在未来不断升级应用程序。
您可以在此处查看我们努力的成果。
Flutter 和 Flame 功能强大、使用方便,而且社区蓬勃发展,是游戏开发者的理想选择。无论您是经验丰富的专业人士还是刚刚起步,这一组合都能提供工具和潜力,让您的游戏创意成为现实。因此,发挥您的创造力,深入 Flutter 和 Flame 的世界,开始打造下一个移动游戏热潮吧!
我们希望您觉得这篇文章有趣且信息丰富。如果您想深入了解软件开发或希望讨论您自己的 MVP 项目,请随时探索Leobit或联系我们的技术团队!