【GameTips】戦略SLG風移動システム

どうもです、タドスケです。

ゲームに使えそうな部品を提供するこのシリーズ。

今回は戦略SLG風の移動システムを作ってみました。

この記事内で公開しているコードは全て自作なので、個人/商用問わずご自由に流用いただいて構いません。
ただしコードを利用して損害が出た場合でも、その責任は負いかねます。
目次

戦略SLG風の移動とは?

ここでいう「戦略SLG」とは、ファイアーエムブレムのようなマス目上にユニット(キャラクター)が配置されるゲームを指します。

引用元:https://www.nintendo.co.jp/switch/anvya/pc/system/battle.html

ユニットを選択すると移動先の候補が表示され、選択した場所にユニットが移動します。

実行例

今回のプログラムを実行すると、以下のような出力が得られます。

[Map]
1 1 1 1 1 1 1 
1 0 0 0 0 0 1 
1 0 2 0 0 0 1 
1 0 0 0 2 0 1 
1 0 0 0 0 0 1 
1 1 1 1 1 1 1 

[Unit]
Hoge: Pos=(3,3), Move=3

[MoveMap for Hoge]
-1 -1 -1 -1 -1 -1 -1 
-1 -1  0  1  0 -1 -1 
-1  0  0  2  1  0 -1 
-1  1  2  3  1  0 -1 
-1  0  1  2  1  0 -1 
-1 -1 -1 -1 -1 -1 -1 

(1,1)には移動できません。
(1,2)に移動します。

[MoveMap for Hoge]
-1 -1 -1 -1 -1 -1 -1 
-1  2  1  0 -1 -1 -1 
-1  3  1  0 -1 -1 -1 
-1  2  1  0 -1 -1 -1 
-1  1  0 -1 -1 -1 -1 
-1 -1 -1 -1 -1 -1 -1 

Map

戦闘マップ(ステージ)です。

数字は地面の種類を表しており、それぞれ移動コストが異なります。

数字移動コスト説明
01普通の地面。
草原とか道とか。
1(進入不可)通れない場所。
壁とか。
22普通の地面よりも移動コストが高い。
水、森、山など。

移動コストとは、その場所に移動するのに必要な移動力のことです。

例えば移動力4のユニットがいた場合、移動コスト1の地面の上であれば4マス移動できますが、移動コスト2のマスは2マスしか移動できません。

Unit

ユニット一体を表します。

パラメータとして、現在いる位置(Pos)と移動力(Move)を持っています。

普通のゲームであればHP、攻撃力などのパラメータも必要ですが、今回は移動のみを扱うので省略しています。

MoveMap

ユニットの移動範囲を表します。

数字は残り移動力を表していて、ユニットが今いる位置にユニットの移動力を、そこから隣に移動したマスには残りの移動力が入ります。

「-1」の場所は進入できない場所で、壁があったり移動力が足りなかったりした時に入ります。

移動ログ

(1,1)には移動できません。
(1,2)に移動します。

このメッセージは、移動先を選択した時を想定しています。

カッコ内の数字は、(X座標、Y座標)を表しています。

MoveMap(左上を0,0としています) の中で(1, 1)のポイントは「-1」のため、移動できません。

(1, 2)のポイントは「0」なので、3マス移動すればギリギリ届きます。

MoveMap の更新

ユニットが移動したら、移動先(1, 2)で移動範囲を計算し直しています。

3の一つ右のマス(2, 2)が2ではなく1になっているのは、(2, 2)の移動コストが2になっているためです。

実装方法

実行例のような動作をするためにどのようなプログラムを書けばよいでしょうか。

クラスごとに説明します。

Unit クラス

class Unit:
    """ユニット.

    :param position: 位置
    :param move: 移動力
    :param name: ユニットの名前
    """

    _DEFAULT_MOVE = 4

    def __init__(
            self,
            position=GridPosition(),
            move=_DEFAULT_MOVE,
            name='') -> None:
        self._pos = position
        self._move = move
        self._name = name

    @property
    def position(self) -> GridPosition:
        """位置."""

        return self._pos

    @property
    def move(self) -> int:
        """移動力."""

        return self._move

    @property
    def name(self) -> str:
        """名前."""
        
        return self._name

    def set_position(self, pos) -> None:
        """位置を設定します."""
        
        self._pos = pos

    def __str__(self) -> str:
        return f'{self.name}: Pos={self.position}, Move={self.move}'

位置・移動力を取得するためのプロパティと、位置を設定するための関数があるくらいです。

移動範囲の計算は MoveMap クラスに任せているため、あっさりした内容になっています。

Map クラス

class Map:
    """マップ.

    :param ground_types: 地面タイプの二次元リスト
    :param ground_dict: 地面タイプ → Ground の辞書
    """

    def __init__(
            self,
            ground_types: list[list[int]],
            ground_dict: dict[int, Ground]) -> None:
        assert 0 < len(ground_types)
        assert 0 < len(ground_types[0])

        self._ground_types = ground_types
        self._ground_dict = ground_dict
        self._unit_set = set()

    @property
    def width(self) -> int:
        """幅."""

        return len(self._ground_types[0])

    @property
    def height(self) -> int:
        """高さ."""

        return len(self._ground_types)

    def get_ground(self, pos: GridPosition) -> Ground:
        """指定位置の地面を得ます.

        :param pos: 位置
        :return: 地面
        """

        type_ = self.get_type(pos)
        return self._ground_dict[type_]

    def get_type(self, pos: GridPosition) -> int:
        """指定位置の地面タイプを得ます.

        :param pos: 位置
        :return: 地面タイプ
        """

        return self._ground_types[pos.y][pos.x]

    def get_cost(self, pos: GridPosition) -> int:
        """指定位置の移動コストを得ます.

        :param pos:
        :return: 移動コスト
        """

        g = self.get_ground(pos)
        return g.cost

    def add_unit(self, unit: Unit) -> None:
        """ユニットを追加します.

        既に存在するユニットを指定した場合は無視します.

        :param unit: ユニット
        """

        self._unit_set.add(unit)

    def find_unit_from_pos(self, pos: GridPosition) -> tp.Optional[Unit]:
        """指定位置にいるユニットを得ます.

        :param pos: 位置
        :return: ユニット。指定位置にいない場合は None
        """

        for unit in self._unit_set:
            if unit.position == pos:
                return unit
        return None

    def is_range(self, pos: GridPosition) -> bool:
        """指定位置がマップ範囲内か?

        :param pos: 位置
        :return: 範囲内ならTrue
        """

        if self.width <= pos.x:
            return False
        if self.height <= pos.y:
            return False
        return True

    def can_move(self, pos: GridPosition, move: int) -> bool:
        """指定位置に移動可能か?

        :param pos: 位置
        :param move: 移動力
        :return: 移動可能ならTrue
        """

        if not self.is_range(pos):
            return False

        cost = self.get_cost(pos)
        if cost == Ground.COST_FORBIDDEN:
            return False
        if move < cost:
            return False

        if self.find_unit_from_pos(pos) is not None:
            return False

        return True

    def dump(self) -> None:
        """マップの情報を出力します."""

        for line in self._ground_types:
            for x in line:
                print(x, end=' ')
            print('')


class Ground:
    """地面.

    :param cost: 移動コスト
    """

    # 進入禁止
    COST_FORBIDDEN = -1

    def __init__(self, cost: int) -> None:
        assert 0 <= cost or cost == self.COST_FORBIDDEN

        self._cost = cost

    @property
    def cost(self) -> int:
        """移動コスト."""

        return self._cost

    def is_forbidden(self) -> bool:
        """進入禁止か?"""

        return self._cost == self.COST_FORBIDDEN

マップの広さ(width, height)と、マスごとの地面の情報(groud_types)を持っています。

地面の情報は直接持つのではなく、地面タイプ→Ground クラスのオブジェクト の辞書を別に持ち、そこから検索しています。

こうすることで、将来的に移動コスト以外の情報をGround クラスに持たせられるようになります。

add_unit / find_unit_from_pos 関数は、マップ内のユニットを登録・検索するためのものです。

ユニット情報は can_move 関数内で「既に他のユニットがいるマスは通れない」という判定に使われています。
(今回の例ではユニットは1体しかいないので、詳しくは省きます)

MoveMap クラス

class MoveMap:
    """移動範囲マップ.

    計算結果は、ユニットのいる位置を起点に、移動力を減らしながら書き込まれます.
    移動できない位置は UNSET_VALUE のままになります.

    :param map_: マップ
    """

    # 書き込みされていない値
    UNSET_VALUE = -1

    def __init__(self, map_: Map) -> None:
        self._map = map_
        self._moves = []
        self._reset()

    def _reset(self) -> None:
        """計算結果をリセットします."""

        self._moves.clear()
        for y in range(self._map.height):
            line = [self.UNSET_VALUE] * self._map.width
            self._moves.append(line)

    def calc(self, unit) -> None:
        """指定ユニットの移動範囲を計算します.

        :param unit: ユニット
        """

        self._reset()
        self._calc_step(unit.position, unit.move)

    def _calc_step(self, pos: GridPosition, move: int) -> None:
        """一歩分の移動範囲を計算します.

        この関数は移動できなくなるまで再帰的に呼び出されます.

        :param pos: 計算する位置
        :param move: 移動力
        """

        self._write(pos, move)
        if move == 0:
            return

        self._calc_step_next(pos.down(), move)
        self._calc_step_next(pos.up(), move)
        self._calc_step_next(pos.left(), move)
        self._calc_step_next(pos.right(), move)

    def _calc_step_next(self, next_pos: GridPosition, move: int) -> None:
        """次の位置への移動を計算します.

        :param next_pos: 移動先の位置
        :param move: 移動力
        """

        if not self._map.can_move(next_pos, move):
            return

        next_cost = self._map.get_cost(next_pos)
        self._calc_step(
            next_pos, move - next_cost)

    def _write(self, pos: GridPosition, move: int) -> None:
        """指定位置に移動力を書き込みます.

        :param pos: 位置
        :param move: 移動力
        """

        if move <= self._moves[pos.y][pos.x]:
            return
        self._moves[pos.y][pos.x] = move

    def can_move(self, pos: GridPosition) -> bool:
        """指定位置に移動できるか?

        :param pos: 位置
        :return: 移動できればTrue
        """

        if not self._map.is_range(pos):
            return False

        step = self.get_step(pos)
        if step == self.UNSET_VALUE:
            return False
        return True

    def get_step(self, pos: GridPosition) -> int:
        """指定位置の計算結果を得ます.

        :param pos: 位置
        :return: 計算結果
        """

        return self._moves[pos.y][pos.x]

    def dump(self) -> None:
        """現在の情報を出力します."""

        for line in self._moves:
            for x in line:
                print(f'{x:2}', end=' ')
            print('')

今回のシステムのキモとなる移動範囲の計算クラスです。

Unit や Map クラスに含めてもよさそうですが、コード量が多くなるためクラスを分割しています。

戦闘中に変化しない Map オブジェクトは init で渡し、計算時に毎回変化する Unit オブジェクトは calc 関数に渡しています。

移動範囲の計算は _calc_step 関数で行っています。

計算方法については、既に他の方々がわかりやすくまとめてくれていますので、紹介にとどめます。

スプーキーズのちょっとTech。
戦略SLGの移動範囲計算を実装してみた - スプーキーズのちょっとTech。 こんにちは、ロックです! 今回は皆さんもお馴染みのゲームジャンル「戦略SLG」についてです! 戦略SLGとは ターン性でキャラクターを動かし合うシミュレーションゲーム。 ...

GridPosition クラス

今回のプログラムでは、位置情報を GridPosition クラスとして扱っています。

単純なプログラムであれば int 型のタプルで十分ですが、今回のプログラムで扱う「位置」には、色々な特徴があります。

  • 位置はマイナスにはならない
    →マイナスの値を入れようとしたらエラーにする
  • マス同士の距離は 縦+横 で数える
    →「斜め」は2マス
  • 「上のマス」など、「1つ隣のマス」を取りたい場合が多い
  • 位置同士の比較が必要な場合が多い
    x == pos.x and y == pos.y と毎回書くのは面倒

このため、位置情報はクラスとして扱ったほうが、利用する側のコードを簡潔に書けます。

そうして書いたのが以下のコードです。

class GridPosition:
    """グリッド上の位置を表すクラス.

    負の数は扱いません.

    :param x: X座標
    :param y: Y座標
    """

    def __init__(self, x: int = 0, y: int = 0) -> None:
        assert 0 <= x
        assert 0 <= y

        self._x = x
        self._y = y

    @property
    def x(self) -> int:
        """X座標."""

        return self._x

    @property
    def y(self) -> int:
        """Y座標."""

        return self._y

    def get(self) -> tuple[int, int]:
        """X,Y座標をタプル形式で得ます.

        :return: X,Y座標のタプル
        """

        return self._x, self._y

    def shift(self, dx: int, dy: int) -> None:
        """指定したX, Yの分だけ移動します.

        :param dx: Xの移動量
        :param dy: Yの移動量
        """

        self._x = max(0, self._x + dx)
        self._y = max(0, self._y + dy)

    def up(self) -> GridPosition:
        """一つ上の座標を返します.

        :return: 一つ上の座標
        """

        return GridPosition(
            self._x, max(0, self._y - 1))

    def down(self) -> GridPosition:
        """一つ下の座標を返します.

        :return: 一つ下の座標
        """

        return GridPosition(self._x, self._y + 1)

    def left(self) -> GridPosition:
        """一つ左の座標を返します.

        :return: 一つ左の座標
        """

        return GridPosition(
            max(0, self._x - 1), self._y)

    def right(self) -> GridPosition:
        """一つ右の座標を返します.

        :return: 一つ右の座標
        """

        return GridPosition(self._x + 1, self._y)

    def calc_distance(self, pos: GridPosition) -> int:
        """指定した座標との距離を計算します.

        グリッド単位での距離となります.
        斜め上の座標を指定した場合、斜め1マスではなく、横→縦で2マスとなる点に注意してください.

        :param pos: 比較先の座標
        :return: 距離
        """

        dx = abs(pos.x - self.x)
        dy = abs(pos.y - self.y)
        return dx + dy

    def __eq__(self, other) -> bool:
        if isinstance(other, tuple):
            x, y = other
        elif isinstance(other, GridPosition):
            x, y = other.get()
        else:
            raise TypeError

        return self.x == x and self.y == y

    def __str__(self):
        return f'({self._x},{self._y})'

システムコード

これまでに紹介してきたクラスを組み合わせて、移動システムを作ります。

先に紹介した実行例を得られるコードは以下です。(今回はテスト側に置いてあります)

class TestSLGMove(unittest.TestCase):
    """各機能を利用したサンプル."""

    def test_case(self):
        map_ = _create_map2()
        print('')
        print('[Map]')
        map_.dump()
        print('')

        unit = Unit(
            GridPosition(3, 3),
            move=3,
            name='Hoge')
        print('[Unit]')
        print(unit)
        map_.add_unit(unit)

        move_map = MoveMap(map_)
        move_map.calc(unit)
        print('')
        print(f'[MoveMap for {unit.name}]')
        move_map.dump()
        print('')

        pos = GridPosition(1, 1)
        self._move_unit(unit, move_map, pos)

        pos = GridPosition(1, 2)
        self._move_unit(unit, move_map, pos)

        move_map.calc(unit)
        print('')
        print(f'[MoveMap for {unit.name}]')
        move_map.dump()
        print('')

    def _move_unit(self, unit, move_map, pos):
        if move_map.can_move(pos):
            unit.set_position(pos)
            print(f'{pos}に移動します。')
        else:
            print(f'{pos}には移動できません。')

コード

コード全文は以下の場所にアップしてあります。

システム本体

ユニット、マップ、移動範囲マップなどのクラスを含むシステム本体のモジュールです。

GitHub
gametips/src/slgmove/main.py at master · tadosuke/gametips ゲームで使えそうな処理. Contribute to tadosuke/gametips development by creating an account on GitHub.
GitHub
gametips/tests/slgmove/test_main.py at master · tadosuke/gametips ゲームで使えそうな処理. Contribute to tadosuke/gametips development by creating an account on GitHub.

グリッド座標モジュール

システム内で使用している座標クラス(GridPosition)が定義されているモジュールです。

GitHub
gametips/src/slgmove/position.py at master · tadosuke/gametips ゲームで使えそうな処理. Contribute to tadosuke/gametips development by creating an account on GitHub.
GitHub
gametips/tests/slgmove/test_position.py at master · tadosuke/gametips ゲームで使えそうな処理. Contribute to tadosuke/gametips development by creating an account on GitHub.

拡張のアイデア

今回のプログラムを拡張して実際のゲームを作る場合、以下のような対応が必要になるでしょう。

戦闘用パラメータ

Unit クラスにHPや攻撃力などのパラメータを追加し、他のユニットを攻撃できるようにします。

ダメージ計算のやり方については、先日公開した「ドラクエ風ターンバトルシステム」の解説が参考になるかもしれません。

あわせて読みたい
【GameTips】ドラクエ風ターンバトルシステム どうもです、タドスケです。 Python で何か作りたいけど、大きなゲームをまるっと作れるほどの余力がない現状。 いろいろ考えてみたところ、 ゲームそのものじゃなくて...

ただし戦略SLGの場合は射程という概念があるため、他のユニットとの距離を測る必要があります。

距離計算には、GridPosition クラスにある calc_distance 関数を使うとよいでしょう。

ターン要素

ターンバトルシステムと組み合わせて、ユニットが順番に行動できるようにします。

ユニットを味方・敵チームに分けて、味方ターン内なら好きな順番でユニットを動かせるようにするとファイアーエムブレムっぽくなります。

あるいは行動量・ウェイトターンの要素を入れて、タクティクスオウガなどのような行動順システムにするのもよいかもしれません。
(こっちの方が実装難易度は上がります)

移動タイプ

実際の戦略SLGには移動タイプというものがあります。

例えば移動コスト2の「水」地形を、魚人のようなユニットがコスト1で移動できたら、戦略の幅が広がりそうです。

同じように、通常は進入できない穴や岩山を移動できる飛行兵なども定番です。

このような機能を入れるのであれば、ユニットに移動力だけでなく移動タイプをパラメータとして持たせる必要があります。

Ground クラスに設定されている移動コストについても、移動タイプ×コストの表を用意する必要があります。

地上12×
水中21×
飛行111
移動コスト表の例

ここで Ground クラスをわざわざ別クラスにした意味が出てきます。

以上、戦略SLG風移動システムの解説でした!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメント一覧 (1件)

コメントする

目次