최근 조사 에 따르면 스타트업 5곳 중 2곳만이 수익을 내고 있는 것으로 나타났습니다. MVP(최소 실행 가능 제품)는 스타트업이 완전한 기능을 갖춘 앱에 전체 예산을 지출하지 않고도 초기 사용자 피드백을 수집할 수 있도록 해주기 때문에 스타트업의 수익성 가능성을 크게 높입니다.
MVP를 사용하면 제한된 예산으로 단기간에 기본 기능을 갖춘 앱을 구축하고, 사용자 피드백을 수집하고, 이 피드백에 따라 개발팀과 함께 솔루션을 지속적으로 확장할 수 있습니다.
MVP는 게임 산업에서 점점 인기를 얻고 있습니다. 오늘 우리는 크로스 플랫폼 최소 실행 가능 제품을 구축하기 위한 뛰어난 조합 인 Flutter와 Flame을 사용하여 신속한 게임 MVP 개발 에 대해 자세히 살펴보겠습니다.
크로스 플랫폼 개발을 위한 기능이 풍부하고 안전한 플랫폼 인 Flutter는 모바일 앱 세계를 휩쓸었으며 그 범위는 UI를 훨씬 뛰어넘어 확장되었습니다. Flutter를 기반으로 구축된 강력한 오픈 소스 게임 엔진인 Flame의 도움으로 Android, iOS, 웹 및 데스크톱 장치에서 원활하게 실행되는 놀라운 2D 게임을 제작할 수 있습니다.
Flutter는 또한 다양한 장치에 걸쳐 기본 기능을 제공하는 솔루션의 빠른 개발을 촉진하는 통합 기능으로 인해 게임 MVP 구축을 위한 인기 있는 솔루션이 되었습니다. 특히 다양한 Flutter 이점과 통합 기능을 통해 다음이 가능합니다.
Flutter는 많은 컴퓨팅 리소스를 소비하지 않으며 크로스 플랫폼 애플리케이션의 간단한 설정을 용이하게 합니다.
Flutter와 Flame 조합을 기반으로 하는 앱 MVP는 안정적이면서도 비교적 개발하기 쉬운 솔루션입니다. 네이티브 코드로 직접 컴파일되어 원활한 게임플레이와 반응성을 보장합니다. 게임 MVP를 한 번 개발한 후 다양한 플랫폼에 배포하여 시간과 리소스를 절약할 수 있습니다. Flutter와 Flame은 내부적으로 플랫폼 차이를 처리합니다.
또한 두 기술 모두 광범위한 문서, 튜토리얼 및 코드 예제를 갖춘 활발한 커뮤니티를 자랑합니다. 이는 당신이 답이나 영감을 얻기 위해 꼼짝 못하게 되는 일이 결코 없다는 것을 의미합니다.
Flame은 리소스를 과도하게 사용하지 않고 단기간에 MVP 게임 기능을 생성하기 위한 전체 도구 세트를 제공합니다. 이 크로스 플랫폼 모델링 프레임워크는 다양한 사용 사례를 위한 도구를 제공합니다.
위에서 언급한 기능 중 대부분은 많은 게임에 필수적이며 MVP 개발 단계에서도 간과되어서는 안 됩니다. 정말 중요한 점은 Flame이 위에서 언급한 기능 개발 속도를 크게 향상시켜 초기 제품 버전에서도 이러한 기능을 출시할 수 있다는 것입니다.
이제 Flame에 대해 이야기하는 대신 이 프레임워크를 사용하여 우리 게임의 기본 기능을 포함하는 MVP를 만들어 보겠습니다. 시작하기 전에 선호하는 IDE 및 테스트용 장치인 Flutter 3.13 이상을 설치해야 합니다.
이 게임은 Chrome Dino에서 영감을 받았습니다. 아, 그 유명한 디노 런! Chrome의 단순한 게임 그 이상입니다. 브라우저의 오프라인 모드에 숨겨져 있는 사랑받는 이스터 에그입니다.
우리 프로젝트에는 다음과 같은 게임플레이가 있습니다:
그리고 그 이름은 "Forest Run!"입니다.
새 앱을 시작할 때마다 하는 것처럼 빈 Flutter 프로젝트를 만듭니다. 시작하려면 프로젝트의 pubspec.yaml에 종속성을 설정해야 합니다. 이 글을 작성하는 시점의 Flame 최신 버전은 1.14.0입니다. 또한 이제 모든 자산 경로를 정의하므로 나중에 이 파일로 돌아갈 필요가 없습니다. 그리고 이미지를 자산/이미지/ 디렉토리에 넣으세요. 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/
Flame은 다른 디렉토리를 구문 분석하지 않으므로 모든 이미지를 자산/이미지/ 아래에 배치해야 합니다.
모든 게임에는 많은 이미지가 필요합니다. 하지만 디자인에 능숙하지 않다면 어떨까요? 다행히도 프로젝트에 사용할 수 있는 오픈 소스 자산이 많이 있습니다. 이 게임의 자산은 itch.io 에서 가져왔습니다. 우리는 프로젝트에 다음 리소스를 사용할 것입니다.
해당 링크를 방문하거나 이 프로젝트에 대해 준비된 자산(자산 아카이브 링크)을 다운로드하고 모든 콘텐츠를 프로젝트에 복사할 수 있습니다.
Flame은 Flutter와 비슷한 철학을 가지고 있습니다. Flutter에서는 모든 것이 위젯입니다. Flame에서는 모든 것이 하나의 구성요소입니다. 심지어 전체 게임도 마찬가지입니다. 모든 구성 요소는 onLoad() 및 update()라는 두 가지 메서드를 재정의할 수 있습니다. onLoad()는 Component가 ComponentTree에 마운트되고 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 믹스인도 필요합니다.
지면을 만들려면 지면 블록 이미지를 여러 번 나열하면 됩니다. 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를 사용했지만 Jack에는 SpriteAnimation을 사용할 것입니다. 어떻게 작동하나요? 음, 모든 것이 쉽습니다. 스프라이트 시퀀스를 반복하기만 하면 됩니다. 예를 들어, 우리의 달리기 애니메이션에는 8개의 스프라이트가 있으며, 이는 작은 시간 간격으로 서로 교체됩니다.
잭은 달리고, 점프하고, 가만히 있을 수 있습니다. 그의 상태를 나타내기 위해 PlayerState 열거형을 추가할 수 있습니다. 그런 다음 SpriteAnimationGroupComponent를 확장하고 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 믹스인을 추가하면 전경 필드에 액세스하여 좌표를 얻을 수 있습니다. 이제 애플리케이션의 크기가 변경될 때마다 호출되는 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를 추가하겠습니다. Jack을 실행시키려면 속도와 가속도 변수가 필요합니다. 또한 이동 거리를 계산해야 합니다(나중에 사용됨).
앞서 언급했듯이 Component에는 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; } } } }
실제로 우리는 개발을 더 쉽게 만들기 위해 트릭을 사용할 것입니다. Jack은 안정적이지만 숲은 Jack을 향해 움직일 것입니다. 따라서 게임 속도를 적용하려면 숲이 필요합니다.
시차 배경의 경우 게임 속도만 전달하면 됩니다. 그리고 나머지는 자동으로 처리됩니다.
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에는 이를 수행할 수 있는 방법이 있습니다. 입력 유형에 맞는 믹스인을 추가하기만 하면 됩니다. 키보드의 경우 화면 탭을 위한 KeyboardEvents 및 TapCallbacks입니다. 이러한 믹스인은 관련 메서드를 재정의하고 논리를 제공할 수 있는 가능성을 제공합니다.
사용자가 스페이스바를 누르거나 화면을 탭하면 게임이 시작되어야 합니다.
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은 클릭한 후 실행할 수 있습니다.
이제 우리는 도로에 장애물을 두고 싶습니다. 우리의 경우에는 유독한 덤불로 표현됩니다. Bush는 애니메이션이 적용되지 않으므로 SpriteComponent를 사용할 수 있습니다. 또한 속도에 액세스하려면 게임 참조가 필요합니다. 그리고 하나 더; 우리는 덤불을 하나씩 생성하고 싶지 않습니다. 왜냐하면 이 접근 방식은 Jack이 점프로 덤불 줄을 통과할 수 없는 상황을 초래할 수 있기 때문입니다. 현재 게임 속도에 따라 달라지는 범위의 임의의 숫자입니다.
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()); } } } }
이제 ForestForeground에 Nature를 추가해 보겠습니다.
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의 다양한 구성 요소 중 또 다른 구성 요소입니다. 이는 충돌 감지를 캡슐화하고 사용자 정의 논리로 이를 처리할 수 있는 가능성을 제공합니다.
잭을 위해 하나를 추가하세요. 구성 요소의 위치는 중앙이 아닌 왼쪽-오른쪽 모서리에 위치한다는 점을 기억하세요. 크기에 따라 나머지는 알아서 처리됩니다.
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 믹스인을 추가한 다음 충돌을 처리해야 하는 구성 요소에 대한 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; } } }
이제 Jack은 덤불을 다룰 수 있습니다.
게임이 끝나면 화면에 텍스트를 표시하고 싶습니다. Flame의 텍스트는 Flutter와 다르게 작동합니다. 먼저 글꼴을 만들어야 합니다. 내부적으로는 char가 키이고 sprite가 값인 맵일 뿐입니다. 거의 항상 게임의 글꼴은 필요한 모든 기호가 모인 하나의 이미지입니다.
이 게임에는 숫자와 대문자만 필요합니다. 이제 글꼴을 만들어 보겠습니다. 그렇게 하려면 소스 이미지와 문자 모양을 전달해야 합니다. 글리프란 무엇입니까? Glyph는 소스 이미지의 문자, 크기 및 위치에 대한 정보의 조합입니다.
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; } }
이제 Jack이 덤불에 부딪힐 때 패널을 보여줄 수 있습니다. 또한 클릭 시 게임을 다시 시작할 수 있도록 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(); } }
이제 Jack이 덤불에 부딪히면 Game Over를 볼 수 있습니다. 그리고 다시 클릭하면 게임이 다시 시작됩니다.
그리고 최종 터치 점수 계산입니다.
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을 탐색하거나 기술 팀에 문의하세요!