【未完成】ジェスチャ入力 - Kivy Advent Calendar 2013

(※編集中です...)
Kivyはジェスチャ認識のライブラリも持っています。
サンプルコード (https://github.com/kivy/kivy/tree/master/examples/gestures) もあり簡単なテストができますが、これは4種類の一筆書きシンボル(check、circle、square、cross)を固定で持って識別するだけです。そこで

  • 自分でパターンを登録して識別できるか
  • Palmの一筆書き入力 (Grafiti) 並に数十個のパターンを入れても実用的な時間で識別できるか

という点を実験してみたいと思います。
 

2013-12-26 追記

ごめんなさい。この記事もギブアップです。休み中にゆっくりやります。
(kivy.storageを使おうと思ったら、Kivy Launcher 1.7.2で実装されてなかった...)
 
一旦クローズさせてください。ひとまずKivy Launcherと戯れてみて思ったことを。

Kivy 1.8はもうちょい

ラッキング追ってますが、大量に通知メールが来ていてKivyチームがバグをぷちぷち直しているところみたいです。なんか今週中にとか言ってたような気もしますが、今年中も無理じゃないのって勢い...orz
(まあバグよりはPython3対応の方に力を入れているみたいですが...)

pyjnius使わなくなるかも

Advent Calendarでpyjniusネタいろいろやったのですが、全部無駄になるかもしれません。
というのも写真、加速度センサ、GPSなど、機種依存なものは「plyer」というモジュールでまとめてまして、これを使えば少し楽に書けるようになりそうです。
https://plyer.readthedocs.org/en/latest/
ただ、今のところiOS (pyobjc) の対応が含まれてないので、Kivy-iOSの人に頑張ってもらうことになりそう...

今後tryしたいところ

自分も十分理解していないので、やろうと思っていても出来なかったネタも多かった一方、ドキュメントで動くといっておきながら動かないものが多々あって結構ハマりました。

  • kivy.garden
    • 拡張ウィジェットを集めてGitHubから直接インストールできるようにするKivy専用のインストーラ。スロット風のセレクタ(日付選択)、画面転換の拡張版、画像をサムネイルで表示するファイルセレクタなど、面白いものもたくさんあるのですが、そもそもインストーラAndroidでは動かないので、2013-12-03 QRコードみたいに、モジュールを同じフォルダ内に置いてimportするなどの細工が必要になります...
  • kivy.uix.camera
    • カメラからの動画をリアルタイムで画面上に写すものですが、少なくともNexus7-2013では動きません(作者自身もgstreamer使わないと無理とか言ってますし)。
  • kivy.storage
    • key-valueストア。1.7.0で入ったと書いてあり、2013-12-25 ジェスチャの記録に使おうと思ったら、影も形もありませんでした...
SL4Aは?

言及はしましたがリリースが停滞しているので使うのはどうなんだろうって感じ。
完全停止しているわけではなくバグ直しは引き続き行われていますが、リリースは難しいのかなって状況です。

まとめ

いろいろ問題はありますけど、Kivyについてはもう少し追っかけてみようかと思います。
まあ自分は仕事的にはWeb方面の人だし、HTML/CSS/MetaJSなんかでも最近はマルチタッチやフレームバッファが使えていろいろ出来ることは分かってはいるのですが、手馴れたPythonで書けるのがやはり嬉しいんですよね。

cymunkを使って雪を降らせる - Kivy Advent Calendar 2013


cymunkは2次元物理エンジンChipMunk (http://chipmunk-physics.net/) のCython実装で、KivyLauncerにもデフォルトで入っています。
https://cymunk.readthedocs.org/en/latest/
オブジェクトの重力、衝突、弾性の計算処理をやってくれるもので、cymunkのサンプルコードではカラフルなボールを弾ませています。
このサンプルコードを

  • カラーボールではなく小さい白い結晶にする
  • タッチしなくてもランダムで発生させる
  • 重力を弱くして、左右に揺れるようにする
  • 弾性はほぼゼロにする
  • 地面等に着いたら跡は残しつつ計算対象から外す

とすれば、雪が降り積もってるようにみえないかなあ…と思ったので実験してみました。

結果は…
なんか「地面等に着いたら」の処理をきちんとやっていないので、地面を雪玉が転がってたりして。
あまり雪っぽくないかな。



(/sdcard/kivy/letitsnow/)

android.txt お約束
flake.png 雪の結晶みたいな画像 (なければcircle.pngをリネームして)
main.py スクリプト本体

(android.txt)

title=letitsnow
author=cheeseshop
orientation=landscape

(main.py)

from kivy.clock import Clock
from kivy.app import App
from kivy.graphics import Color, Rectangle
from kivy.uix.widget import Widget
from kivy.properties import DictProperty, ListProperty
from kivy.core.image import Image
from cymunk import Space, Segment, Vec2d, Body, Circle
from random import random
from kivy.lang import Builder
from os.path import dirname, join

class Snow(Widget):

    cbounds = ListProperty()
    cmap = DictProperty({})
    flist = ListProperty()

    def __init__(self, **kwargs):
        super(Snow, self).__init__(**kwargs)
        self.init_space()
        self.bind(size=self.update_bounds, pos=self.update_bounds)
        self.texture = Image(join(dirname(__file__), 'flake.png')).texture
        Clock.schedule_interval(self.step, 1 / 30.)

    def init_space(self):
        self.space = space = Space()
        space.iterations = 30
        space.gravity = (0, -300)
        space.sleep_time_threshold = 0.5
        space.collision_slop = 0.5

        for x in xrange(4):
            seg = Segment(space.static_body,
                          Vec2d(0, 0), Vec2d(0, 0), 0)
            seg.elasticity = 0.0
            self.cbounds.append(seg)
            space.add_static(seg)
        self.update_bounds()

    def update_bounds(self, *largs):
        a, b, c, d = self.cbounds
        x0, y0 = self.pos
        x1 = self.right
        y1 = self.top

        self.space.remove_static(a)
        self.space.remove_static(b)
        self.space.remove_static(c)
        self.space.remove_static(d)
        a.a = (x0, y0); a.b = (x1, y0)
        b.a = (x1, y0); b.b = (x1, y1)
        c.a = (x1, y1); c.b = (x0, y1)
        d.a = (x0, y1); d.b = (x0, y0)
        self.space.add_static(a)
        self.space.add_static(b)
        self.space.add_static(c)
        self.space.add_static(d)

    def step(self, dt):
        if random() < 0.4:
            self.add_flake(random() * self.right, self.top - 20, 5)
        if random() < 0.2:
            self.space.gravity = (100-random()*200, -50*random()-50)
        self.space.step(1 / 30.)
        self.update_objects()

    def update_objects(self):
        for body, obj in self.cmap.iteritems():
            p = body.position
            radius, color, rect = obj
            rect.pos = p.x - radius, p.y - radius
            rect.size = radius * 2, radius * 2

    def add_flake(self, x, y, radius):
        body = Body(100, 1e9)
        body.position = x, y
        circle = Circle(body, radius)
        circle.elasticity = 0.1
        self.space.add(body, circle)

        with self.canvas.before:
            color = Color(1, 1, 1)
            rect = Rectangle(
                texture=self.texture,
                pos=(self.x - radius, self.y - radius),
                size=(radius * 2, radius * 2))
        self.cmap[body] = (radius, color, rect)

        self.flist.append((body, circle) )
        if len(self.flist) > 300:
            body, circle = self.flist.pop(0)
            self.space.remove(body)
            self.space.remove(circle)

class SnowApp(App):
    def build(self):
        return Snow()

if __name__ == '__main__':
    SnowApp().run()

スクリーンショットを撮る/タイマーを使う

端末のスクリーンショットを撮る機能、Nexus7-2013では[電源]+[音量小]を同時に0.3秒くらい押すと撮れますが、まあこれがやりにくい。失敗してボリューム表示まで撮れてしまったり…
Kivy自体にもスクリーンショットを取る機能があって、「from kivy.modules import keybinding」とインポートすると「F12」キーを押して撮れます。でもいつもキー入力できるとは限らないし、「ボタンを押したら撮る」だとボタンそのものが写ってしまいます。
ここでは「2013-12-09 画面転換」でスクリーンショットを撮るため、アプリ自体にセルフタイマーつきのスクリーンショット機能を追加してみます。

解説

タイマーについて

ワンショットタイマー (schedule_once) をkv言語側で使ってみました。Clockクラスはkv言語の組み込みではないのでimportが必要ですが、メソッドが増えるよりはいいかなと…
注意点としてはタイマーのコールバックは1つ引数を取るってところでしょうか。dtはシステム時間っぽいです。まあ全然使わないけど引数の数を合わせないといけないので…

スクリーンショット

特にファイル名を指定しなければ「scheenshot0001.png」という形式で、main.pyと同じフォルダに保存してくれます。ただ保存に時間がかかるので、画面が一瞬固まるのが分かると思います。

Home画面とSetting画面。Settingの下から2番目が「5秒後にスクリーンショットを撮る」ボタン。


ワイプとフェード。


スライド(垂直方向と水平方向)。



(/sdcard/kivy/capture/)

android.txt お約束
main.py 5行追加しただけ ほとんど使い回し

(android.txt)

title=capture
author=cheeseshop
orientation=portrait

(main.py)

from kivy.app import App
from kivy.properties import StringProperty
from kivy.uix.screenmanager import ScreenManager, Screen
from kivy.uix.screenmanager import ShaderTransition, SlideTransition, FadeTransition, WipeTransition
from kivy.core.window import Window

FADEINOUT_TRANSITION_FS = '''$HEADER$
uniform float t;
uniform sampler2D tex_in;
uniform sampler2D tex_out;

void main(void) {
float t1 = 0.45;
float t2 = 0.55;
vec4 cin = vec4(texture2D(tex_in, tex_coord0.st));
vec4 cout = vec4(texture2D(tex_out, tex_coord0.st));
vec4 frag_col = t < t1 ? vec4*1
            on_text: app.settr(self.text)
        Button:
            pos_hint: {'center_x':0.5, 'top':0.4}
            size_hint: (None, None)
            size: (200, 50)
            text: 'Capture after 5sec'
            on_press: Clock.schedule_once(app.capture, 5)
        Button:
            pos_hint: {'center_x':0.5, 'top':0.2}
            size_hint: (None, None)
            size: (200, 50)
            text: 'Back to home'
            on_press: root.manager.transition = app.tr1; root.manager.current = 'home'
''')

class FadeInOutTransition(ShaderTransition):
    fs = StringProperty(FADEINOUT_TRANSITION_FS)

class HomeScreen(Screen):
    pass

class SettingScreen(Screen):
    pass

class ScreenApp(App):

    trs = {
        'Slide_H': (SlideTransition(direction='right'),
                    SlideTransition(direction='left')),
        'Slide_V': (SlideTransition(direction='up'),
                    SlideTransition(direction='down')),
        'Fade':     FadeTransition(),
        'Wipe':     WipeTransition(),
        'FadeIO':   FadeInOutTransition(),
    }

    def settr(self, tr):
        trs = self.trs[tr]
        if isinstance(trs, tuple):
            self.tr0 = trs[0]
            self.tr1 = trs[1]
        else:
            self.tr0 = trs
            self.tr1 = trs

    def capture(self, dt):
        Window.screenshot()

    def build(self):
        self.sm = ScreenManager()
        self.sm.add_widget(HomeScreen(name='home'))
        self.sm.add_widget(SettingScreen(name='setting'))
        self.settr('Fade')
        return self.sm

if __name__ == '__main__':
    ScreenApp().run()

*1:1.0 - t / t1) * cout) : t > t2 ? vec4((t - t2) / (1.0 - t2) * cin) : vec4(0.0); gl_FragColor = frag_col; } ''' from kivy.lang import Builder Builder.load_string(''' #:import Clock kivy.clock.Clock : canvas.before: Color: rgb: 0.4, 0.3, 0.2, 1 Rectangle: pos: self.pos size: self.size FloatLayout: Label: pos_hint: {'center_x':0.5, 'top':0.8} size_hint: (None, None) size: (200, 50) text: 'Home' Button: pos_hint: {'center_x':0.5, 'top':0.2} size_hint: (None, None) size: (200, 50) text: 'Goto setting' on_press: root.manager.transition = app.tr0; root.manager.current = 'setting' : canvas.before: Color: rgb: 0.2, 0.3, 0.4, 1 Rectangle: pos: self.pos size: self.size FloatLayout: Label: pos_hint: {'center_x':0.5, 'top':0.8} size_hint: (None, None) size: (200, 50) text: 'Setting' Spinner: pos_hint: {'center_x':0.5, 'top':0.6} size_hint: (None, None) size: (200, 50) text: 'Slide_H' values: sorted(app.trs.keys(

【未完成】RPG風にキャラクタを動かす (2) - Kivy Advent Calendar 2013

今度は「背景がスクロール+キャラクタが足踏み」で移動しているように見せます。背景をScrollViewにして、これをAnimationでコントロールできるかどうか、という実験です。

  FloatLayout (背景)
    Image (キャラクタ) ... 位置&画像を変更

      ↓

  FloatLayout
    ScrollView (背景) ... 位置を変更
    Image (キャラクタ) ... 画像を変更

(22:30)
申し訳ありませんが、今回はギブアップさせてください。
 
今のところうまくいっているのは次のとおり。

  • 前回 (2013-12-19) から引継いだUI
  • FloatLayout上で背景フレーム (ScrollView) とキャラクタ (Image) を重ね合わせる
  • ScrollViewの内部にマップ (GridLayoutで配置したImage群) を持たせる

次の点がうまくいっていません。

  • ScrollViewの左上隅にマップが固定されてしまい、(scroll_x,scroll_y)プロパティを変更しても動かすことができない


(スクリーンショットでも分かりづらいとは思いますが) キャラクタの初期位置はマップの中央で、背景の要素(マップチップ)とキャラクタが揃うはずなのですが、マップがGame領域の左上詰めとなって動かない状況なので、当然キャラクタと揃っていません。
おそらくsize_hintなどの指定が間違っているんだと思いますが、いろいろ試行錯誤しても動く気配すらない状況です…年末年始の宿題とさせてください。

イベント処理 - Kivy Advent Calendar 2013

相変わらずネタ切れ/時間切れで申し訳ないです。イベント処理の問題で少し加筆 (http://d.hatena.ne.jp/cheeseshop/20131205) したので、その件で少し書くことにします。
Kivyのイベント処理は、各ウィジェットクラスが「あるタイミング」で「イベントに結びつけた関数群を実行する」というものです。今回のAdvent Calendarで使ってきたものを挙げると

記事 クラス イベント 動作
2013-12-01 ファイル選択 Button on_release Load/Cancelボタンを押したら実行(ダミー)
2013-12-03 QRコード TextInput on_text_validate Enterキーを押したらQRコード生成
2013-12-05 スライド RstDocument on_touch_up タップしたらポップアップを開く
2013-12-08 ポップアップ FloatLayout on_touch_up タップしたらバブルを開く
2013-12-09 画面転換 Spinner on_text 選択肢を変更したら画面効果差替
2013-12-09 画面転換 Button on_press ボタンを押したら別スクリーンに転換
2013-12-10 アプリ復帰 App on_pause 別のアプリへの切替時
2013-12-10 アプリ復帰 App on_resume 別のアプリからの復帰時
2013-12-11 カード Scatter on_touch_up タッチしたらひっくり返す
2013-12-13 流行語 Button on_press ボタンを押したら喋る
2013-12-14 写真 StackLayout on_touch_up タッチしたらカメラ起動
2013-12-14 写真 PythonActivity on_activity_result カメラ終了のタイミング
2013-12-15 テキスト共有 Button on_press ボタンを押したらメーラ起動
2013-12-16 Webブラウザ Button on_press ボタンを押したらブラウザ起動
2013-12-16 Webブラウザ Button on_press ボタンを押したらブラウザ起動
2013-12-18 Launcher Button on_press ボタンを押したらKivyLauncher起動
2013-12-19 キャラ移動 Button on_press ボタンを押したらキャラクタ移動
2013-12-19 キャラ移動 Animation on_progress 画像を順次変えてアニメ
2013-12-19 キャラ移動 Animation on_complete 排他制御を解除

まあ「ボタンを押して○○する」のが多いです。でも中にはアニメ、アプリ、アクティビティのようにKivyにおけるユーザ操作とは関係なくイベントが発生するものがあります。

on_touch_up / on_touch_downについて

さて、問題だったのは「2013-12-05 スライド」のon_touch_upです。ここでは画面をダブルタップしたらreStructureTextソースをポップアップを見せていましたが、ここではon_touch_upの戻り値で問題が解決したかのように書いていました。実際にはそうではありませんでした。一見「今開いたスライド」のソースを見せていましたが、実は

  • 全部のスライド (page01〜page03) のソースが一斉に開く (どれが一番上になるかは不定)
  • 外部をタップしたときには3つとも閉じた

というものでした。必ずしも表示しているスライドのソースが見えなかったということです。
何故こうなるのかといえば、on_touch_upというのはタッチした場所にあるウィジェットへ送られるイベントではなく、単に「タッチパネルから指が離れた」というイベントだからです。
つまり、「タッチした場所以外に置かれたウィジェット」に対してもon_touch_upは発生するし、しかもScreenManagerやCarouselのように画面転換によって「非表示になっているウィジェット」にもon_touch_upは発生してしまうのでした。

:
    orientation: 'vertical'
    Label:
        text: 'LABEL1'
        on_touch_down: self.text = 'LABEL1: DOWN'
        on_touch_up: self.text = 'LABEL1: UP'
    Label:
        text: 'LABEL2'
        on_touch_down: self.text = 'LABEL2: DOWN'
        on_touch_up: self.text = 'LABEL2: UP'
    Label:
        text: 'LABEL3'
        on_touch_down: self.text = 'LABEL3: DOWN'
        on_touch_up: self.text = 'LABEL3: UP'

3つのラベルがあってタッチによって文字列を変えようとしていますが、これでは3つとも一斉に書き換わってしまいます。正しく動かすには次のようにしなければなりません。

:
    orientation: 'vertical'
    Label:
        text: 'LABEL1'
        on_touch_down: self.collide_point(*args[1].pos) and setattr(self, 'text', 'LABEL1: DOWN')
        on_touch_up: self.collide_point(*args[1].pos) and setattr(self, 'text', 'LABEL1: UP')
    Label:
        text: 'LABEL2'
        on_touch_down: self.collide_point(*args[1].pos) and setattr(self, 'text', 'LABEL2: DOWN')
        on_touch_up: self.collide_point(*args[1].pos) and setattr(self, 'text', 'LABEL2: UP')
    Label:
        text: 'LABEL3'
        on_touch_down: self.collide_point(*args[1].pos) and setattr(self, 'text', 'LABEL3: DOWN')
        on_touch_up: self.collide_point(*args[1].pos) and setattr(self, 'text', 'LABEL3: UP')

on_touch_up/on_touch_downの式では、args[1].posでタッチした位置の座標(x,y)が取れ、self.collide_point(x,y)でウィジェット内部かどうかのチェックができます。でもkvレイアウト言語の中なのでandを条件文の代わりにして、代入式が書けないからsetattrに置き換えて…こうしてやっと個別のイベント処理が出来ることになります。
(まあ一般的にイベントハンドラは、kv言語よりもメソッドで書く方が楽だと思います…)
ScreenManagerやCarouselの非表示ウィジェットについては、それが現在表示されているものか調べることができますから、それを条件にしてイベントを受け付けるか受け付けないかを切り替えることになります。

self.collide_point(*touch.pos) 自身にタッチしたか
self.manager.current_screen is self ScreenManagerが自身を表示しているか
self.parent.current_slide is self Carouselが自身を表示しているか (2013-12-05 スライドはこれを使用)

on_touch_up/on_touch_downは、全画面で使う分にはこういった問題はない (2013-12-08 ポップアップなど) のですが、複数あったり非表示要素がある場合は注意が必要です。

KivyLauncherとSL4A+Py4Aの比較 - Kivy Advent Calendar 2013

さて今回KivyLauncherをいろいろといじってきましたが、2年前はSL4A+Py4Aをいろいろと試していました。

となると、この2つはどう違うのか気になるところかもしれません。

KivyLauncherは、まずSDLOpenGLを組み合わせてマルチメディア環境を作ってからPythonを起動します。まあマルチメディア環境といっても、AndroidのSurfaceViewとSDL関連のクラスをくっつけて、Androidのサービスプログラムとして動くようにしたものです。Unityみたいなゲームエンジンに比べれば貧弱です。でも、最低限フレーム(画面)とサウンドとタッチパネル入力にアクセスできればいいという発想ですので、ライブラリ次第でどのようなUIでも組み立てられます。
起動されたPythonからはAndroidAPIではなく、そのマルチメディア環境にアクセスします。Kivyのライブラリはその上でウィジェットの描画、クロックやタッチイベントの管理を行うことでアニメーションやインタラクションが実現されています。
WindowsMacOSXiOS上のKivyの場合も (実装はDirectXだったりいろいろ違うのですが) 同じマルチメディア環境を作り、その上でウィジェット描画やイベント管理を行うので、ハードウェアの違い(カメラ、GPS、加速度センサ)を避けるという条件であれば、マルチプラットフォームのアプリケーションを作ることが可能なわけです。

SL4Aでのプログラミングのアプローチは、これとは対照的なものになります。
SL4Aから起動されたPythonは、SL4Aを経由してAndroid APIにアクセスして描画やハードウェア操作が利用できる形になっています。SL4AはAndroidと密接に連携していて、描画もAndroid本来のレイアウトエンジンを使うことになります。SL4Aはプログラミング言語としてPythonJrubyPHPなどが使えますが、同じ名前のAPIを呼び出すので、見た目はJavaAndroidプログラミングするのに近い形になります。

マルチプラットフォームを意識するのであればKivyしか選択肢はありませんが、もし「Androidだけで動けばよい」プログラムであればKivyで書くかSL4Aで書くか迷うところかもしれません。
SL4Aは、おそらくAndroidアプリをJavaで開発した経験があれば早く馴染むと思います。またWebViewベースのアプリケーションを作る場合はSL4Aを選択することになります。
一方KivyはこれまでのAndroidプログラミングとは異なる形態なので、Javaによる開発経験がない方が向いていると思います。ゲームプログラミングや組み込みのLinux端末を経験した人は、Kivyの方が馴染むかもしれません。

あと、KivyLauncherとSL4A+Py4Aのすぐに分かる違いとして、Androidアプリのパーミッションがあります。KivyLauncherはUSBストレージ、ネットワーク、Bluetooth程度しか要求していませんが、SL4AはAndroid APIの大半を網羅しているため、連絡帳だのSMSだの通話だの、ありとあらゆるパーミッションを要求しています。
さすがにこんなSL4Aを「インストールしてください」とお客さんにお願いするのは難しいので、最低限のパーミッションでAPKパッケージを作って渡すしかないです。そもそもSL4AがGoogle Playで公開できないのも、このパーミッションが影響していると思います。
一方のKivyLauncherはGoogle Playからインストール可能になっています。まだ試してはいませんが、KivyLauncherでpyjniusを使ってGPS情報を取得するアプリを作ったら、GPS関連のパーミッションをつけるためにビルドし直す必要があるでしょう。

RPG風にキャラクタを動かす - Kivy Advent Calendar 2013


Animationオブジェクトの実験ですが、無駄にゲーム機っぽいインタフェースにしてみました。
でもA/B/X/Yキーはダミーです。動くのは上下左右キーだけ。
エラーチェック何もしてませんので、平気で画面外へ出て行っちゃいます...
 
※spellyonさんのサイト「点睛集積」から素材をお借りしています (http://dispell.net/)


(/sdcard/kivy/thrpg/)

android.txt お約束
main.py スクリプト本体
th7_remari.png 霊夢魔理沙の24x32チビキャラファイル(背景を透過化)
chara.atlas 上の画像に紐付けるatlasファイル 霊夢のデータしか使ってません...

(android.txt)

title=thrpg
author=cheeseshop
orientation=landscape

(chara.atlas)

{
    "th7_remari.png": {
        "N0": [ 24, 224, 24, 32],
        "N1": [  0, 224, 24, 32],
        "N2": [ 24, 224, 24, 32],
        "N3": [ 48, 224, 24, 32],
        "E0": [ 24, 192, 24, 32],
        "E1": [  0, 192, 24, 32],
        "E2": [ 24, 192, 24, 32],
        "E3": [ 48, 192, 24, 32],
        "S0": [ 24, 160, 24, 32],
        "S1": [  0, 160, 24, 32],
        "S2": [ 24, 160, 24, 32],
        "S3": [ 48, 160, 24, 32],
        "W0": [ 24, 128, 24, 32],
        "W1": [  0, 128, 24, 32],
        "W2": [ 24, 128, 24, 32],
        "W3": [ 48, 128, 24, 32]
    }
}

(main.py)

from kivy.app import App
from kivy.animation import Animation
from kivy.uix.label import Label
from kivy.uix.button import Button
from kivy.uix.image import Image
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.relativelayout import RelativeLayout
from itertools import cycle

from kivy.lang import Builder
Builder.load_string('''\
:
    orientation: 'vertical'
    Label:
        size_hint_y: 3./8.
    GridLayout:
        size_hint_y: 2./8.
        rows: 3
        Label:
        Button:
            id: btn_N
            text: '^'
            on_press: app.move('N')
        Label:
        Button:
            id: btn_W
            text: '<'
            on_press: app.move('W')
        Label:
        Button:
            id: btn_E
            text: '>'
            on_press: app.move('E')
        Label:
        Button:
            id: btn_S
            text: 'v'
            on_press: app.move('S')
        Label:
    Label:
        size_hint_y: 3./8.

:
    orientation: 'vertical'
    Label:
        size_hint_y: 3./8.
    GridLayout:
        size_hint_y: 2./8.
        rows: 3
        Label:
        Button:
            text: 'X'
        Label:
        Button:
            text: 'Y'
        Label:
        Button:
            text: 'A'
        Label:
        Button:
            text: 'B'
        Label:
    Label:
        size_hint_y: 3./8.

:
    game: _game
    orientation: 'horizontal'
    ControlPad:
        size_hint_x: 1./8.
    Game:
        id: _game
        size_hint_x: 6./8.
    ActionPad:
        size_hint_x: 1./8.

:
    canvas:
        Color:
            rgb: (.25, .50, .25)
        Rectangle:
            size: self.size
            pos: (0, 0)

:
    drct: 'E'
    source: 'atlas://chara/E0'
    allow_stretch: True
    size: (192, 256)
    size_hint: (None, None)
''')

class Root(BoxLayout):
    pass

class ControlPad(BoxLayout):
    pass

class ActionPad(BoxLayout):
    pass

class Game(RelativeLayout):
    pass

class Character(Image):
    pass

class GameApp(App):

    def cycle(self, iter=cycle(list('0123'))):
        return iter.next()

    def reload(self, anim, ch, progress):
        ch.source = 'atlas://chara/%s%s' % (ch.drct,self.cycle())
        ch.reload()

    def clear(self, anim, ch):
        self.moving = False

    def move(self, drct, *args):
        if self.moving:
            return False
        self.moving = True
        self.anim = Animation(
            d=1./1., s=1./8., t='linear',
            x=self.ch.x+(drct=='E')*192-(drct=='W')*192,
            y=self.ch.y+(drct=='N')*256-(drct=='S')*256)
        self.ch.drct = drct
        self.anim.bind(on_progress=self.reload)
        self.anim.bind(on_complete=self.clear)
        self.anim.start(self.ch)
        return False

    def build(self):
        root = Root()
        self.moving = False
        self.ch = Character()
        self.ch.pos = (0, 0)
        self.ch.drct = 'S'
        root.game.add_widget(self.ch)
        return root

if __name__ == '__main__':
    GameApp().run()

解説

Kivyのアニメーション機能

Animationオブジェクトは、描画タイミングにあわせてウィジェットのサイズや位置といったプロパティを逐次変更していくというものです。
引数には次のようなものを指定してインスタンスを生成します。

  • サイズや位置の最終値 (x, y, pos, size)
  • 描画タイミング (d:アニメーションの時間、s:描画間隔)
  • トランジション関数 (t:文字列または3つの引数[初期値,最終値,0〜1の値]を取る関数)

その後 start(widget) メソッドで、引数に指定したウィジェットの移動を開始します。

歩行アニメーション

Animationオブジェクトは、あくまでウィジェットの「数値の」プロパティを順次変更するものです。
関数をうまく使えば画像を変更するのも可能かもしれませんが、ちょっと面倒なので別の方法を取ることにしました。
このサンプルでは、描画タイミングで「on_progressイベントが発生する」ことを利用しています。on_progressで呼び出すハンドラの第2引数には移動中のウィジェットが入るので、そこで同じ向きのキャラクタ画像 (3パターン) を順次入れ替えて歩行のアニメーションを実現しています。

排他処理

アニメーションはプログラムをブロックせず並行に走るので、動作中に別の移動ボタンを受け付けてしまう可能性があります。このサンプルでは「ひとつの移動が終わってから次のキーを受け付けるようにしたい」ので、移動が終わったときのon_completeイベントとフラグ (moving) を使って排他制御をしています。

キー操作がリピートしない

ゲームキーパッドもどきはon_pressを使っているので、その都度離して押してを繰り返さないといけないです。この件については後で解決策を探してみます...
(たぶんon_touch_downを使えばいいんでしょうが、本当にそのボタンを押したかをcollide_pointでチェックする必要があります)