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

対象者

  • Flutterで2次元RPGのゲームを作ってみたい人
  • 地図やマップを使ったアプリ開発に興味がある人
  • Dart言語とFlutterフレームワークを学習中の人

はじめに

前回の記事で、画像の一部を切り出して、表示する方法を学習しました。
本記事では、地図の元データを作成して、地図の画像を生成する方法を検討しようと思っています。

今回の作成物

地図データの作成

リソース

地図の元ネタはこちらです。「知ってた」なら、とても嬉しいです(笑)。
地図のデータとして漢字を使用していますが、実際の開発では地図作成ツールを使用してデータを作成して、バイナリ情報として保存することが一般的、と思われます。今回は動作の確認を目的としているため、手抜きシンプルな方法を採用しています。

地図のリソースは、前回同様「白螺子屋」様からお借りしてます。「マップチップ>他」の一つ目の「■フィールドマップ」を使用してます。

地図のデータを作成する

地図のデータは、以下のようにリストで表現されます。各要素は、地形を表す漢字一文字で構成されています。例えば、「林」は森林、「平」は平野、「山」は山、「岩」は岩山を表しています。

static const _mapData = <String>[
  '林林林林林山山山山山山山山山岩',
  '林林林林林林林林山山山山山山山',
  // 以下略
];

地図のチップデータを作成する

地図のチップデータは、各地形に対応する画像がどこに配置されているかを示すデータです。例えば、MapChip('林', 0, 15)は、「林」に対応する画像が、画像ファイルの左上から横に0個目、縦に15個目の位置にあることを表しています。
「マップチップの画像サイズx何個目か」がピクセル上の画像の場所になります。

static const _chipData = <MapChip>{
  MapChip('林', 0, 15),
  MapChip('平', 0, 10),
  // 以下略
};

描画部分の作成

地図の描画において、CustomPainterクラスを継承したMapPainterクラスを用います。このクラスでは、paintメソッドが重要で、ここで地図の描画処理が行われます。

class MapPainter extends CustomPainter {
  // 他の部分は省略

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint();
    for (int mapY = 0; mapY < mapHeight; mapY++) {
      for (int mapX = 0; mapX < mapWidth; mapX++) {
        final symbol = mapData[mapY].substring(mapX, mapX + 1);
        final chip = mapChips.firstWhere((e) => e.symbol == symbol);
        final src = Rect.fromLTWH(
            chip.xInChips * kWidth, chip.yInChips * kHeight, kWidth, kHeight);
        final dst =
            Rect.fromLTWH(mapX * kWidth, mapY * kHeight, kWidth, kHeight);
        canvas.drawImageRect(image, src, dst, paint);
      }
    }
  }

  // 他の部分は省略
}

描画の説明

画像の一部から切り取る方法については前回説明したので、そちらを参照ください

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

変数の説明

  • paint: Paintオブジェクトは、キャンバスに描画する際のスタイルや色などを指定するために使用します。この例では、特にスタイルを指定していないので、デフォルトの設定で描画されます。
  • mapYmapX: これらの変数は、地図データの二次元配列を走査するためのインデックスとして使用されます。mapYは縦方向(上から下)、mapXは横方向(左から右)に対応しています。
  • symbol: 地図データの各要素は、地形を表す漢字一文字で構成されています。symbol変数は、現在のmapYmapXの位置にある漢字を取得しています。
  • chip: mapChipsセットから、symbolに対応するMapChipオブジェクトを検索しています。MapChipオブジェクトには、漢字に対応する画像の位置情報(xInChipsyInChips)が含まれています。
  • src: Rect.fromLTWHを使用して、ソース画像(chip12e_map.png)の中から、対応する地形の画像を切り出す矩形領域を指定しています。chip.xInChips * kWidthchip.yInChips * kHeightで、切り出す画像の左上の座標を計算し、kWidthkHeight(画像の幅と高さ)で矩形のサイズを指定しています。
  • dst: キャンバス上に画像を描画する位置を指定するための矩形領域です。mapX * kWidthmapY * kHeightで描画する位置の左上の座標を計算し、画像のサイズはsrcと同じになります。

描画処理の流れ

  1. 外側のループ(mapY)が縦方向(上から下)に走査します。
  2. 内側のループ(mapX)が横方向(左から右)に走査します。
  3. 現在の座標に対応する地形の漢字(symbol)を取得します。
  4. symbolに対応する画像の位置情報を持つMapChipオブジェクト(chip)を検索します。
  5. ソース画像から対応する地形の画像を切り出す領域(src)を指定します。
  6. キャンバス上に画像を描画する位置(dst)を指定します。
  7. canvas.drawImageRectメソッドを使用して、ソース画像のsrc領域をキャンバスのdst領域に描画します。

この処理を全ての座標に対して繰り返すことで、地図が完成します。

Widget上での描写

ここからは通常のFlutterになります。
FutureBuilderで非同期処理の結果を待ち、loadImage()関数によって非同期的に読み込まれた画像データを表示します。

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: [
        const Text('元画像'),
        Image.asset('assets/chip12e_map.png'),
        const Text('加工した地図'),
        Transform.scale(
          scale: 1.5,
          alignment: Alignment.topLeft,
          child: CustomPaint(
            painter: MapPainter(snapshot.data!, _mapData, _chipData),
            size: Size(
              MapPainter.kWidth * _mapData.length,
              MapPainter.kHeight * _mapData[0].length,
            ),
          ),
        ),
      ],
    );
  },
)

各部分の説明

  • 上の画像は、Image.asset('assets/chip12e_map.png'): 読み込んだ画像データをそのまま表示します。

  • Transform.scale(...): CustomPaintウィジェットを使用して、MapPainterクラスによって生成された地図のキャンバスを表示します。scale: 1.5により、キャンバスを1.5倍に拡大しています。

  • alignment: Alignment.topLeft: 拡大したキャンバスがウィジェットの左上に配置されるように設定しています。
    デフォルトでは左中央に配置されるため、上の画像にかぶります。それを避けるため、この設定が必要です。

  • CustomPaint(painter: MapPainter(snapshot.data!, _mapData, _chipData), ...): MapPainterクラスに画像データ、地図データ、チップデータを渡しています。これにより、MapPainterはこれらのデータを使用して地図を描画できます。

このコードは、画像データの読み込み、元の画像の表示、および加工した地図の表示を行っています。地図はデータに基づいて描画され、1.5倍に拡大されて左上を起点にして配置されます。

まとめ

本記事では、Flutterを用いて地図データから画像を生成する方法を解説しました。地図データとチップデータを作成し、CustomPainterを使用して地図を描画しました。

昔は全く作成できる気がしなかったRPGの地図画面ですが、技術と自分の進歩のおかげで、割と簡単に作っていけてます!あと、フリー素材!

参考

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

import 'dart:ui' as ui;

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

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

class MapChip {
  const MapChip(this.symbol, this.xInChips, this.yInChips);

  final String symbol;
  final int xInChips;
  final int yInChips;
}

class MyApp extends StatelessWidget {
  static const _mapData = <String>[
    '林林林林林山山山山山山山山山岩',
    '林林林林林林林林山山山山山山山',
    '林林林林林林林平平平平平平山山',
    '林林林林林林平平平平平平平平平',
    '林林平平平平平平町平平平平平水',
    '林林平平平平平平平水水水水水水',
    '平平平城平平平水水水水水水水水',
    '平平平平平平水水水水水水水水水',
    '平平平平水水水水水水水水水水水',
    '平平水水水水水水水水水水岩岩岩',
    '水水水水水水水水水水岩岩岩岩岩',
    '水水水水水水水水毒毒毒岩岩岩岩',
    '水水水水水水水岩毒城毒岩岩岩岩',
    '水水水水水水岩岩毒毒毒砂砂岩岩',
  ];

  static const _chipData = <MapChip>{
    MapChip('林', 0, 15),
    MapChip('平', 0, 10),
    MapChip('山', 4, 13),
    MapChip('岩', 4, 15),
    MapChip('水', 0, 6),
    MapChip('城', 21, 9),
    MapChip('町', 23, 9),
    MapChip('毒', 7, 14),
    MapChip('砂', 10, 3),
  };

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('第2回 地図の生成(2)'),
        ),
        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: [
                  const Text('元画像'),
                  Image.asset('assets/chip12e_map.png'),
                  const Text('加工した地図'),
                  Transform.scale(
                    scale: 1.5,
                    alignment: Alignment.topLeft,
                    child: CustomPaint(
                      painter: MapPainter(snapshot.data!, _mapData, _chipData),
                      size: Size(
                        MapPainter.kWidth * _mapData.length,
                        MapPainter.kHeight * _mapData[0].length,
                      ),
                    ),
                  ),
                ],
              );
            }),
      ),
    );
  }

  Future<ui.Image> loadImage() async {
    // 480 x 256 => 30x16
    final mapAsBytes = await rootBundle.load('assets/chip12e_map.png');
    final pixelData = mapAsBytes.buffer.asUint8List();

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


class MapPainter extends CustomPainter {
  static const kWidth = 16.0;
  static const kHeight = 16.0;

  final ui.Image image;
  final List<String> mapData;
  final Set<MapChip> mapChips;
  final int mapHeight;
  final int mapWidth;

  MapPainter(this.image, this.mapData, this.mapChips)
      : assert(mapData.isNotEmpty),
        mapWidth = mapData[0].length,
        mapHeight = mapData.length {
    assert(mapData.every((mapLine) => mapLine.length == mapWidth));

    assert(
      mapData
          .reduce((e1, e2) => '$e1$e2')
          .split('')
          .every(mapChips.map((e) => e.symbol).contains),
    );
  }

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint();
    for (int mapY = 0; mapY < mapHeight; mapY++) {
      for (int mapX = 0; mapX < mapWidth; mapX++) {
        final symbol = mapData[mapY].substring(mapX, mapX + 1);
        final chip = mapChips.firstWhere((e) => e.symbol == symbol);
        final src = Rect.fromLTWH(
            chip.xInChips * kWidth, chip.yInChips * kHeight, kWidth, kHeight);
        final dst =
            Rect.fromLTWH(mapX * kWidth, mapY * kHeight, kWidth, kHeight);
        canvas.drawImageRect(image, src, dst, paint);
      }
    }
  }

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