Hello World / plɹoM ollǝH

Programmers Live in Vain

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_())

Raspberry Piでトイレにクラシック音楽を流す

人がきたら音楽を流す装置をトイレに設置しました(割と好評)

  • ケースはRaspberry Piの箱に100均で買った折り紙を貼って作成
  • 本体のUSBでBluetoothスピーカーを充電

f:id:dungeonneko:20170407163311j:plain

必要なもの

Raspberry Piと人感センサーの接続

下記ページを確認しながらRaspberry Piと人感センサーを接続

対応するピン同士をジャンパーワイヤで繋ぐ

人感センサー Raspberry Pi
+Power <---> 5V PWR
GND <---> GND
High/Low Output <---> GPIO18

人感センサーで音を鳴らすプログラムの作成

  • python
  • mp3を再生したかったのでpygameモジュール
  • スクリプトと同じフォルダにあるmp3をランダムで再生
     
import pygame, os, random, time, RPi.GPIO

pygame.init()
pygame.mixer.pre_init(44100, 16, 2, 1024 * 4)
pygame.mixer.init()
pygame.mixer.set_num_channels(8)
screen = pygame.display.set_mode((320, 240))
shutdown = False
clock = pygame.time.Clock()
files = []
active = False
last_activated = 0
pin = 18
RPi.GPIO.setmode(RPi.GPIO.BCM)
RPi.GPIO.setup(pin, RPi.GPIO.IN)

# 入力を検知したあとに何秒間流すか
timeout = 30

# 音楽の再生
def play_random_track():
    global files
    # 再生リストがなくなったらmp3ファイルを検索してシャッフル
    if not files:
        files = [f for f in os.listdir('./') if f.endswith('.mp3')]
        random.shuffle(files)
    # 再生リストから一番最初の音楽をとってきて鳴らす
    pygame.mixer.music.load(files.pop(0))
    pygame.mixer.music.set_volume(1.0)
    pygame.mixer.music.play()

# メインループ
while not shutdown:
    # 入力フラグ
    active = False

    # OSイベント処理
    for e in pygame.event.get():
        # 閉じるボタンやESCキーが入力されたらプログラムを終了する
        if e.type == pygame.QUIT or (e.type == pygame.KEYDOWN and e.key == 27):
            shutdown = True
            break
        # テスト用:Enterキーで音を鳴らせるようにしておく
        elif e.type == pygame.KEYDOWN and e.key == 13:
            active = True
            continue

    # 人感センサーからの入力
    if RPi.GPIO.input(pin) == RPi.GPIO.HIGH:
        active = True

    # 入力があったときの処理
    if active:
        # 入力時間を更新する
        last_activated = time.time()
        # 再生してないときは次のトラックを再生
        if not pygame.mixer.music.get_busy():
            play_random_track()

    # 最後の入力から一定時間(timeout秒)が経過したら音楽をフェードアウトさせる
    if (time.time() - last_activated) > timeout:
        if pygame.mixer.music.get_busy():
            pygame.mixer.music.fadeout(5000)

    pygame.display.flip()
    clock.tick(5)  # 処理負荷を抑える(1秒間に5回ループをまわす)

RPi.GPIO.cleanup()
pygame.quit()

電源入れたら自動でプログラムが動くようにする

以前の記事と同じ手順で起動時にBluetoothスピーカー接続&今回用意したスクリプトを起動するように修正

pulseaudio -D
sleep 5  # 適当にウェイト
bluetoothctl << EOF
power on
connect FF:FF:FF:FF:FF:FF  # BluetoothスピーカーのMACアドレス
quit
EOF
sleep 5  # 適当にウェイト
pacmd set-sink-volume 1 32767  # 音量調節(デバイス番号、音量~65565)

cd otohime
python3.4 otohime.py