Pythonでマイクラを作る ⑭ゲームをセーブする
Pythonでマイクラを作る 第14回目です。今回はセーブ機能を追加します。
せっかく作った建築がゲーム終了で消えてしまうと悲しいです。セーブ機能があれば、セーブデータからワールドをロードして再現できるようになり、建築の続きができるようになります。これで建築が捗りますね。
セーブ方法を考える
ワールドをセーブするにはいくつかの方法が考えられます。
最も簡単なのは、テキストファイルでブロック情報を保存する方法です。ブロック情報は self.block.block_dictionary に辞書として保存されているので、それをそのままテキストファイルに書き込むだけで完了です。
しかしこの方法は欠点があります。
ブロック情報以外のデータは保存できない
複数のワールドを保存すると、テキストファイルが複数になってしまう
データの挿入、更新、削除、検索などの操作が簡単にできない
これらの欠点を解消する方法として、データベースを使う方法があります。Python で標準で使用できる軽量データベース SQLite3 を使ったセーブを実装していくことにします。
SQLite3
SQLite3 はオープンソースで軽量な RDBMS(リレーショナルデータベース管理システム)のことです。データベースサーバーを用意する必要がなく、ファイル形式でアプリ・ゲームに組み込むことができます。データベースを操作する言語である、SQL を使ってデータベースを操作できます。
次にデータベース操作言語 SQL の基礎を学びます。
SQL の基礎
ターミナル(PowerShell)を使って、SQL を使ってみます。まずデータベースを保存する saves ディレクトリを作成してます。
データベースを新規に作成します。データベース名は sample.sqlite3 にしました。同じ名前のデータベースがあるときは、指定したデータベースが開きます。
sqlite > の表示はデータベースの中に入って、コマンドを実行できることを表しています。
データベースの中にテーブルを作ります。テーブルはデータを格納する領域のことで、データの種類によって複数持つことができます。テーブルは表形式のデータで、縦の列(カラム)、横の行(レコード)から作られます。
テーブル名 worlds を作成します。カラムは、id、name、ground_size、game_mode の4つを指定し、それぞれデータ形式 INTEGER(整数)、TEXT(文字列)を指定します。
テーブルが作成されたか確認しましょう。.table で作成済みの全てのテーブルを確認できます。
データの挿入は INSERT INTO で行います。values に保存する値を記述します。全てのカラムに値を入れるときは、worlds の後ろの括弧は省略できます。
データの表示は SELECT FROM で行います。*(アスタリスク)は全てのカラムを指定することを意味します。* の代わりにカラム名を指定すると、そのカラムだけ表示できます。
実行すると、2件のデータが保存されていることが確認できました。
データの更新は UPDATE SET で行います。更新するカラム = データ値の形で指定します。WHERE 節で更新するデータの条件を指定してやります。
データの削除は DELETE FROM で行います。WHERE 節で削除するデータの条件を指定してやります。
SQL言語は大変奥が深く、しっかり理解するのは1冊の本が必要になります。今回は必要最低限の基礎だけ説明しました。
読者の皆さんは、3件目、4件目、5件目… のデータを挿入して、更新、削除を試してみてください。SQL の感覚が掴むことができます。
データベースを閉じるには .exit を実行します
データベースマネージャー
データベースを管理するソフトを導入します。
Windows、Mac の両方で使えて、SQLite 以外にもたくさんのデータベースに対応している。TablePlus を紹介します。タブを2つ以上開けられないなど制限はありますが、無料で問題なく使えます。
公式サイトから、ソフトをダウンロード、インストールをしてください。
データベースに接続する方法を説明します。
上図の右のエリアで右クリック → New → Connect を選択します。
対応しているデータベースの一覧が表示されます。SQLIte を選んで Createボタンをクリックします。
name欄にデータベースの管理名を入力します。Select fileボタンから先ほど作成した sample.sqlite3 を選びます。Connectボタンでデータベースに接続できます。
先ほど作成した worldsテーブルのデータが確認できました。SLECT FROM からデータを確認することができますが、件数が増えてくるとコマンドラインからは操作しずらくなるので、データベースマネージャーをうまく利用していくとよいです。
データの挿入、更新、削除などの操作もソフト上で行えます。データの変更後に、Command + S(Ctrl + S)で保存してください。
データベースの設計
データベースの設計では、複数のテーブルを連携(リレーション)させて、管理しやすいデータベースを作成することを目指します。今回保存したいデータは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) '
'values(?, ?, ?, ? ,?, ?, ?)',
(character_type, x, y, z, direction_x, direction_y, direction_z)
)
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」キーを押してメニュー画面に入ります。「ゲームをセーブ」ボタンからセーブ画面に入ります。ワールドの名前を決めて、「セーブする」ボタンを押して、セーブを実行します。
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でマイクラを作る ⑮物理シミュレーションの実装
その他のタイトルはこちら
この記事が気に入ったらサポートをしてみませんか?