Hello World / plɹoM ollǝH

Programmers Live in Vain

Pythonゲームプログラミング #1 メインループ

gist消してしまったので
代わりにこのリポジトリを参考にしてください

#0 環境構築
#1 メインループ
#2 画像表示
#3 アニメーション
#4 キー入力
#5 サウンド
#6 衝突判定
#7 ステートマシン
#8 マップチップ
#9 スクロール

メインループ

pygameを使ったゲームプログラミングについて説明していきます。

  • Windows10
  • Python3.6.1
  • pygame1.9.3

今回はメインループを実装してみます。英語が苦でない方はpygame公式ドキュメントのTutorialを読んだほうが良いでしょう。

リアルタイムゲームのプログラミング

一般的なGUIアプリケーションはイベント駆動型。つまりボタン等をクリックしたときに何らかの処理がはじめて実行されるというものがほとんどです。

f:id:dungeonneko:20170510171330p:plain:w240

これに対してアクションゲームなどでは、プレイヤーが何も操作しなくてもゲーム全体の状況はどんどん変化します。

f:id:dungeonneko:20170510171342p:plain:w240

アニメーションは異なる絵をパラパラ漫画のように切り替えてものを動かしたりしますが、同じようにゲームでも、キャラクターの移動処理などを何度も繰り返しながらゲームの状況を変化させていきます。

f:id:dungeonneko:20170510171405p:plain

この1コマ1コマのことを「フレーム」と呼び、メインループとはフレームの処理を繰り返すためのループになります。

PyCharmでプロジェクトを作る

前回インストールしたPyCharmを起動します

初回起動時に出てくるウインドウの「Create New Project」ボタンを押すか

f:id:dungeonneko:20170509223327p:plain

メニューの「File -> New Project」からプロジェクトが作成できます

f:id:dungeonneko:20170509223415p:plain

とりあえず「c:\pygame」フォルダ内に「mygame」プロジェクトを作成します

f:id:dungeonneko:20170509223503p:plain

プロジェクトパネルのmygameを右クリックしてソースコードを追加します

f:id:dungeonneko:20170509223756p:plain

f:id:dungeonneko:20170509223820p:plain

これでプログラムを書く準備ができました

プログラムを書いて実行してみる

作成したmain.pyに次のプログラムを書いて実行してみます

書き終わったら「Run -> Debug...」でプログラムをデバッグ実行します

f:id:dungeonneko:20170509225543p:plain

実行するソースコードを聞かれるのでmain.pyを選択します

f:id:dungeonneko:20170509225621p:plain

青い画面のウインドウが表示されればOKです

f:id:dungeonneko:20170509230023p:plain

その他の処理について補足

pygameの細かい仕様等については公式ドキュメント見てください。

フレームレート

フレームレートとはフレーム処理が1秒間に何回実行されるのかをあらわします。

f:id:dungeonneko:20170510173940p:plain

ゲームスピードが固定されていないとキャラクターの移動速度などが変化してしまうなどの不都合がおこります。これを回避するためにフレームレートを一定に保ったり、キャラクターの移動速度や加速度をフレームレートに応じて変化させるといった処理が必要になります。

clock.tick(60)

本プログラムでは、Clockクラスを使用してフレームレートを固定しています。tick(60)は「前回の呼び出しから1/60秒経過するまで待つ」という意図になります。

イベント処理

アプリケーションは基本的にOS上で動作していて、OSからの命令(ウインドウの処理等)もアプリケーション側で正しく処理する必要があります。

f:id:dungeonneko:20170510174157p:plain:w320

for event in pygame.event.get():
    if event.type == pygame.QUIT:
        end_game = True

アプリケーションの終了命令がきたらゲーム終了フラグをTrueにしています。このイベント処理をしないと×ボタン等に反応しなくなります。

バッファフリップ

ダブルバッファリングでは2つの画面を切り替えて処理することで、片方をウインドウに表示している間、プログラムは表示されていないほうの画面に描画処理をおこなうという処理を実現しています。

f:id:dungeonneko:20170510174252p:plain

pygame.display.flip()

ダブルバッファリングが有効になっている場合、ウインドウに表示するバッファを切り替える処理を呼び出す必要があります。
pygameではデフォルトでダブルバッファリングが有効になっているようです。

Pythonゲームプログラミング #0 環境構築

#0 環境構築
#1 メインループ
#2 画像表示
#3 アニメーション
#4 キー入力
#5 サウンド
#6 衝突判定
#7 ステートマシン
#8 マップチップ
#9 スクロール

環境構築

pygameを使ったゲームプログラミングについて説明していきます。

  • Windows10
  • Python3.6.1
  • pygame1.9.3

python3をインストール

まずはPythonの公式サイトからpythonインストーラをダウンロードします。画像のように Downloads -> Python 3.x.x を選択します。

f:id:dungeonneko:20170509011739p:plain

ダウンロードが完了したらインストーラを実行し「Add Python 3.x to PATH」にチェックをいれて「Install Now」を押してください。画像は64bit版になっていますが32bit版でもたぶん大丈夫です。

f:id:dungeonneko:20170508235308p:plain

インストールが終わったらpythonが正しくインストールできたか確認します。「Winキー + R」で「ファイル名を指定して実行」ウインドウを表示し、「cmd」と入力してEnterキーを押します。

f:id:dungeonneko:20170509000721p:plain

コマンドプロンプトが起動するので「python -V」と打ちEnterキーを押します。Vは大文字です。インストールしたバージョンが表示されれば正常にインストールされています。

f:id:dungeonneko:20170509000951p:plain

pygameをインストール

コマンドプロンプトに「pip install pygame」と打ちEnterキーを押します。

f:id:dungeonneko:20170509001129p:plain

pygameのインストールが終わったら、もうコマンドプロンプトは使いませんので、「exit」と入力して終了します。

PyCharmをインストール

IDEは何でもいいのですがPyCharmがおすすめです。PyCharmの公式サイトからCommunity版をダウンロードします。

f:id:dungeonneko:20170509001907p:plain

インストーラの指示に従ってインストールを完了させます。

f:id:dungeonneko:20170509011943p:plain

インストールが正常に完了していれば「Winキー + S」の検索メニューで「pycharm」と入力すれば、プログラムが検索できるはずです。

f:id:dungeonneko:20170509014255p:plain:w320

PySide: Asynchronously Text Filtering

QThreadを使った非同期テキストフィルタ

ほしいもの
  • 別スレッドで実行されるテキストフィルタ
  • 一定時間ごとにフィルタリング済みのデータを返す
  • 途中でキャンセルもしたい
コード
from PySide import QtCore, QtGui
import datetime
import time

class FilteringThread(QtCore.QThread):
    completed = QtCore.Signal()

    def __init__(self):
        super().__init__()
        self._data = []  # フィルタリングする前のデータリスト
        self._filter = ''  # 部分一致キーワード
        self._queue = []  # フィルタ済みのデータ
        self._lastFlushed = datetime.datetime.now()  # 前回データを流した時間
        self._callback = None  # フィルタ済みデータを受け取る関数
        self._complete = True  # 全体の処理が完了したか判定する
        self._canceled = False  # 途中キャンセルがリクエストされたか

    def setFilter(self, text):
        # フィルタの設定
        self._filter = text

    def setData(self, data):
        # データの設定
        self._data = data

    def setCallback(self, func):
        # データ受取関数の設定
        self._callback = func

    def cancel(self):
        # キャンセルのリクエスト
        self._canceled = True

    def isCompleted(self):
        # 終了判定
        # isFinishedがいい感じで動かない
        return self._complete

    def run(self):
        # スレッドから実行される関数

        self._complete = False
        self._canceled = False
        if self._filter:
            for d in self._data:
                if self._canceled:
                    break

                time.sleep(0.001)  # フィルタリングに時間がかかってるふり

                if self._filter in d:
                    # フィルタを追加したらqueueにいれる
                    self._queue.append(d)

                    # 一定時間経過してたら流す
                    now = datetime.datetime.now()
                    if (now - self._lastFlushed).total_seconds() >= 1.0:
                        self._callback(self._queue)
                        self._queue.clear()
                        self._lastFlushed = now

        # 最後に残ったデータを流して終了
        self._callback(self._queue)
        self._queue.clear()
        self.completed.emit()
        self._complete = True
        self.exit()

# 実験用データ
DATA = [str(i) for i in range(0, 1000)]

# 必要なインスタンスを作る
app = QtGui.QApplication([])
listview = QtGui.QListWidget()
filtertext = QtGui.QLineEdit()
thread = FilteringThread()
message = QtGui.QLabel('')

# フィルタ変更時の処理
def textChanged(text):
    while not thread.isCompleted():
        thread.cancel()
        time.sleep(0.1)
    listview.clear()
    if text == '':
        return
    thread.setFilter(text)
    thread.start()
    message.setText('')

# フィルタ項目取得時
def flushed(queue):
    for i in queue:
        listview.addItem(QtGui.QListWidgetItem(i))

# 開始時
def started():
    message.setText('filteringなう...')

# 終了時
def completed():
    message.setText('{0} matching records'.format(listview.count()))

# 設定
listview.setUniformItemSizes(True)  # ちょっぴり速くなるよ
listview.setSortingEnabled(False)  # もしソートするならflushedで一回だけやる
filtertext.textChanged.connect(textChanged)
thread.setData(DATA)
thread.setCallback(flushed)
thread.started.connect(started)
thread.completed.connect(completed)

# 実行
widget = QtGui.QWidget()
widget.setLayout(QtGui.QVBoxLayout())
layout = widget.layout()
layout.addWidget(filtertext)
layout.addWidget(listview)
layout.addWidget(message)
widget.show()
app.exec_()

表示するデータがあまりに多くなると今度はListWidgetのclearやらpaintやらで時間がかかってしまうようになるっぽい。これはどうしたものか…

PySide: Save and Restore QDockWidgets

Dockの生成削除込みでQSettings保存

適当に調べた
  • QSettingsを使用してWidgetの位置やサイズを保存できる
  • 復元する前にWidgetを生成してobjectNameを設定しておく必要がある
  • DockWidgetを追加・削除できるプログラムではその状況も保存する必要がある
  • QMainWindow::restoreDockWidget関数はコレジャナイ感
  • あっ、もしかしてDockWidgetの復元は自分でやらないといけない?
必要な処理
  1. 初期化時に設定ファイルがあったら前回の内容を復元

    1. 前回生成されていたDockWidgetの復元
    2. restoreGeometry
    3. restoreState
  2. 終了時に設定ファイルを保存

    1. 全部のDockWidgetの型とobjectName(とりあえず毎回上書き)を保存
    2. saveGeometry
    3. saveState
コードこんな感じ
class MainWindow(QtGui.QMainWindow):
    def __init__(self):
        # 復元
        if os.path.exists(path_to_ini):
            s = QtCore.QSettings(path_to_ini, QtCore.QSettings.IniFormat)
            self._restoreDockWidgets(s.value('dockwidgets'))
            self.restoreGeometry(s.value('geometry'))
            self.restoreState(s.value('state'))

    def closeEvent(self, event):
        # 保存
        s = QtCore.QSettings(path_to_ini, QtCore.QSettings.IniFormat)
        s.setValue('dockwidgets', self._saveDockWidgets())
        s.setValue('geometry', self.saveGeometry())
        s.setValue('state', self.saveState())

        super().closeEvent(event)

    def createDockWidget(self, widget_type) -> QtGui.QDockWidget:
        # 渡されたWidgetをDockにいれて返す
        dock = QtGui.QDockWidget()
        self.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dock)
        dock.setWidget(widget_type())
        dock.setWindowTitle(dock.widget().windowTitle())
        dock.setFloating(True)
        return dock

    def _saveDockWidgets(self):
        # DockWidgetにユニークな名前を付けて型名と一緒に保存
        # 実際に保存する型名はDockWidgetに設定されたWidgetのもの
        a = [x for x in self.children() if isinstance(x, QtGui.QDockWidget)]
        for x in a:
            x.setObjectName(str(uuid.uuid1()))
        # 保存されるデータは "型名:名前,型名:名前..." になる
        return ','.join(
            [type(x.widget()).__name__ + ':' + x.objectName() for x in a])

    def _restoreDockWidgets(self, value):
        # 設定ファイルから型と名前を取得してDockWidgetを復元
        # ここではwidgetsモジュール内にDock化される型をまとめている前提
        for t, name in [x.split(':') for x in value.split(',')]:
            if hasattr(widgets, t):
                d = self.createDockWidget(getattr(widgets, t))
                d.setObjectName(name)

他に良い方法知ってたら教えてください

PySide: Suppress the clicked signal when emitting a contextMenuRequested signal

右クリックでコンテキストメニューを出すときに一緒にclickedシグナルが発信されると困るケースがあった。mousePressEventとmouseReleaseEventを継承して左クリックのみに反応するようにしてこれを回避した

from PySide import QtGui, QtCore
import sys


class CustomListWidget(QtGui.QListWidget):
    def __init__(self):
        super().__init__()
        self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
        self.clicked.connect(lambda: print('clicked'))
        self.customContextMenuRequested.connect(self._showContextMenu)
        self.addItem(QtGui.QListWidgetItem('hoge'))
        self.addItem(QtGui.QListWidgetItem('fuga'))
        self.addItem(QtGui.QListWidgetItem('piyo'))

    def mousePressEvent(self, event):
        # 左クリックのときだけ反応させる
        if event.button() == QtCore.Qt.LeftButton:
            super().mousePressEvent(event)

    def mouseReleaseEvent(self, event):
        # 選択状態のアイテムにはReleaseEventのフックも必要
        # 左クリックのときだけ反応させる
        if event.button() == QtCore.Qt.LeftButton:
            super().mouseReleaseEvent(event)

    def _showContextMenu(self, pos):
        item = self.itemAt(pos)
        if not item:
            return
        menu = QtGui.QMenu()
        menu.addAction(QtGui.QAction(item.text(), self))
        menu.exec_(self.mapToGlobal(pos))

app = QtGui.QApplication(sys.argv)
w = CustomListWidget()
w.show()
sys.exit(app.exec_())

PySide リソース一覧の取得

qrcファイルの中身がこんな感じだったら

<!DOCTYPE RCC><RCC version="1.0">
<qresource>
    <file>folder/hogehoge.txt</file>
    <file>folder/fugafuga.bin</file>
    <file>folder/piyopiyo.jpg</file>
</qresource>
</RCC>

こんな感じでpyに変換すると思うのですが

pyside-rcc -o resource.py -py3 resource.qrc

こんな感じで一覧を取得できます

from PySide import QtCore
import resource

for x in QtCore.QDir(':folder').entryList():
    print(x)

実行結果

fugafuga.bin
hogehoge.txt
piyopiyo.jpg

アイコンとかまとめて作るときはこんな感じで

icons = {
    os.path.splitext(x)[0]: QtGui.QIcon(':/icon/{0}'.format(x))
    for x in QtCore.QDir(':icon').entryList()
}

QTreeWidgetItemでアイコン複数表示

HTMLを表示するdelegateを用意するのが手っ取り早い

f:id:dungeonneko:20170411134501p:plain

コードはここらへんを参考に

stackoverflow.com

PyQt版の回答をゴニョゴニョして作成

import sys
from PySide import QtCore, QtGui


class RichTextDelegate(QtGui.QStyledItemDelegate):
    def paint(self, painter, option, index):
        option_v4 = QtGui.QStyleOptionViewItemV4(option)
        self.initStyleOption(option_v4, index)
        doc = self.make_text(option_v4.text)

        option_v4.text = ""
        style = QtGui.QApplication.style()
        style.drawControl(QtGui.QStyle.CE_ItemViewItem, option_v4, painter)

        painter.save()
        rect = style.subElementRect(QtGui.QStyle.SE_ItemViewItemText, option_v4)
        painter.translate(rect.topLeft())
        painter.setClipRect(rect.translated(-rect.topLeft()))
        context = QtGui.QAbstractTextDocumentLayout.PaintContext()
        if option_v4.state & QtGui.QStyle.State_Selected:
            context.palette.setColor(
                QtGui.QPalette.Text, option.palette.color(QtGui.QPalette.Active, QtGui.QPalette.HighlightedText))
        doc.documentLayout().draw(painter, context)
        painter.restore()

    def sizeHint(self, option, index):
        option_v4 = QtGui.QStyleOptionViewItemV4(option)
        self.initStyleOption(option_v4, index)
        doc = self.make_text(option_v4.text)
        return QtCore.QSize(doc.idealWidth(), doc.size().height())

    def make_text(self, text):
        doc = QtGui.QTextDocument()
        doc.setDocumentMargin(1)
        f = QtGui.QTreeWidgetItem().font(0)
        f.setPointSize(11)
        doc.setDefaultFont(f)
        doc.setHtml(text)
        return doc


app = QtGui.QApplication(sys.argv)
tree = QtGui.QTreeWidget()
tree.setHeaderHidden(True)
tree.setItemDelegate(RichTextDelegate())
hoge = QtGui.QTreeWidgetItem()
hoge.setText(0, '<i>hoge</i> <img src="hoge.png"/><img src="hoge.png"/>')
fuga = QtGui.QTreeWidgetItem()
fuga.setText(0, '<b>fuga</b> <img src="hoge.png"/><img src="hoge.png"/><img src="hoge.png"/>')
piyo = QtGui.QTreeWidgetItem()
piyo.setText(0, '<font color="red">piyo</font>')
hoge.addChild(fuga)
hoge.addChild(piyo)
tree.addTopLevelItem(hoge)
tree.show()
sys.exit(app.exec_())