【Flutter】ドラクエ風RPG開発記:第4回 キャラクターの移動

対象者

  • FlutterでレトロRPGを「車輪の再開発」したい人
  • スプライト画像を入れてアニメーションしているような演出がしたい人
  • Tickerの基本的な使用方法を知りたい方

はじめに

ドラクエ風RPGの第4回目では、前回作成したゲームコントローラを使って、キャラクターの移動を実装しました。

実装

用語

スプライト画像: 複数のグラフィック要素を一枚の画像にまとめた画像。描画速度の向上やリソース管理の効率化に貢献する。

キャラクターの向きの設定

RPGゲーム開発において、キャラクターの向きを制御することは、プレイヤーの操作感とゲームのリアリズムに直接影響を与えます。このため、キャラクターの向きを表すために、CharacterDirectionという列挙型(enum)を定義しました。

enum CharacterDirection {
  up(0, CrossButtonStatus.up),
  right(1, CrossButtonStatus.right),
  down(2, CrossButtonStatus.down),
  left(3, CrossButtonStatus.left);

  const CharacterDirection(this.imagePosition, this.status);
  final int imagePosition;
  final CrossButtonStatus status;
}

この列挙型では、キャラクターが向きうる四つの方向—up, right, down, left—を定義しています。それぞれの方向には、キャラクターのマップ用チップセット上での位置(imagePosition)が割り当てられており、これによりキャラクターのグラフィックを適切に表示できます。例えば、upは0番目の位置に対応し、キャラクターが上を向いている状態を表します。

また、キャラクターの向きとゲームパッドのボタン操作(CrossButtonStatus)との連動も重要です。プレイヤーが上ボタンを押した際にキャラクターが上を向くように、CrossButtonStatus.upCharacterDirection.upに関連付けています。

なお、キャラクターの向きとクロスボタンステータスを一つのenumで管理することも考えましたが、クロスボタンステータスには「押していない」状態を示すnoneも含まれるため、意味合いが若干異なります。このため、これらを別々のenumとして定義し、クリアなコード構造を維持することにしました。

歩いている演出の実装

ゲーム内でキャラクターが歩いているように見せる演出は、プレイヤーにとってリアルな体験を提供する重要な要素です。本プロジェクトでは、キャラクターが歩く速度を表現するために、ウォークスピードを500ミリ秒(0.5秒)と設定しました。これにより、キャラクターは1秒間に2回、足踏みをするような動きをします。この演出は、(昔の)RPGゲームで一般的に見られるような、左右の足を交互に動かす歩行アニメーションに似ています。

この動きは、FlutterのTickerを利用して実装されています。Tickerはフレームごとにコールバックを提供し、経過した時間に応じて状態を更新することができるため、非常に適しています。

late final _ticker = createTicker((elapsed) {
  bool isOddSecond = (elapsed.inMilliseconds / _kWalkSpeed).floor() % 2 == 1;
  if (isOddSecond != _isOddSecond) {
    setState(() {
      _isOddSecond = isOddSecond;
    });
  }
});

上記のコードでは、経過時間を500で割り、切り捨てした結果をさらに2で割った際の余りが奇数か偶数かでキャラクターの足を交互に動かしています。これにより、キャラクターの歩行が自然に見えるように演出されます。

initStateメソッドで_tickerを開始し、disposeメソッドで停止することで、リソースの適切な管理も行っています。

@override
void initState() {
  super.initState();
  _ticker.start();
}

@override
void dispose() {
  _ticker.dispose();
  super.dispose();
}

キャラクター描画の実装詳細

この記事では、キャラクター画像(スプライト画像の一部分)をより大きく表示するために、_kScaleという定数を用いて画像を2倍に拡大しています。

static const _kScale = 2.0;

このスケールを適用することで、元の画像が小さくても、ゲーム上で適切な視認性を保つため、キャラクターを大きく表示します。Transform.scaleウィジェットを使用して画像を拡大して描画されます。

Transform.scale(
  scale: _kScale,
  alignment: Alignment.topLeft,
  child: CustomPaint(
    painter: CharacterPainter(
      snapshot.data!,
      _isOddSecond ? 0 : 2,
      _characterDirection.imagePosition,
    ),
    size: const Size(
      CharacterPainter.kWidth * _kScale,
      CharacterPainter.kHeight * _kScale,
    ),
  ),
),

ここでのキーとなるポイントは、_isOddSecond変数を使ったアニメーション演出です。この変数は、キャラクターが歩いているときの足の動きを模倣するために用いられ、歩行アニメーションのために画像の左右の部分(通常は右が0、左が2)を交互に表示することで歩いているような動きを再現しています。これにより、キャラクターが一歩ごとに足を交互に出す動きが自然に見えるように演出されます。

_isOddSecond ? 0 : 2,

このような細かい演出は、プレイヤーにとってキャラクターの動きが自然であると感じさせるために重要です。画像のスケーリングと合わせて、これらの技術を使用することで、ゲームのビジュアルとプレイ感を大幅に向上させることが可能です。

キャラクターの描画クラスの実装

キャラクターのビジュアルをゲーム内で正確に描画するためには、専用の描画クラスが必要です。このクラスでは、キャラクターのスプライト画像から適切な部分を選択して画面上に表示する役割を担っています。今回の実装では、特に「CharacterPainter」クラスがその核心をなす部分です。

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

  final ui.Image image;
  final int srcX, srcY;

  CharacterPainter(this.image, this.srcX, this.srcY);

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

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

このクラスは、CustomPainterを継承しており、キャラクターの画像から必要な部分を切り取って描画する機能を持っています。特に、paintメソッド内で画像のソース領域(src)と描画先領域(dst)を定義しています。この例では、キャラクターの一番左上の画像が表示されるように、ソース領域の開始位置(srcXsrcY)をチップのサイズに合わせて調整しています。

今回の実装では、常に同じ位置に画像を描画するため、描画先の位置は固定(0, 0)です。これにより、キャラクターの画像が常に同じ場所に表示されるようになっています。

このクラスの詳細な動作や背景については、前回のブログ記事で既に解説していますので、さらなる詳細に興味がある方は、そちらを参照してください。

ゲームパッドの描画と機能実装

この記事では、ゲームパッドの描画と機能にいくつかの変更が加えられています。このセクションでは、これらの変更点について詳しく解説します。

class GamePad extends StatelessWidget {
  const GamePad({
    required this.callbackCrossButton,
    required this.callbackButtonA,
    required this.callbackButtonB,
    super.key,
  });

  final void Function(CrossButtonStatus status) callbackCrossButton;
  final void Function() callbackButtonA;
  final void Function() callbackButtonB;

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 十字ボタン
        GestureDetector(
          onTapUp: (_) {
            callbackCrossButton(CrossButtonStatus.none);
          },
          onTapDown: (detail) {
            final x = (detail.localPosition.dx / MyApp.kButtonPartSize).floor() - 1;
            final y = (detail.localPosition.dy / MyApp.kButtonPartSize).floor() - 1;

            final buttonStatus = CrossButtonStatus.values.firstWhere(
                (e) => e.x == x && e.y == y,
                orElse: () => CrossButtonStatus.none);
            callbackCrossButton(buttonStatus);
          },
          child: SvgPicture.asset('assets/cross_button.svg'),
        ),
        // AボタンとBボタン
        SizedBox(
          height: MyApp.kButtonPartSize * 1.3,
          child: FilledButton(
            onPressed: callbackButtonA,
            child: const Text('A'),
          ),
        ),
        SizedBox(
          height: MyApp.kButtonPartSize * 1.3,
          child: FilledButton(
            onPressed: callbackButtonB,
            child: const Text('B'),
          ),
        ),
      ],
    );
  }
}

本実装では、十字ボタンやA、Bボタンを押した際に実行されるコールバック関数を定義しています。これにより、ボタンが押されたときに特定のアクションをトリガーすることが可能です。特に、十字ボタンに関しては、押された位置に応じてCrossButtonStatusを更新し、それに基づいてキャラクターの向きを変更する機能が実装されています。

前回のブログで説明したゲームパッドの基本的な考え方は変わっていませんが、今回の変更により、ユーザー操作によりゲームの挙動に反映されるようになりました。十字ボタンを押すと、キャラクターの向きが変更されるようになりました。

ゲームパッド操作の実装と問題点

この記事では、ゲームパッドの操作によってキャラクターの向きが変更される機能を実装しました。また、時間が経過するごとにキャラクターの歩行画像を入れ替えることで、歩いているような演出を追加しています。これらの機能により、ゲーム内でキャラクターが動いている感覚をプレイヤーに提供することができます。

しかしながら、現在の実装では画面のちらつきが発生しており、これはユーザー体験に悪影響を与えます。この問題は、画像の描画方法に起因するものと考えられ、実際のアプリでは改善が必要です。

まとめ

今回はキャラクターの描画をしました。歩くのと方向を変更する演出を実装しました。コントローラからの入力が画面に反映できて、ちょっと、それっぽくなってきました。

次回の記事では、キャラクターが地図上を歩いているような演出をよりリアルにするために、背後に動く地図の処理を加える予定です。これにより、キャラクターが実際に地図上を移動しているところを実装したいです。

参考

  • 【CSS】スプライト画像をstepsでアニメーションさせる方法・作り方
    スプライト画像とは、複数の画像を1枚にまとめた画像

  • スプライト
    スプライトとは、コンピュータ上で動く図形を表現する際に、動かす図形と固定された背景とを別に作成し、ハードウェア上で合成することによって表示を高速化する手法のことである。(中略) ファミリーコンピュータなどでよく利用されていた。 今日の高性能な家庭用ゲーム機では用いられていない。

スプライトという単語は知っていたが、改めて調べました。スプライトとスプライト画像は、元ネタ(sprite: 妖精)は一緒だけど、別の意味か。

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

import 'dart:ui' as ui;

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

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

enum CrossButtonStatus {
  up(0, -1),
  right(1, 0),
  down(0, 1),
  left(-1, 0),
  none(0, 0);

  const CrossButtonStatus(this.x, this.y);
  final int x;
  final int y;
}

enum CharacterDirection {
  up(0, CrossButtonStatus.up),
  right(1, CrossButtonStatus.right),
  down(2, CrossButtonStatus.down),
  left(3, CrossButtonStatus.left);

  const CharacterDirection(this.imagePosition, this.status);
  final int imagePosition;
  final CrossButtonStatus status;
}

class MyApp extends StatefulWidget {
  static const kButtonPartSize = 38.0;

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> with SingleTickerProviderStateMixin {
  var _buttonStatus = CrossButtonStatus.none;
  var _characterDirection = CharacterDirection.down;
  static const _kScale = 2.0;

  var _isOddSecond = false;
  static const _kWalkSpeed = 500;

  late final _ticker = createTicker((elapsed) {
    bool isOddSecond = (elapsed.inMilliseconds / _kWalkSpeed).floor() % 2 == 1;
    if (isOddSecond != _isOddSecond) {
      setState(() {
        _isOddSecond = isOddSecond;
      });
    }
  });
  @override
  void initState() {
    super.initState();
    _ticker.start();
  }

  @override
  void dispose() {
    _ticker.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('第4回 キャラクターの向きを変える'),
        ),
        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: [
                  Expanded(
                    child: Center(
                      child: Transform.scale(
                        scale: _kScale,
                        alignment: Alignment.topLeft,
                        child: CustomPaint(
                          painter: CharacterPainter(
                            snapshot.data!,
                            _isOddSecond ? 0 : 2,
                            _characterDirection.imagePosition,
                          ),
                          size: const Size(
                            CharacterPainter.kWidth * _kScale,
                            CharacterPainter.kHeight * _kScale,
                          ),
                        ),
                      ),
                    ),
                  ),
                  Padding(
                    padding: const EdgeInsets.all(16.0),
                    child: GamePad(
                      callbackCrossButton: (status) {
                        _buttonStatus = status;

                        setState(() {
                          if (status == CrossButtonStatus.none ||
                              status == _characterDirection.status) {
                            return;
                          }

                          _characterDirection = CharacterDirection.values
                              .firstWhere((e) => e.status == status);
                        });
                      },
                      callbackButtonA: () {},
                      callbackButtonB: () {},
                    ),
                  ),
                ],
              );
            }),
      ),
    );
  }

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

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

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

  final ui.Image image;
  final int srcX, srcY;

  CharacterPainter(this.image, this.srcX, this.srcY);

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

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

class GamePad extends StatelessWidget {
  const GamePad({
    required this.callbackCrossButton,
    required this.callbackButtonA,
    required this.callbackButtonB,
    super.key,
  });

  final void Function(CrossButtonStatus status) callbackCrossButton;
  final void Function() callbackButtonA;
  final void Function() callbackButtonB;

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        SizedBox(
          height: 3 * MyApp.kButtonPartSize,
          width: 3 * MyApp.kButtonPartSize,
          child: GestureDetector(
            onTapUp: (_) {
              callbackCrossButton(CrossButtonStatus.none);
            },
            onTapDown: (detail) {
              final x =
                  (detail.localPosition.dx / MyApp.kButtonPartSize).floor() - 1;
              final y =
                  (detail.localPosition.dy / MyApp.kButtonPartSize).floor() - 1;

              final buttonStatus = CrossButtonStatus.values.firstWhere(
                  (e) => e.x == x && e.y == y,
                  orElse: () => CrossButtonStatus.none);
              callbackCrossButton(buttonStatus);
            },
            child: SvgPicture.asset('assets/cross_button.svg'),
          ),
        ),
        Expanded(child: Container()),
        Padding(
          padding: const EdgeInsets.only(
              top: MyApp.kButtonPartSize, right: MyApp.kButtonPartSize / 3),
          child: SizedBox(
            height: MyApp.kButtonPartSize * 1.3,
            child: FilledButton(
              style: FilledButton.styleFrom(
                backgroundColor: Colors.pink,
                shape: const CircleBorder(),
              ),
              onPressed: callbackButtonB,
              child: const Text(''),
            ),
          ),
        ),
        SizedBox(
          height: MyApp.kButtonPartSize * 1.3,
          child: FilledButton(
            style: FilledButton.styleFrom(
              backgroundColor: Colors.pink,
              shape: const CircleBorder(),
            ),
            onPressed: callbackButtonB,
            child: const Text(''),
          ),
        ),
      ],
    );
  }
}