【Flutter】ドラクエ風RPG開発記:第1回 地図の作成(1)

対象者

  • FlutterでRPG風のゲームをゼロから作りたい方
  • 単に画像全体や一部をCanvas上に描画したい人

はじめに

小学5年の頃、作成しようとしたプログラムがありました。ドラクエ風のRPGゲームです。当時MSXという古いPCで、それっぽいものを作っている同級生がいました(勇者が星マークとかのレベルですけど)。また中学生でPC98を使って、ファミコンに近い出力をしている人もいました(Line文を駆使して、画像を書いてた)
プログラミング歴20年以上になりますので、さすがに作れるだろうな、と思いつつも挑戦してこなかったです。Flutterの勉強がてら、あの懐かしい冒険の世界を再現しようとおもいます。

余談

RPGを作りたい人はRPGツクール系、Flutterでゲームを作りたい方はbonfire の使用をお勧めします。完全に車輪の再開発になってます、劣化版だし。

今回の制作物

上に画像全体、下に画像の一部を切り出した勇者様を表示する

地図生成の方法

今回は、ゲーム開発の第一歩として、地図の生成に挑戦します。

ゲームの世界を形作る地図は、プレイヤーが冒険を進める上で欠かせない要素です。今回は、Flutterを使って、地図の画像チップから一部を抜き取り、特定の位置に表示する方法を記載します。
具体的には以下の手順で進めます。

  1. 画像の準備:まず、地図の元となる画像を用意します。この画像に、人物や草原など、ゲームに必要な要素が含まれています。

  2. 要素の抽出:画像からゲームの地図に使いたい部分を切り取ります。たとえば、勇者やが立つ草原や、村の風景などです。今回は勇者を抽出して、表示します。

実装

以下は、Flutterを使用して画像の一部を抜き出し、地図を生成するためのブログ記事の例です。コードの説明とともに、必要なコードを転記しています。

画像のロード

まず、地図の元となる画像をロードする必要があります。この例では、assets フォルダにある char_p_hero01.png という画像を使用しています。loadImage 関数を使って、画像を非同期にロードします。

Future<ui.Image> loadImage() async {
  final mapAsBytes = await rootBundle.load('assets/char_p_hero01.png');
  final pixelData = mapAsBytes.buffer.asUint8List();

  return decodeImageFromList(Uint8List.fromList(pixelData));
}

画像の全体をCanvasに表示する

特定のキャラクターを画面に表示する前段階として、画像全体を表示する方法をテストします。 Imageが通常のFlutter開発で実施しているImageウィジェットでないので注意が必要でした。
画像をロードしたら、CustomPaint ウィジェットを使って、画像を Canvas に描画します。ImagePainter クラスは CustomPainter を継承しており、paint メソッドで画像を描画しています。

import 'dart:ui' as ui;

class ImagePainter extends CustomPainter {
  final ui.Image image;

  ImagePainter(this.image);

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawImage(image, const ui.Offset(0, 0), Paint());
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return false;
  }
}

画像の一部をCanvasに表示する

地図の一部を抜き出して表示するには、MapTilePainter クラスを使用します。このクラスでは、画像の特定の領域を指定して、その部分を Canvas に描画しています。

class MapTilePainter extends CustomPainter {
  static const kWidth = 24.0;
  static const kHeight = 33.0;

  final ui.Image image;
  final double srcX, srcY;
  final double destX, destY;

  MapTilePainter(this.image, this.srcX, this.srcY, this.destX, this.destY);

  @override
  void paint(Canvas canvas, Size size) {
    final src = Rect.fromLTWH(srcX * kWidth, srcY * kHeight, kWidth, kHeight);
    final dst = Rect.fromLTWH(destX * kWidth, destY * kHeight, kWidth, kHeight);
    canvas.drawImageRect(image, src, dst, Paint());
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return false;
  }
}

MapTilePainter クラスは、画像の特定の部分をキャンバスに描画するために使用されます。このクラスでは、以下のパラメータを使用しています。

kWidth と kHeight

これらは定数で、画像の中の一つのキャラクターやタイルのサイズを表します。この例では、kWidth が 24.0、kHeight が 33.0 と設定されており、これは画像の中の一つのキャラクターが24ピクセルの幅と33ピクセルの高さであることを意味します。

srcX と srcY

これらの変数は、画像の中で抜き出したい部分の左上の角の位置を表します。たとえば、srcX = 1srcY = 2 であれば、画像の中で2番目のキャラクターの幅の位置と3番目のキャラクターの高さの位置からキャラクターを抜き出すことを意味します。

destX と destY

これらの変数は、キャンバス上で描画したい位置を表します。たとえば、destX = 3destY = 4 であれば、キャンバス上で4番目のキャラクターの幅の位置と5番目のキャラクターの高さの位置にキャラクターが描画されます。

drawImageRect メソッド

drawImageRect メソッドは、画像の特定の部分を指定された矩形に描画します。このメソッドには4つの引数があります。

  1. image: 描画する画像です。
  2. src: 抜き出す画像の部分を表す矩形です。この矩形は、srcXsrcYkWidthkHeight を使って計算されます。
  3. dst: キャンバス上で描画する位置を表す矩形です。この矩形は、destXdestYkWidthkHeight を使って計算されます。
  4. Paint(): 描画のためのスタイルや色を指定するためのオブジェクトです。この例では、デフォルトの設定を使用しています。

このようにして、MapTilePainter クラスを使用することで、画像の一部をキャンバス上の特定の位置に描画することができます。これにより、地図の一部を生成するための基礎が築かれます。

画面に画像全体と一部の両方を表示する

最終的に、FutureBuilder を使用して画像がロードされたら、画像の全体と一部を画面に表示します。

body: FutureBuilder<ui.Image>(
  future: loadImage(),
  builder: (context, snapshot) {
    if (snapshot.hasError) {
      return Text(snapshot.error!.toString());
    }
    if (snapshot.connectionState == ConnectionState.waiting) {
      return const CircularProgressIndicator();
    }
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        CustomPaint(
          size: const Size(24 * 8, 33 * 8),
          painter: ImagePainter(snapshot.data!),
        ),
        CustomPaint(
          painter: MapTilePainter(snapshot.data!, 0, 0, 0, 0),
        ),
      ],
    );
  }),

まとめ

この記事では、Flutterを使って画像のロード、画像の全体および一部を Canvas に表示する方法を紹介しました。これにより、地図の生成の基礎を築くことができます。次回は、さらに地図の詳細を作り込んでいきたいと思います。
地図の生成は、ドラクエ風RPGを作る上での第一歩です。Flutterを使えば、比較的簡単に実装できると期待できます。途中で力尽きるかもしれませんが、この冒険の旅を一緒に楽しんでいただけると嬉しいです。

参考

ソース(main.dartにコピペして動作確認用)

import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('第1回 地図の生成(1)'),
        ),
        body: FutureBuilder<ui.Image>(
            future: loadImage(),
            builder: (context, snapshot) {
              if (snapshot.hasError) {
                return Text(snapshot.error!.toString());
              }
              if (snapshot.connectionState == ConnectionState.waiting) {
                return const CircularProgressIndicator();
              }
              return Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  CustomPaint(
                    size: const Size(24 * 8, 33 * 8),
                    painter: ImagePainter(snapshot.data!),
                  ),
                  CustomPaint(
                    painter: MapTilePainter(snapshot.data!, 0, 0, 0, 0),
                  ),
                ],
              );
            }),
      ),
    );
  }

  Future<ui.Image> loadImage() async {
    final mapAsBytes = await rootBundle.load('assets/char_p_hero01.png');
    final pixelData = mapAsBytes.buffer.asUint8List();

    return decodeImageFromList(Uint8List.fromList(pixelData));
  }
}

class ImagePainter extends CustomPainter {
  final ui.Image image;

  ImagePainter(this.image);

  @override
  void paint(Canvas canvas, Size size) {
    // Paintオブジェクトを作成
    canvas.drawImage(image, const ui.Offset(0, 0), Paint());
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return false;
  }
}

class MapTilePainter extends CustomPainter {
  static const kWidth = 24.0;
  static const kHeight = 33.0;

  final ui.Image image;
  final double srcX, srcY;
  final double destX, destY;

  MapTilePainter(this.image, this.srcX, this.srcY, this.destX, this.destY);

  @override
  void paint(Canvas canvas, Size size) {
    // Paintオブジェクトを作成
    final src = Rect.fromLTWH(srcX * kWidth, srcY * kHeight, kWidth, kHeight);
    final dst = Rect.fromLTWH(destX * kWidth, destY * kHeight, kWidth, kHeight);
    canvas.drawImageRect(image, src, dst, Paint());
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return false;
  }
}