見出し画像

Pythonでマイクラを作る ⑭ゲームをセーブする

Pythonでマイクラを作る 第14回目です。今回はセーブ機能を追加します。
せっかく作った建築がゲーム終了で消えてしまうと悲しいです。セーブ機能があれば、セーブデータからワールドをロードして再現できるようになり、建築の続きができるようになります。これで建築が捗りますね。

セーブ方法を考える

ワールドをセーブするにはいくつかの方法が考えられます。
最も簡単なのは、テキストファイルでブロック情報を保存する方法です。ブロック情報は self.block.block_dictionary に辞書として保存されているので、それをそのままテキストファイルに書き込むだけで完了です。
しかしこの方法は欠点があります。

  • ブロック情報以外のデータは保存できない

  • 複数のワールドを保存すると、テキストファイルが複数になってしまう

  • データの挿入、更新、削除、検索などの操作が簡単にできない

これらの欠点を解消する方法として、データベースを使う方法があります。Python で標準で使用できる軽量データベース SQLite3 を使ったセーブを実装していくことにします。

SQLite3

SQLite3 はオープンソースで軽量な RDBMS(リレーショナルデータベース管理システム)のことです。データベースサーバーを用意する必要がなく、ファイル形式でアプリ・ゲームに組み込むことができます。データベースを操作する言語である、SQL を使ってデータベースを操作できます。
次にデータベース操作言語 SQL の基礎を学びます。

SQL の基礎

ターミナル(PowerShell)を使って、SQL を使ってみます。まずデータベースを保存する saves ディレクトリを作成してます。

$ cd ~/Documents/pynecrafter # 開発ディレクトリに移動(環境による)
$ mkdir saves
$ cd saves

データベースを新規に作成します。データベース名は sample.sqlite3 にしました。同じ名前のデータベースがあるときは、指定したデータベースが開きます。
sqlite > の表示はデータベースの中に入って、コマンドを実行できることを表しています。

$ sqlite3 sample.sqlite3
sqlite>

データベースの中にテーブルを作ります。テーブルはデータを格納する領域のことで、データの種類によって複数持つことができます。テーブルは表形式のデータで、縦の列(カラム)、横の行(レコード)から作られます。

(用語の説明)
Excel で例えると、データベースをイメージしやすいです。データベースがブック、テーブルはシートに相当します。カラムは列、レコードは行、そしてフィールドはセルに相当します。
データベース = ブック
テーブル = シート
カラム = 列
レコード = 行(データそのもの)
フィールド = セル

テーブル名 worlds を作成します。カラムは、id、name、ground_size、game_mode の4つを指定し、それぞれデータ形式 INTEGER(整数)、TEXT(文字列)を指定します。

sqlite> CREATE TABLE worlds(id INTEGER, name TEXT, ground_size INTEGER, game_mode TEXT);

テーブルが作成されたか確認しましょう。.table で作成済みの全てのテーブルを確認できます。

sqlite> .table
worlds  # テーブルが作成された

データの挿入は INSERT INTO で行います。values に保存する値を記述します。全てのカラムに値を入れるときは、worlds の後ろの括弧は省略できます。 

sqlite> INSERT INTO worlds(id, name, ground_size, game_mode) values(1, 'test_world', 32, 'debug');
sqlite> INSERT INTO worlds values(2, 'My World', 128, 'game');  # テーブル名の後ろの括弧を省略

データの表示は SELECT FROM で行います。*(アスタリスク)は全てのカラムを指定することを意味します。* の代わりにカラム名を指定すると、そのカラムだけ表示できます。
実行すると、2件のデータが保存されていることが確認できました。

sqlite> SELECT * FROM worlds ;
1|test_world|32|debug  # 1件目のデータが保存できた
2|My World|128|game  # 2件目のデータが保存できた

sqlite> SELECT name, ground_size FROM worlds;
My World|128  # name、gorund_sizeカラムのみ表示された
test_world|32  # name、gorund_sizeカラムのみ表示された

データの更新は UPDATE  SET で行います。更新するカラム = データ値の形で指定します。WHERE 節で更新するデータの条件を指定してやります。

sqlite> UPDATE worlds SET name = 'TEST_WORLD', game_mode = 'game' WHERE id = 1;
sqlite> SELECT * FROM worlds;
1|TEST_WORLD|32|game  # 更新されている
2|My World|128|game

データの削除は DELETE FROM で行います。WHERE 節で削除するデータの条件を指定してやります。

sqlite> DELETE FROM worlds WHERE id = 1;
sqlite> SELECT * FROM worlds;
2|My World|128|game  # 1件目のデータが削除された

SQL言語は大変奥が深く、しっかり理解するのは1冊の本が必要になります。今回は必要最低限の基礎だけ説明しました。
読者の皆さんは、3件目、4件目、5件目… のデータを挿入して、更新、削除を試してみてください。SQL の感覚が掴むことができます。
データベースを閉じるには .exit を実行します

sqlite> .exit

データベースマネージャー

データベースを管理するソフトを導入します。
Windows、Mac の両方で使えて、SQLite 以外にもたくさんのデータベースに対応している。TablePlus を紹介します。タブを2つ以上開けられないなど制限はありますが、無料で問題なく使えます。
公式サイトから、ソフトをダウンロード、インストールをしてください。

tableplus1

データベースに接続する方法を説明します。
上図の右のエリアで右クリック → New → Connect を選択します。

tableplus2

対応しているデータベースの一覧が表示されます。SQLIte を選んで Createボタンをクリックします。

name欄にデータベースの管理名を入力します。Select fileボタンから先ほど作成した sample.sqlite3 を選びます。Connectボタンでデータベースに接続できます。

tableplus4

先ほど作成した worldsテーブルのデータが確認できました。SLECT FROM からデータを確認することができますが、件数が増えてくるとコマンドラインからは操作しずらくなるので、データベースマネージャーをうまく利用していくとよいです。
データの挿入、更新、削除などの操作もソフト上で行えます。データの変更後に、Command + S(Ctrl + S)で保存してください。

データベースの設計

データベースの設計では、複数のテーブルを連携(リレーション)させて、管理しやすいデータベースを作成することを目指します。今回保存したいデータは4種類です。

  1. ワールドの基本情報

  2. ブロックの情報

  3. プレイヤーの情報

  4. モブキャラクターの情報

動物などのモブは今後の実装予定ですが、データベース設計段階で考慮しておきます。プレイヤー情報とモブキャラクター情報は、共通の形で管理できそうなので、テーブルは3つ作成することにしました。

データベース設計

上図は、worlds、blocks、characters の3つのテーブルを示しました。テーブル同士を結合するために、主キーと外部キーを設定します。
worldsテーブルの id が主キーになります。主キーは1件のレコードを特定するために使われます(よってテーブルに同じ主キーの値があってはいけない)。
外部キーは主キーと結びつけるために使われます。blocksテーブルの world_id が外部キーで、worldsテーブルの主キーの値を保存しています。上図の初めの4件のデータは、worldsテーブルの1件目と結合していることを示しています。
worldsテーブルには作成日時、更新日時を保存する created_at、updated_atカラムを追加します。更新日時が新しいものを上に表示できるようにするためです。

以上でデータベースの設計ができました。worlds、blocks、characters の3つのテーブルを作成し、ワールドを保存する。worldsテーブルの主キー(id)、blocksテーブル、charactersテーブルの外部キー(world_id)を使って、テーブル間の連携を保存する。
準備が長くなりましたが、コードを書いていきましょう。

ワールドをセーブする

"""src/mc.py"""
from direct.showbase.ShowBase import ShowBase
from panda3d.core import *
from . import *


class MC(ShowBase, UserInterface, Inventory, Menu):
    def __init__(self, ground_size=128, mode='normal'):
        self.mode = mode
        self.ground_size = ground_size  # 追記
        # ShowBaseを継承する
        ShowBase.__init__(self)
        self.font = self.loader.loadFont('fonts/PixelMplus12-Regular.ttf')
        UserInterface.__init__(self)
        Inventory.__init__(self)
        Menu.__init__(self)

        # ウインドウの設定
        self.properties = WindowProperties()
        self.properties.setTitle('Pynecrafter')
        self.properties.setSize(1200, 800)
        self.win.requestProperties(self.properties)
        self.setBackgroundColor(0, 1, 1)

        # ブロック
        self.block = Block(self, ground_size)

        # プレイヤー
        self.player = Player(self)

        # ゲーム終了
        self.accept('escape', self.exit_game)  # 修正

    def exit_game(self):  # メソッドを追加
        if self.db:
            self.cursor.close()
            self.db.close()
        exit()

    def get(self, var):
        try:
            return getattr(self, var)
        except AttributeError:
            return None

    def set(self, var, val):
        setattr(self, var, val)

MCクラスを修正します。ground_sizeを保存するために、インスタンス変数に代入します。
ゲームの終了処理も修正します。データベースを開いたときは、closeメソッドを実行してから、次の処理を行わなければなりません。exit_gameメソッドを作成して、データベースを閉じてから、ゲームを終了するようにコードを修正しました。

"""src/block.py"""
from math import *
from panda3d.core import *


class Block:

    def __init__(self, base, ground_size):
        self.base = base
        self.ground_size = ground_size
        self.block_dictionary = {}
        # ブロックノード
        self.base.block_node = self.base.render.attachNewNode(PandaNode('block_node'))  # 追記

        # グラウンドを作成
        self.set_flat_world()

    def add_block_dictionary(self, x, y, z, block_id):
        key = f'{floor(x)}_{floor(y)}_{floor(z)}'
        self.block_dictionary[key] = block_id

    def add_block_model(self, x, y, z, block_id):
        key = f'{floor(x)}_{floor(y)}_{floor(z)}'
        self.base.set(key, self.base.block_node.attachNewNode(PandaNode(key)))  # 修正
        placeholder = self.base.get(key)
        placeholder.setPos(floor(x), floor(y), floor(z))
        block = self.base.loader.loadModel(f'models/{block_id}')
        block.reparentTo(placeholder)

以下略

Blockクラスを修正します。
セーブしたワールドをロードするとき、ワールドを初期化してからワールドを再生成します。初期化でブロックを全て削除しなければなりませんが、全削除を簡単に行うため、block_nodeノードを作成することにしました。
ブロックを設置するときは block_nodeノードに配置していきます。ブロックを全削除するには、block_nodeノードごとをブロックを一気に削除できるようになりました。

"""src/menu.py"""
import os  # 追記
import sqlite3  # 追記
from panda3d.core import *
from .utils import *


class Menu:
    save_path = 'saves'  # 追記
    db_name = 'pynecrafter'  # 追記

    def __init__(self):
        # sqlite3データベース
        self.db = None
        self.cursor = None

Menuクラスを修正します。osライブラリ(OS に依存した機能を使う)と sqlite3ライブラリ(データベースを操作する)をインポートします。
クラス変数で、save_path(セーブデータの保存場所)、db_name(データベース名)を保存します。

修正

self.exit_button = DrawMappedButton(
    parent=self.menu_node,
    model=self.button_model,
    text='ゲームを終了',
    font=self.font,
    pos=(0, 0, -0.4),
    command=self.exit_game  # 修正
)

インスタンス変数exit_button を修正して、ゲーム終了時に self.exit_gameメソッドが実行されるように書き換えます。

追記

def connect_db(self):
    path = Menu.save_path
    db_name = Menu.db_name
    if not os.path.exists(path):
        os.makedirs(path)
    if self.db is None:
        self.db = sqlite3.connect(f'{path}/{db_name}.sqlite3')
        self.cursor = self.db.cursor()

connect_dbメソッドは、データベース接続を確立します。
if not os.path.exists(path): でセーブデータを保存するディレクトリがあるか確認して、なければ os.makedirs(path) で作成ます。
sqlite3.connect(f'{path}/{db_name}.sqlite3')により、データベースと接続します。データベースを操作する cursor(カーソル)を cursorメソッドで作成します。

追記

    def create_tables(self):
        self.cursor.execute(
            'CREATE TABLE IF NOT EXISTS worlds('
            'id INTEGER PRIMARY KEY AUTOINCREMENT, '
            'name TEXT UNIQUE, '
            'ground_size INTEGER, '
            'game_mode TEXT, '
            'created_at TEXT NOT NULL DEFAULT (DATETIME(\'now\', \'localtime\')), '
            'updated_at TEXT NOT NULL DEFAULT (DATETIME(\'now\', \'localtime\')))'
        )
        self.cursor.execute(
            'CREATE TRIGGER IF NOT EXISTS trigger_worlds_updated_at AFTER UPDATE ON worlds '
            'BEGIN'
            '   UPDATE test SET updated_at = DATETIME(\'now\', \'localtime\') WHERE rowid == NEW.rowid;'
            'END'
        )
        self.cursor.execute(
            'CREATE TABLE IF NOT EXISTS characters(' 
            'id INTEGER PRIMARY KEY AUTOINCREMENT, '
            'character_type TEXT, '
            'x INTEGER, '
            'y INTEGER, '
            'z INTEGER, '
            'direction_x INTEGER, '
            'direction_y INTEGER, '
            'direction_z INTEGER, '
            'world_id INTEGER)'
        )
        self.cursor.execute(
            'CREATE TABLE IF NOT EXISTS blocks('
            'id INTEGER PRIMARY KEY AUTOINCREMENT, '
            'x INTEGER, '
            'y INTEGER, '
            'z INTEGER, '
            'block_id INTEGER, '
            'world_id INTEGER)'
        )

create_tablesメソッドで、3つのテーブル(worlds、blocks、characters)を作成します。self.cursor.executeメソッドの引数に SQL文を指定してデータベースを操作します。
SQL の基礎で説明した CREATE TABLE 文(テーブルの作成)を使います。
CREATE TABLE IF NOT EXISTS はテーブルが存在しない時に新しくテーブルを作成するコマンドです。
id INTEGER PRIMARY KEY AUTOINCREMENT は、id を主キーに設定して、自動的に整数を順番に割り当てることを指示しています。
name TEXT UNIQUE はワールド名がユニーク(唯一)であることを指示しています。同じワールド名が存在しないように指定しました。
created_at TEXT NOT NULL DEFAULT (DATE_TIME(\'now\', \'localtime\')) は、このカラムは NULL ではなく何か値を入れなければならないことを指示しています。明示的に指定されないときは、現在の日時(DATE_TIME(\'now\', \'localtime\'))を自動で入力することを指示しています。
データを更新時に updated_at の値を自動的に更新するためにトリガーを指定しました。
CREATE TRIGGER IF NOT EXISTS trigger_worlds_updated_at で trigger_worlds_updated_atというトリガーを作成します。
トリガーの実行条件は BEGIN - END の間に指定します。
UPDATE worlds SET updated_at = CURRENT_TIMESTAMP WHERE rowid == NEW.rowid; がトリガーの実行条件です。rowid は内部的に使われる非表示の ID です。NEW.rowid で更新した rowid が取得できるので、そのレコードの updated_atカラムを現在日時に更新します。

追記

def get_world_id_from_name(self, world_name):
    self.cursor.execute(
        'SELECT id from worlds where name = ?',
        (world_name,)
    )
    world_id = self.cursor.fetchone()[0]

    return world_id

get_world_id_from_nameメソッドは、ワールド名から主キーの値を取得するメソッドです。executeメソッドの第一引数は SQL文を指定します。第2引数は SQL文の ? に代入する値をタプル形式で指定します。(world_name,) はタブルの要素が一つなので、カンマが入っていることに注意してください。
fetchoneメソッドで self.cursor を読み込むことができます。読み込んだデータはタプル形式なので、[0]により目的の id(主キー)が得られます。

修正

def save_world(self):
    world_name = self.save_input_field.get(True)
    if world_name:
        self.save_notification_text['text'] = 'セーブしています...'

        # セーブ処理
        self.connect_db()
        self.create_tables()
        self.cursor.execute('SELECT COUNT(*) FROM worlds WHERE name = ?', (world_name,))
        has_same_world_name = self.cursor.fetchone()[0]

        # ワールドを保存
        if has_same_world_name:
            self.cursor.execute(
                'UPDATE worlds SET ground_size = ?, game_mode = ? ',
                (self.ground_size, self.mode)
            )
        else:
            self.cursor.execute(
                'INSERT INTO worlds(name, ground_size, game_mode) values(?, ?, ?)',
                (world_name, self.ground_size, self.mode)
            )

        # world_id を取得
        world_id = self.get_world_id_from_name(world_name)

        # ブロックデータを初期化
        self.cursor.execute(
            'DELETE FROM blocks where world_id = ?',
            (world_id,)
        )

        # ブロックデータを保存
        inserts = []
        for key, value in self.block.block_dictionary.items():
            x, y, z = key.split('_')
            block_id = value
            inserts.append((x, y, z, block_id, world_id))
        self.cursor.executemany(
            'INSERT INTO blocks(x, y, z, block_id, world_id) values(?, ?, ?, ? ,?)',
            inserts
        )

        # プレイヤーを初期化
        self.cursor.execute(
            'DELETE FROM characters where world_id = ?',
            (world_id,)
        )

        # プレイヤー情報を保存
        character_type = 'player'
        x, y, z = self.player.position
        direction_x, direction_y, direction_z = self.player.direction
        self.cursor.execute(
            'INSERT INTO characters(character_type, x, y, z, direction_x, direction_y, direction_z, world_id) '
            'values(?, ?, ?, ? ,?, ?, ?, ?)',
            (character_type, x, y, z, direction_x, direction_y, direction_z, world_id)
        )

        self.db.commit()
        self.save_notification_text['text'] = 'セーブ完了!'
    else:
        self.save_notification_text['text'] = 'ワールド名を入力してください。'

save_worldメソッドは、ワールドデータを保存します。まず db_connectメソッド、create_tablesメソッドでデータベースに接続し、テーブルを作成します。

        self.cursor.execute('SELECT COUNT(*) FROM worlds WHERE name = ?', (world_name,))
        has_same_world_name = self.cursor.fetchone()[0]

SELECT COUNT(*) はレコードの数を計算します。has_same_world_name は同じワールド名があれば 1、なければ 0 の値を取ります。
同じワールド名のレコードがあれば UPDATE、なければ INSERT によりレコードを更新、または挿入します。
次に blocksテーブル、characterテーブルにデータを保存します。get_world_id_from_nameメソッドにより、外部キーである world_id を得ることができます。その外部キーを使って、現在存在しているデータを全て削除(DELETE)します。そして新たにブロック情報とプレイヤー情報を保存します。
executemanyメソッドは、一度に大量のデータを保存できる便利なメソッドです。第2引数にリストを渡すと、要素であるタプルを連続で保存します。

全ての操作が終了したら、commitメソッドで変更を確定します。

セーブ画面

11_01_main.py を実行し、「q」キーを押してメニュー画面に入ります。「ゲームをセーブ」ボタンからセーブ画面に入ります。ワールドの名前を決めて、「セーブする」ボタンを押して、セーブを実行します。

pynecrafter.sqlite3

TablePlus でデータベースを覗いてみましょう。worldsテーブルに最初のレコードが保存されていることが確認できます。作成日時、更新日時も自動で保存されています。blocksテーブル、playerテーブルもデータが正しく保存されていることを確認しておいてください。

ワールドをロードする

追記

def get_world_names(self):
    self.connect_db()
    self.create_tables()

    # ワールド名のリストを取得
    self.cursor.execute(
        'SELECT name FROM worlds ORDER BY updated_at DESC'
    )
    world_names = [value[0] for value in self.cursor.fetchall()]

    return world_names

get_world_namesメソッドはデータベースに保存されているワールド名をリストとして取得できます。SQL文の ORDER BY updated_at DESC は、updated_atカラムを逆順(DESC)で並べ替えることを意味します。そうすることで、最新の更新したデータが先頭に表示できます。fetchallメソッドは cursor の全てのデータをリスト形式で取得します。要素はタプル形式になっています。
リスト内表記 [value[0] for value in self.cursor.fetchall()]によって、ワールド名のリストが得られます。

追記

def add_list_items(self):
    self.load_list.removeAndDestroyAllItems()

    world_names = self.get_world_names()
    for name in world_names:
        list_item = DrawMappedButton(
            parent=None,
            model=self.button_model,
            text=name,
            font=self.font,
            pos=(0, 0, -0.75),
            command=self.load_world,
            extra_args=[name]
        )
        self.load_list.addItem(list_item)

add_list_itemsメソッドは、ロード画面で表示するリストを取得できます。
removeAndDestroyAllItemsメソッドで、リストを空にしてから、addItmeメソッドでリストの要素を加えていきます。リストの要素は DrawMappedButtonクラスで作成するボタンです。引数parent はNone を指定してください。

修正

def toggle_load(self):
    if self.load_node.isStashed():
        self.menu_node.stash()
        self.load_node.unstash()
        self.load_notification_text.setText('')
        self.add_list_items()  # 追記
    else:
        self.menu_node.unstash()
        self.load_node.stash()

toggle_loadメソッドを修正します。add_list_itemsメソッドを実行して、保存したワールド名のリストを表示します。

修正

def load_world(self, world_name):
    # ロード処理
    # # ブロックを全て削除
    self.block_node.removeNode()

    # ブロックを復元
    self.block_node = self.render.attachNewNode(PandaNode('block_node'))
    world_id = self.get_world_id_from_name(world_name)
    self.cursor.execute('SELECT * FROM blocks WHERE world_id = ?', (world_id,))
    recorded_blocks = self.cursor.fetchall()
    for block in recorded_blocks:
        _, x, y, z, block_id, _ = block
        self.block.add_block(x, y, z, block_id)

    # プレイヤーを更新
    self.cursor.execute(
        'SELECT x, y, z, direction_x, direction_y, direction_z FROM characters WHERE world_id = ? AND character_type = ?',
        (world_id, 'player')
    )
    x, y, z, direction_x, direction_y, direction_z = self.cursor.fetchall()[0]
    self.player.position = Point3(x, y, z)
    self.player.direction = Vec3(direction_x, direction_y, direction_z)

load_worldメソッドは保存したワールドを復元します。
ブロックの復元は、removeNodeメソッドで block_nodeノードを完全に削除してから、self.render.attachNewNode(PandaNode('block_node'))により block_nodeノードを作り直します。
blocksテーブルから world_id が 1 のデータを読み込んで、self.block.add_blockメソッドでブロックを設置し直します。
プレイヤーの復元は、charactersテーブルから、world_id = 1 で character_type = 'player' のレコードを読み込んで、self.player.position、self.player.direction を更新します。

"""main.py"""
from math import *
from src import MC


class Game(MC):
    def __init__(self):
        # MCを継承する
        MC.__init__(self, ground_size=32)


game = Game()
game.run()

main.py を新規作成し、ゲームを実行するコードを記述します。実行すると、地面だけのワールドが生成されます。
「q」キーからメニュー画面に入り、「ゲームをロード」ボタンをクリックします。

ロード画面

ロード画面で、先ほど保存した「My World」を選んでクリックすると、ゴールドブロックの壁があるワールドが再生成されます。プレイヤーの位置と向きも復元されることを確認してください。
これで今回のミッションは達成されました。

今回はデータベースを使ったワールドデータの保存について見てきました。
最近のプログラミングでは、素の SQL文はあまり使われることはなく、Ruby on Rails の Active Record に代表されるメソッドでデータベースを操作する方法が一般的です。しかし SQL文を理解しておくと、Active Record が何をやっているのかをより深く理解できます。
次回はジャンプを実装します。プレイヤーが大地を離れ、飛び立つ時がきました。お楽しみに。


前の記事
Pythonでマイクラを作る ⑬メニュー画面を作成する
次の記事
Pythonでマイクラを作る ⑮物理シミュレーションの実装

その他のタイトルはこちら


この記事が気に入ったらサポートをしてみませんか?