Tworzenie Gier w Kivy i Kivent (jak zacząć)

Posted on pon 20 sierpnia 2018 in python

Skąd toto?

To jest artykuł napisany na potrzeby konferencji PyconPL 2018 i jest też do ściągnięcia z PyconPLowego githuba

Wstęp

Zarówno Kivy jak i Kivent mogą służyć do pisania prostych gier w Pythonie. W tym artykule, na przykładzie bardzo prostej aplikacji gropodobnej, porównam podstawowe aspekty tworzenia gier w jednym i drugim.

Kivy - omówienie

Kivy (http://kivy.org) jest wieloplatformową (Linux, Windows, macOS, Android, iOS) biblioteką do tworzenia UI.

Napisana jest w Pythonie i Cythonie. Obsługuje zarówno Pythona 2.x jak i 3.x.

Zalety Kivy

  • wieloplatformowość. Aplikacja którą stworzymy będzie działała (prawie) tak samo pod linuksem jak i pod androidem, z zastrzeżeniem różnic w rozdzielczości.
  • przejrzysty i bogaty język KV opisujący nam UI aplikacji (i nie tylko), bogactwo layoutów szczegóły składni języka można znaleźć pod adresem http://kivy.org/docs/guide/lang.html
  • buildozer (o którym niżej)

Wady Kivy

  • mimo bogactwa layoutów, do szału człowieka może doprowadzić konieczność umieszczenia buttona o zadanych rozmiarach w rogu ekranu, albo przycisku który dopasuje się do zawartości i jednocześnie będzie miał konkretne proporcje.
  • inicjalizacja biblioteki, okienek i w ogóle całej warstwy pod spodem jest w momencie import kivy a nie w momencie uruchomienia mainloop. Co czasem wkurza.
  • twórcy kivy mieli własny pomysł na mechanizmy logowania. Czyli Logger.info zamiast logging.info, defaultowy handler jest na ekran i do plików tekstowych w katalogu domowym. Co prawda jest to oparte na pythonowy moduł logging, ale próba wpięcia się w to albo wręcz przekierowania tego zupełnie np. do własnego logging.basicConfig(...) jest trudne i nieintuicyjne.

Kilka słów na temat buildozera

Tworząc aplikację w Kivy, niemal na pewno mamy możliwość uruchomienia jej na androidzie bez większego wysiłku, za pomocą Buildozera. Buildozer jest to skrypt/nakładka na P4A (https://github.com/kivy/python-for-android) która robi za użytkownika całą niewdzięczną robotę, polegająca na ściąganiu odpowiednich SDK, NDK i innych *DK.

Przy odrobinie szczęścia, można odpalić aplikację napisaną w Kivy bez wnikania w to jakie paczki do siebie pasują, gdzie je (są gigantyczne przecież) rozpakować i w ogóle jakie rodzaje *DK w ogóle występują.

Tworzenie gry w Kivy

W artykule opiszę najprostszą grę w kivy. (gra to dużo powiedziane ... coś co może być podstawą do gry :). Niech to to będzie przeciskanie się między balonowatymi obiektami do prawej ściany, która będzie celem gry.

Źródło aplikacji jest pod adresem https://gitlab.com/maho/pycon-kivygame

Instalacja kivy, kadłubek aplikacji

maho@..kivygame$ mkvirtualenv --python=/usr/bin/python3 kivygame
[...]

maho@..kivygame$ pip install cython
Collecting cython
  Downloading https://files.pythonhosted.org/packages/93/a3/213a6106aed3d51f5fb6aa0868849b6a3afe240e019f6586c52cac3bbe7b/Cython-0.28.4-cp35-cp35m-manylinux1_x86_64.whl (3.3MB)
    100% |████████████████████████████████| 3.3MB 1.4MB/s
Installing collected packages: cython
Successfully installed cython-0.28.4

Niestety nie da się tego załatwić pojedynczym plikiem req.txt, bo pakiety muszą być instalowane w osobnych krokach.

maho@..kivygame$ pip install kivy
Collecting kivy
[...]
Successfully built kivy
Installing collected packages: urllib3, idna, certifi, chardet, requests, Kivy-Garden, docutils, pygments, kivy
Successfully installed Kivy-Garden-0.1.4 certifi-2018.4.16 chardet-3.0.4 docutils-0.14 idna-2.7 kivy-1.10.1 pygments-2.2.0 requests-2.19.1 urllib3-1.23

Aplikację zacznijmy od pliku .py - nazwijmy go main.py. Można go nazwać dowolnie, ale jeżeli zechcemy użyć buildozera do zbudowania paczki na Androida, to musi się nazywać main.py i koniec.

main.py:

from kivy.app import App
from kivy.uix.widget import Widget


class KivyGame(Widget):
    pass


class KivyGameApp(App):
    pass


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

Jest zadeklarowana klasa KivyGame, dziedzicząca po Widget, która jest używana w KivyGameApp.

KivyGameApp domyślnie ładuje jako root widget to co jest pierwsze w odpowiednim pliku .kv. A odpowiedni plik .kv to to jest <nazwa aplikacji bez "App", lowercase>.kv

kivygame.kv

KivyGame:
    BoxLayout:
        pos: root.pos
        size: root.size
        Label:
            text: "to ja - tło"
            size_hint: 1, 1

Tutaj mamy Widget, który w sobie zawiera BoxLayout, domyślnie rozciągający się na całe KivyGame i zawierający w sobie Label z tekstem to ja - tło, rozciągający się na całego rodzica, czyli BoxLayout w tym przypadku.

apka wygląda tak

Dodajemy postaci

Do root widgeta dodaję informację o postaci, nazwijmy ją Fred, w kivygame.kv:

    Widget:
        size: 145, 145
        center: 300, 300

Widget jest poza BoxLayout, bo layout ma za zadanie rozmieścić elementy UI wg jakiegoś algorytmu, a postać ma być dokładnie w określonym miejscu. Goły widget jednak nie zawiera nic, więc trzeba coś do niego wsadzić. Najprościej narysować coś na jego płótnie:

    Widget:
        canvas:
            Rectangle:
                pos: self.pos
                size: self.size
                source: 'img/fred.png'

Żeby móc zidentyfikować Freda wewnątrz aplikacji, trzeba nadać mu id, umieścić referencję do tego id w KivyGame...

KivyGame:
    fred: fred
    [...]

    Widget:
        [...]
        id: fred

i od teraz w KivyGame pojawia się self.fred.

Dodanie balonów

Balony dodaję w inny sposób: nie moge ich zadeklarować w pliku .kv, bo nie wiem dokładnie ile ich będzie. Dlatego, w pliku .kv definiuję wygląd klasy <Baloon> zaś dodanie jej będzie odbywać się w kodzie.

plik kivygame.kv:

<Baloon>:
    canvas:
        Rectangle:
            pos: self.pos
            size: self.size
            source: 'img/baloon.png'

    size: 100, 100

oraz main.py:

class Baloon(Widget):
    pass

dodanie 10 balonów w losowych miejscach na arenie o pozycjach

class KivyGame(Widget):
    def __init__(self, *a, **kwa):
        super().__init__(*a, **kwa)

        self.baloons = []

        for _ in range(defs.num_baloons):
            baloon = Baloon(center=(randint(200, 800), randint(100, 500)))
            self.add_widget(baloon)

Efekt jak poniżej: apka z postacią i balonami

Apka z losowymi przemieszczeniami się balonów

W KivyGame.__init__ uruchamiamy wywoływanie metody update 30 razy na sekundę:

class KivyGame(Widget):
    def __init__(self, *a, **kwa):
        [...]
        Clock.schedule_interval(self.update, 1.0 / 30)

A tam, modyfikujemy pozycje balonów w sposób losowy:

    def update(self, dt):
        for b in self.baloons:
            x, y = b.pos
            b.pos = (x + randint(-5, 5), y + randint(-5, 5))

W repozytorium kod jest oznaczony tagiem 03-randommoves

Dodany input, można sterować, nadal brak interakcji

Najprościej dodać obsługe myszki, wystarczy tylko przeciążyć metody on_touch_*. W naszym przypadku - będzie to on_touch_up. Przyśpieszamy Freda w kierunku punktu kliknięcia.

class KivyGame(Widget):
    def __init__(self, *a, **kwa):
        [...]
        self.fred_speed = Vector(0, 0)
        [...]

    def update(self, dt):
        [...]
        self.fred.pos = Vector(self.fred.pos) + self.fred_speed

i samo przyśpieszenie:

    def on_touch_up(self, touch):
        vdir = Vector(touch.pos) - self.fred.center  # wynik to Vector
        self.fred_speed += vdir / 100

Klawiatura jest ciutkę mniej oczywista:

from kivy.base import EventLoop
[...]
from kivy.core.window import Keyboard

[...]
class KivyGame(Widget):
    def __init__(self, *a, **kwa):
        [...]
        EventLoop.window.bind(on_key_up=self.on_key_up)

    [...]
    def on_key_up(self, __window, key, *__, **___):
        # code = Keyboard.keycode_to_string(None, key) # można tak, ale to brzydki hack
        dx, dy = 0, 0
        if key == Keyboard.keycodes['up']:
            dy = 5
        elif key == Keyboard.keycodes['down']:
            dy = -5
        elif key == Keyboard.keycodes['left']:
            dx = -5
        elif key == Keyboard.keycodes['right']:
            dx = +5

        self.fred_speed += Vector(dx, dy)

Czyli, możemy sterować już Fredem, ale nadal jest zero interakcji między Fredem a balonami.

Fizyka: jest interakcja, ale wszystko nam ucieka

Do fizyki mamy 2 świetne biblioteki: pymunk (http://pymunk.org ) i cymunk (http://github.com/kivy/cymunk ) - obie oparte na Chipmunk (http://chipmunk-physics.net ).

Do niedawna tylko Cymunk wchodził w grę, jeżeli trzeba było zbudować grę na androida. Od ponad roku Pymunk ma także swoją receptę w Python4android więc należy sądzić że też jest używalny.

Cymunk jest nieco do tyłu w stosunku do Pymunka jeżeli chodzi o funckcjonalność i obsługiwaną wersję Chipmunka, użyję jednak Cymunka w tym przykładzie, jako że Kivent, o którym jest w dalszej części artykułu, używa właśnie Cymunka.

Instalacja cymunk:

   maho@dlaptop:~/workspace/kiventgames/kivygame$ pip install git+https://github.com/kivy/cymunk

I wkładam cymunka do aplikacji:

from cymunk import Space

[...]
class KivyGame(Widget):
    def __init__(self, *a, **kwa):
        [...]
        self.init_physics()

    def init_physics(self):
        self.space = Space()  # cała fizyka dzieje się w ramach obiektu Space()

    def update(self, dt):
        [...]
        self.space.step(1.0 / 30)

W update robimy tick fizyki - mówimy jej że minęło 1/30 sekundy. Można podać zmienną dt zamiast sztywnego 1/30, ale problem jest taki że jeżeli coś się przytka w systemie i np. następne update zrobi się nie za 1/30 sekundy a za jedną sekundę, to może się np. okazać że jakiś obiekt przeleciał przez ścianę. Lepiej więc poświęcić zgodność czasu fizyki chipmunka z czasem rzeczywistym, niż narazić się na fantastycznie wielkie przyśpieszenia osiągane przez obiekty.

Trzeba powiązać obiekty z self.space:

    def init_physics(self):
        self.space = Space()  # cała fizyka dzieje się w ramach obiektu Space()

        self.init_body(self.fred, 72)
        for b in self.baloons:
            self.init_body(b, 50)

    def init_body(self, widget, r):
        """ initialize cymunk body for given widget as circle
            of radius=r
        """
        widget.body = Body(defs.mass, defs.moment)
        widget.body.position = widget.center
        self.widgets_with_bodies.append(widget)

        shape = Circle(widget.body, r)
        shape.elasticity = defs.elasticity
        shape.friction = defs.friction
        shape.collision_type = defs.default_collision_type

        self.space.add(widget.body)
        self.space.add(shape)

następnie sprawić, żeby aplikacja śliedziła pozycje obiektów

    def update(self, dt):
        [...]
        for w in self.widgets_with_bodies:
            w.center = tuple(w.body.position)

Jest jeden drobny szkopuł: self.fred nie ma w KivyGame.__init__, całe procesowanie rzeczy zdefiniowanych w kivygame.kv dzieje się "tuż po" __init__. Dlatego trzeba odłożyć operacje na tych obiektach na "tuż po". W tym celu:

class KivyGame(Widget):

    def __init__(self, *a, **kwa):
        super().__init__(*a, **kwa)

        Clock.schedule_once(self.init_widgets)  # tutaj przekazujemy do "tuż po"

    def init_widgets(self, dt):  # a to się wykona "tuż po"
        [...]

        self.init_physics()

    def init_physics(self):
        [...]
        self.init_body(self.fred, 72)
        for b in self.baloons:
            self.init_body(b, 50)

Po uruchomieniu wygląda tak:

po uruchomieniu wygląda tak

Balony się pięknie rozsunęły. Ale, widać że nie da się już sterować Fredem, zaś balony przestały się ruszać. To oczywiste, wszelkie zmiany do self.fred.pos są kasowane poprzez ustawianie tej pozycji do fizycznego ciała. Dlatego, trzeba od tej pory operować tylko na ciałach fizycznych.

Dlatego to:

            b.pos = (x + randint(-5, 5), y + randint(-5, 5))

zamieniam na

            b.body.apply_impulse((randint(-10, 10), randint(-10, 10)))

Zaś sterowanie Fredem zamieniam na:

zamieniam tym:

    def on_key_up(self, __window, key, *__, **___):
        # code = Keyboard.keycode_to_string(None, key) # można tak, ale to brzydki hack
        dx, dy = 0, 0
        if key == Keyboard.keycodes['up']:
            dy = 500
        elif key == Keyboard.keycodes['down']:
            dy = -500
        elif key == Keyboard.keycodes['left']:
            dx = -500
        elif key == Keyboard.keycodes['right']:
            dx = +500

        self.fred.body.apply_impulse(Vector(dx, dy))

    def on_touch_up(self, touch):
        vdir = Vector(touch.pos) - self.fred.center  # wynik to Vector
        self.fred.body.apply_impulse(vdir * 5)

Kod pod tagiem 05-physics-02

Dodajemy ściany

Balony i główny bohater nam uciekają, trzeba zrobić ściany. Ściany będą na granicy okienka, a więc i tak niewidoczne, a więc tworzymy te obiekty tylko w Space cymunka.

Żeby się nie okazało że zmniejszając okienko, "przelecimy" ścianą przez obiekt, ściany zrobimy naprawdę bardzo grube, jak na obrazku:

ściany

Korzystamy ze kształtu Segment, któremu nadamy bardzo dużą grubość (400px).

class KivyGame(Widget):

    def __init__(self, *a, **kwa):
        [...]
        self.walls = []

    [...]
    def init_physics(self):
        [...]
        self.create_walls()

    def create_walls(self):
        segments, R = self.wall_segments()
        for v1, v2 in segments:

            wall = Segment(self.space.static_body, v1, v2, R)
            wall.elasticity = defs.elasticity
            wall.friction = defs.wall_friction
            wall.collision_type = defs.default_collision_type

            self.space.add_static(wall)
            self.walls.append(wall)
    [...]
    def wall_segments(self):
        w, h = self.size
        R = 200
        return [(Vec2d(-2 * R, -R), Vec2d(w + 2 * R, -R)),
                (Vec2d(-R, -2 * R), Vec2d(-R, h + 2 * R)),
                (Vec2d(-2 * R, h + R), Vec2d(w + 2 * R, h + R)),
                (Vec2d(w + R, h + 2 * R), Vec2d(w + R, -2 * R))], R

Segment jest dodany jako ciało statyczne, nie domyślne -- dynamiczne. Ciało statyczne jest to ciało które uczestniczy w odbiciach z innymi ciałami, ale nie podlega siłom/nie zmienia położenia.

Dodatkowo, reagujemy na zmianę rozmiaru okna (po zmianie parametrów kształtów ciała dynamicznego, reindeksujemy przestrzeń)

class KivyGame(Widget):

    def __init__(self, *a, **kwa):
        [...]
        Window.bind(on_resize=self.on_resize)
    [...]
    def on_resize(self, _win, w, h):
        if not self.walls:
            return
        Logger.debug("move walls with width w=%s h=%s", w, h)

        segments, __R = self.wall_segments()

        for (v1, v2), wall in zip(segments,
                                  self.walls):
            wall.a = v1
            wall.b = v2

        self.space.reindex_static()

Pełny kod pod tagiem 06-walls

Cel gry, Screen Manager, Plansza success

Żeby aplikacja chociaż trochę przypominała grę, musi mieć jakiś cel. Niech to będzie dotarcie do ściany po prawej.

W tym celu robimy dwie rzeczy. Identyfikujemy która ściana jest celem i nadajemy jej inny collision_type

    def create_walls(self):
        [...]
        for v1, v2, goal in [(Vec2d(-2*R, -R), Vec2d(w + 2*R, -R), False),
                             (Vec2d(-R, -2*R), Vec2d(-R, h + 2*R), False),
                             (Vec2d(-2*R, h + R), Vec2d(w + 2*R, h + R), False),
                             (Vec2d(w + R, h + 2*R), Vec2d(w + R, -2*R), True)]:
            [...]
            wall.collision_type = defs.goal_collision_type if goal else defs.default_collision_type

oraz Fredowi:

    def init_physics(self):
        [...]
        self.init_body(self.fred, 72, defs.fred_collision_type)

    def init_body(self, widget, r, collision_type=defs.default_collision_type):
        [...]
        shape.collision_type = collision_type

handler kolizji między defs.fred_collision_type a defs.goal_collision_type

    def init_physics(self):
        [...]
        self.space.add_collision_handler(defs.goal_collision_type,
                                         defs.fred_collision_type,
                                         self.goal_reached)
    [...]
    def goal_reached(self, _space, _arbiter):
        print("\n\nSUCCESS!!!\n\n")

Kod pod tagiem 07-goal-01

No tak, ale print na konsolę to kiepski pomysł w grze. Trzeba zrobić jakąś planszę. Najlepiej użyć ScreenManager który trzyma grę jako jeden z ekranów, planszę z wynikiem jako inny, można tam jeszcze wstawić planszę powitalną, planszę z Game Over itd...

A więc w kivygame.kv jest zawartość klasy GameScreenManager (w nawiasach ostrych - a więc nie mamy tworzenia instancji, ta będzie stworzona w main.py)

<GameScreenManager>:
    Screen:  # pierwszy screen jest wyświetlany domyślnie
        name: 'game'
        KivyGame:
    Screen:
        name: 'success'
        BoxLayout:
            pos: root.pos
            size: root.size

            Label:
                font_size: '100sp' 
                text: "Success!"

oraz w main.py:

class GameScreenManager(ScreenManager):
    pass


class KivyGame(Widget):
    def __init__(self, *a, **kwa):
        [...]
        self.app = App.get_running_app()

    [...]
    def goal_reached(self, _space, _arbiter):
        self.app.sm.current = 'success'  # <-- tak, to wystarczy zeby zmienić screen
[...]
class KivyGameApp(App):
    def __init__(self, *a, **kw):
        super().__init__(*a, **kw)
        self.sm = None

    def build(self):
        self.sm = GameScreenManager()
        return self.sm

Tag 07-goal-2

Wynik:

goal reached

Stress test - zwiększamy liczbę balonów ...

No to zróbmy mały stress test - zwiększmy liczbę balonów do 20, 40, 80, 160 .... i zobaczmy kiedy zacznie "ciąć".

  • Przy 80 balonach, musiałem zmniejszyć rozmiar balona o połowę, ponieważ nie mieściły się na planszy. (tag 08-stress-80)
  • Przy 320 balonach, ponownie musiałem to zrobić (tag 08-stress-320) i wtedy odnotowałem pierwsze objawy zacinania, zwłaszcza w momencie kiedy robiło się ciasno.
  • Przy 1280 balonach, zacinanie stało się bardzo widoczne, mimo zmniejszenia rozmiaru balodu do 15,15. Ale jeszcze momentami ruch był płynny.
  • 3000 balonów to już było zdecydowanie za dużo dla mojego sprzetu (i3 z 1.6, bez wspomagania grafiki)

KivEnt

Kivent - omówienie

Kivent jest biblioteką do tworzenia gier, opartą na Kivy i Cymunk. Składa się z kilku modułów, z których interesują nas tutaj dwa: kivent_core i kivent_cymunk.

Głownym autorem KivEnt jest Jacob Kovacs.

Zalety i wady Kivent w stosunku do Kivy

Aplikacja w Kivent jest jednocześnie aplikacją w Kivy, więc automatycznie mają zastosowaie wady i zalety Kivy.

Zalety:

  • jest biblioteką "wszystkomającą". Jest tam i fizyka i kamera, i śledzenie tą kamerą obiektu, wsparcie dla przesuwania dwoma palcami itd. Dużo bajerów które mogą się przydać.
  • jest wydajniejsza (napisana w Cythonie)

Wady: * ma znacznie mniejsze grono developerów, więc jak znajdziesz błąd - nastaw się na to że będziesz go sam naprawiał. * jest skomplikowana, jeżeli znajdziesz błąd i będziesz go sam naprawiał, nastaw się że nie będzie lekko (no chyba że dobrze znasz Cythona i OpenGL). * więcej się trzeba "opisać" żeby stworzyć minimalną aplikację. Więcej wysiłku żeby rozpocząć - gorsza do prototypowania. * Kivent oparte jest na wzorcu ECS (Entity-Component-System) i o ile jest to ponoć lepszy sposób na tworzenie gier niż oparcie ich o OOP, to nie ułatwia to stworzenia szybkiego prototypu gry, zwłaszcza jeżeli ktoś nie jest "otrzaskany" z ECS.

Tworzenie gry w Kivent

To będzie taka sama gra jak w Kivy, z dodatkowym udziałem kilku bajerów pochodzących z Kivent

instalacja kivent, kadłubek

Najpierw, do już stworzonego virtualenva, należy doinstalować kivent_core i kivent_cymunk

maho@dlaptop:~/workspace/kiventgames/kiventgame$ pip install "git+https://github.com/kivy/kivent#egg=kivent_core&subdirectory=modules/core"
Collecting kivent_core from git+https://github.com/kivy/kivent#egg=kivent_core&subdirectory=modules/core
[...]
Installing collected packages: kivent-core
Successfully installed kivent-core-2.2.0.dev0

maho@dlaptop:~/workspace/kiventgames/kiventgame$ pip install "git+https://github.com/kivy/kivent#egg=kivent_cymunk&subdirectory=modules/cymunk"
Collecting kivent_cymunk from git+https://github.com/kivy/kivent#egg=kivent_cymunk&subdirectory=modules/cymunk
  Cloning https://github.com/kivy/kivent to /tmp/pip-install-ndhjjtuk/kivent-cymunk
[...]
Installing collected packages: kivent-cymunk
Successfully installed kivent-cymunk-1.0.0

Teraz prościutka, pusta aplikacja. Skopiowane z drobnymi modyfikacjami z przykładów KivEnt.

kiventgame.kv:

KiventGame:
    gameworld: gameworld
    GameWorld:
        id: gameworld
        gamescreenmanager: gamescreenmanager
        size_of_gameworld: 100*1024
        size_of_entity_block: 128
        system_count: 2
        zones: {'general': 10000}
    GameScreenManager:
        id: gamescreenmanager
        size: root.size
        pos: root.pos
        gameworld: gameworld
        GameScreen:
            name: 'main'

main.py:

import kivy
from kivy.app import App
from kivy.uix.widget import Widget
import kivent_core

class KiventGame(Widget):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.gameworld.init_gameworld([], callback=self.init_game)

    def init_game(self):
        self.setup_states()
        self.set_state()

    def setup_states(self):
        self.gameworld.add_state(state_name='main',
            systems_added=[],
            systems_removed=[], systems_paused=[],
            systems_unpaused=[],
            screenmanager_screen='main')

    def set_state(self):
        self.gameworld.state = 'main'

class KiventGameApp(App):
    def build(self):
        pass

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

Dodajemy postaci

I o ile dotąd wyglądało to mniej więcej prosto, to teraz człowiek się opisać jak głupi.

Najpierw, trzeba pobrać plik shadera, skopiowałem go z przykładów Kivent:

maho@dlaptop:~/workspace/kiventgames/kiventgame$ mkdir assets
(kivygame)
maho@dlaptop:~/workspace/kiventgames/kiventgame$ wget -nv -O assets/positionrotateshader.glsl https://raw.githubusercontent.com/kivy/kivent/master/examples/assets/glsl/positionrotateshader.glsl
2018-07-23 00:13:03 URL:https://raw.githubusercontent.com/kivy/kivent/master/examples/assets/glsl/positionrotateshader.glsl [1360/1360] -> "assets/positionrotateshader.glsl" [1]

W kiventgame.kv trzeba dopisać kilka systemów:

<KiventGame>:
    [...]
        PositionSystem2D:
            system_id: 'position'
            gameworld: gameworld
            zones: ['general']

        RotateSystem2D:
            system_id: 'rotate'
            gameworld: gameworld
            zones: ['general']

        RotateRenderer:
            gameworld: gameworld
            zones: ['general']
            shader_source: 'assets/positionrotateshader.glsl'

        CymunkPhysics:
            gameworld: root.gameworld
            zones: ['general']

Zaś w main.py, trzeba te system dodać do init_gameworld:

class KiventGame(Widget):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.gameworld.init_gameworld(['cymunk_physics', 'rotate', 'rotate_renderer', 'position'],
                                      callback=self.init_game)

No i dodanie obiektu: dodanie obiektu Freda, wymaga wypełnienia kolosalnego wprost słownika, gdzie nie ma żadnych wartości domyślnych. Literalnie każdy jeden parametr musi być wypełniony.

    def draw_objects(self):
        create_dict = {
            'cymunk_physics': {
                'main_shape': 'circle',
                'velocity': (0, 0),
                'position': (300, 300),
                'vel_limit': 250,
                'mass': defs.mass,
                'col_shapes': [{
                    'shape_type': 'circle',
                    'elasticity': defs.elasticity,
                    'collision_type': defs.fred_collision_type,
                    'friction': defs.friction,
                    'shape_info': {
                        'inner_radius': 0, 'outer_radius': 80,
                        'mass': defs.mass, 'offset': (0, 0)
                    }
                }],
                'angular_velocity': 0,  # literalnie wszystkie klucze muszą być wypełnione
                'ang_vel_limit': 0,
                'angle': 0
            },
            'rotate_renderer': {
                'texture': 'fred',
                'size': (145, 145),
                'render': True
            },
            'position': (300, 300),
            'rotate': 0
        }
        # id fred, to jest int - identyfikator Freda w systemach (Position, Renderer, Cymunk)
        self.id_fred = self.gameworld.init_entity(create_dict,
                                                  ['position', 'rotate',
                                                   'rotate_renderer',
                                                   'cymunk_physics'])

a uprzednio, tekstura musi być załadowana:

texture_manager.load_image('img/fred.png')
texture_manager.load_image('img/baloon.png')

Tag: 02-objects-01

Żeby dodać balony, w/w funkcję zmieniam na bardziej uniwersalną:

class KiventGame(Widget):
    def __init__(self, **kwargs):
        [...]
        self.ids_baloons = []

    [...]
    def draw_object(self, texture, pos, size, collision_type=defs.default_collision_type):
        w, h = size
        R = (w + h) / 4
        create_dict = {
            'cymunk_physics': {
                'main_shape': 'circle',
                'velocity': (0, 0),
                'position': pos,
                'vel_limit': 250,
                'mass': defs.mass,
                'col_shapes': [{
                    'shape_type': 'circle',
                    'elasticity': defs.elasticity,
                    'collision_type': collision_type,
                    'friction': defs.friction,
                    'shape_info': {
                        'inner_radius': 0, 'outer_radius': R,
                        'mass': defs.mass, 'offset': (0, 0)
                    }
                }],
                'angular_velocity': 0,  # literalnie wszystkie klucze muszą być wypełnione
                'ang_vel_limit': 0,
                'angle': radians(180)
            },
            'rotate_renderer': {
                'texture': texture,
                'size': size,
                'render': True
            },
            'position': pos,
            'rotate': 0
        }
        # id fred, to jest int - identyfikator Freda w systemach (Position, Renderer, Cymunk)
        return self.gameworld.init_entity(create_dict,
                                                  ['position', 'rotate',
                                                   'rotate_renderer',
                                                   'cymunk_physics'])

    def draw_objects(self):
        self.id_fred = self.draw_object('fred', (300, 300), (145, 145), defs.fred_collision_type)
        for __ in range(defs.num_baloons):
            self.ids_baloons.append(self.draw_object('baloon',
                                                     (randint(300, 400), randint(300, 400)),
                                                     (50, 50)))

Input

To nadal Kivy, więc input będzie niemal identyczny jak w KivyGame:

class KiventGame(Widget):
    def __init__(self, **kwargs):
        [...]
        EventLoop.window.bind(on_key_up=self.on_key_up)
    [...]
    def on_key_up(self, __window, key, *__, **___):
        dx, dy = 0, 0
        if key == Keyboard.keycodes['up']:
            dy = 1500
        elif key == Keyboard.keycodes['down']:
            dy = -1500
        elif key == Keyboard.keycodes['left']:
            dx = -1500
        elif key == Keyboard.keycodes['right']:
            dx = +1500

        fred = self.gameworld.entities[self.id_fred]
        fred.cymunk_physics.body.apply_impulse(Vec2d(dx, dy))

    def on_touch_up(self, touch):
        fred = self.gameworld.entities[self.id_fred]
        vdir = Vector(touch.pos) - fred.position.pos
        fred.cymunk_physics.body.apply_impulse(vdir * 5)

Tag: 03-input

Ściany

Podobnie jak w Kivy:

class KiventGame(Widget):
    def __init__(self, **kwargs):
        [...]
        Window.bind(on_resize=self.on_resize)

    def init_game(self):
        [...]
        self.create_walls()

    [...]
    def create_walls(self):
        segments = self.wall_segments()

        shapes = []

        for (v1, v2), goal in zip(segments, [False, False, False, True]):
            coltype = defs.goal_collision_type if goal else defs.default_collision_type
            shapes.append({'shape_type': 'segment',
                           'elasticity': defs.elasticity,
                           'collision_type': coltype,
                                'friction': defs.friction,
                                'shape_info': {'a': v1, 'b': v2, 'radius': defs.wall_size,
                                               'mass': 0}  # mass: 0 => obiekt statyczny
                           })
        self.id_walls = self.gameworld.init_entity({
                        'cymunk_physics': {
                            'main_shape': 'shape',
                            'velocity': (0, 0),
                            'position': (0, 0),
                            'vel_limit': 0,
                            'mass': 0,
                            'col_shapes': shapes,
                            'angular_velocity': 0,
                            'ang_vel_limit': 0,
                            'angle': 0
                        },
                        'position': (0, 0),
                        'rotate': 0
                  }, ['position', 'rotate', 'cymunk_physics'])

    [...]
    def on_resize(self, _win, _w, _h):
        if self.id_walls is None:
            return
        walls = self.gameworld.entities[self.id_walls]
        for (v1, v2), shape in zip(self.wall_segments(), walls.cymunk_physics.shapes):
            shape.a = v1
            shape.b = v2
        self.physics.space.reindex_static()

    def wall_segments(self):
        w, h = self.size
        R = defs.wall_size
        return [(Vec2d(-2 * R, -R), Vec2d(w + 2 * R, -R)),
                (Vec2d(-R, -2 * R), Vec2d(-R, h + 2 * R)),
                (Vec2d(-2 * R, h + R), Vec2d(w + 2 * R, h + R)),
                (Vec2d(w + R, h + 2 * R), Vec2d(w + R, -2 * R))]

Oraz CymunkPhysics musi uzyskać uchwyt w pliku .kv i mieć dostęp do self.physics:

<KiventGame>:
    [...]
    physics: physics
    GameWorld:
        [...]
        CymunkPhysics:
            id: physics

Wygląda zupełnie jak w wersji Kivy:

like in Kivy

Tag: 04-walls

Dodajemy cel - dotarcie do ściany, użycie Screen Managera, Plansza success

Ponownie: analogicznie jak w Kivy:

<KiventGame>:
    [...]
    GameScreenManager:
        GameScreen:
            name: 'main'
        GameScreen:
            name: 'success'
            BoxLayout:
                pos: root.pos
                size: root.size

                Label:
                    font_size: '100sp' 
                    text: "Success!"

i w main.py:

class KiventGame(Widget):
    [...]
    def init_game(self):
        [...]
        self.physics.space.add_collision_handler(defs.goal_collision_type,
                                                 defs.fred_collision_type,
                                                 self.goal_reached)

    def setup_states(self):
        self.gameworld.add_state(state_name='main',
                                 systems_added=[],
                                 systems_removed=[], systems_paused=[],
                                 systems_unpaused=[],
                                 screenmanager_screen='main')
        self.gameworld.add_state(state_name='success',
                                 systems_added=[],
                                 systems_removed=[], systems_paused=['cymunk_physics'],
                                 systems_unpaused=[],
                                 screenmanager_screen='success')
    [...]
    def goal_reached(self, _space, _arbiter):
        self.gameworld.state = 'success'

Zauważmy, że tutaj nie ma zmiany nazwy ekranu w ScreenManagerze, ale jest dodanie stanu GameWorld, gdzie przy okazji można zapauzować fizykę.

Interesujące jest także to, że w przypadku Kivy - ekran z planszą "Sukces" zastępował grę, tutaj domyślnie plansza jest nad grą:

tak to wygląda

Stress test

Podobnie jak w przypadku Kivy, stress test dla zwiększanej liczby balonów (20, 40, 80....)

Widać ogromną różnicę. O ile w przypadku aplikacji w Kivy proces brał cały czas od 90% CPU wzwyż, tutaj mamy ok. 20% i dopiero przy 320 balonach mamy 30% CPU i wiatraki się włączyły. Mimo wszystko nie ma mowy o jakimkolwiek zacinaniu.

320 balonów

Przy 1280 balonach musiałem zmniejszyć rozmiar balonu do 15x15, bo nie mieściły się na planszy.

Przy 5000 balonach dopiero gra straciła używalność, przez kilka długich sekund, kiedy "balony" były "na kupie", wiatraki szalały a na ekranie nic się nie działo, potem jednak CPU spadło do 60% i gra odzyskała płynność.

5000 balonów

Kivy vs Kivent - podsumowanie

Mimo że w Kivy wszystko trzeba sobie samemu powiązać, jest o wiele prościej i o wiele szybciej jest napisać prototyp gry.

W Kivent znowu, żeby rozpocząć, trzeba się rozpisać że aż się odechciewa. Dodatkowo w Kivent występują tego typu błedy:

[INFO   ] [Base        ] Leaving application in progress...
 Traceback (most recent call last):
    [...]
   File "main.py", line 23, in init_game
     self.draw_objects()
   File "main.py", line 66, in draw_objects
     ['position', 'renderer', 'cymunk_physics'])
   File "kivent_core/gameworld.pyx", line 436, in kivent_core.gameworld.GameWorld.init_entity
   File "kivent_cymunk/physics.pyx", line 466, in kivent_cymunk.physics.CymunkPhysics.create_component
   File "kivent_core/systems/gamesystem.pyx", line 260, in kivent_core.systems.gamesystem.GameSystem.create_component
   File "kivent_cymunk/physics.pyx", line 456, in kivent_cymunk.physics.CymunkPhysics.init_component
   File "kivent_cymunk/physics.pyx", line 289, in kivent_cymunk.physics.CymunkPhysics._init_component
   File "kivent_core/systems/staticmemgamesystem.pyx", line 441, in kivent_core.systems.staticmemgamesystem.ZonedAggregator.add_entity
 IndexError: list index out of range

i wiem, że bład polega na tym że mam źle wypełniony create_dict, ale komunikat o błędzie jest absolutnie bezużyteczny w tropieniu, co jest przyczyną i trzeba macać metodą prób i błedów.

Z drugiej strony, w Kivent, jeżeli już się przemożemy, jest o wiele, wiele wydajniej. Dodatkowo, jest cała masa gadżetów które nie zostały tutaj opisane, jak skalowanie mapy (ręczne i automatyczne), przesywanie dwoma palcami, śledzenie obiektu (kamerą), wiele kamer w widoku, particles i wiele innych.

Reasumując: jeżeli chcesz na szybko machnąć prototyp, wybierz Kivy. Jeżeli jednak coś więcej, przepisz to potem na Kivent.