Retour au projet Foxvoid Engine
Le premier jeu du Foxvoid Engine : Tappy Plane

Le premier jeu du Foxvoid Engine : Tappy Plane

Publié le 21 août 2026

Tappy Plane : Validation de la cross-compilation (WASM & Android)

Pour éprouver l'architecture du Foxvoid Engine et m'assurer que le pont entre le C++ et Python était viable sur différentes plateformes, il me fallait un projet simple et concret. C'est ainsi qu'est né Tappy Plane, un clone de Flappy Bird utilisant les excellents assets libres de droits de Kenney.nl.

L'objectif principal n'était pas d'innover sur le gameplay, mais de valider le pipeline de cross-compilation. Je voulais m'assurer que le moteur pouvait s'exporter vers WebAssembly (WASM) pour les navigateurs et vers Android pour le mobile, le tout en conservant l'exécution native de mes scripts Python de gameplay.

Logique de jeu avec l'API Python

Le fonctionnement de Tappy Plane est trivial : une gravité constante tire l'avion vers le bas, et une impulsion vers le haut est appliquée lors d'un appui (ou d'un "tap" sur l'écran tactile). L'API Python du Foxvoid Engine rend cette implémentation extrêmement concise.

Voici le script complet contrôlant le joueur :

from foxvoid import *
from typing import Optional


class PlaneController(Component):
    __categories__ = {
        "Movement Settings": ["jump_force", "rotation_multiplier", "max_up_angle", "max_down_angle"],
        "Death Management": ["is_dead", "game_over_prefab"]
    }

    def __init__(self):
        super().__init__()

        self.jump_force = -350

        self.rotation_multiplier = 0.15
        self.max_up_angle = -30.0
        self.max_down_angle = 45.0

        self.is_dead = False
        self.game_over_prefab = "assets/prefabs/GameOverPanel.prefab"

        self._transform: Optional[Transform2d] = None
        self._rigidbody: Optional[RigidBody2d] = None
        self._animator: Optional[Animator2d] = None

    def start(self):
        self._transform = self.game_object.get_component(Transform2d)
        self._rigidbody = self.game_object.get_component(RigidBody2d)
        self._animator = self.game_object.get_component(Animator2d)

    def update(self, delta_time: float):
        if not self._rigidbody or not self._animator:
            return

        if self.is_dead:
            self._rigidbody.velocity.y = 0
            return

        if Input.is_action_pressed("takeoff"):
            self._rigidbody.velocity.y = self.jump_force
            audio_manager: Optional[GameObject] = Scene.find_object_by_name("AudioManager")
            if audio_manager:
                audio_source = audio_manager.get_component(AudioSource)
                if audio_source:
                    audio_source.play_sfx("jump")

        if self._rigidbody.velocity.y < 0:
            self._animator.play("takeoff")
        else:
            self._animator.play("fall")

        target_rotation = self._rigidbody.velocity.y * self.rotation_multiplier

        if target_rotation < self.max_up_angle:
            target_rotation = self.max_up_angle
        elif target_rotation > self.max_down_angle:
            target_rotation = self.max_down_angle

        self._transform.rotation = target_rotation

    def on_collision(self, collision: Collision2D):
        if collision.other and collision.other.name in ["SpikeTop", "SpikeDown", "Ground"] and not self.is_dead:
            audio_manager: Optional[GameObject] = Scene.find_object_by_name("AudioManager")
            if audio_manager:
                audio_source = audio_manager.get_component(AudioSource)
                if audio_source:
                    audio_source.play_sfx("game_over_collision")

            self.is_dead = True

            Globals.set_bool("game_over", True)

            self._animator.play("fall")
            self._rigidbody.gravity_scale = 0.0

            if self._transform:
                self._transform.rotation = self.max_down_angle

            camera_obj = Scene.find_object_by_name("Camera")

            if camera_obj:
                camera = camera_obj.get_component(Camera2d)
                if camera:
                    camera.shake(15.0, 0.3)

            GameObject.instantiate(self.game_over_prefab)

Le défi WebAssembly (WASM)

Porter le Foxvoid Engine sur le web impliquait de compiler à la fois le code C++ (via Emscripten) et l'interpréteur Python. Puisque Raylib supporte nativement le web, le rendu graphique, la boucle principale et l'audio se sont exportés de manière relativement transparente.

Le véritable défi technique résidait dans le binding pybind11. Il a fallu s'assurer que l'interpréteur Python intégré (compilé en WASM) puisse lire le système de fichiers virtuel d'Emscripten pour charger le script plane_controller.py et les assets de Kenney. Après avoir correctement configuré le système de Virtual File System (VFS) d'Emscripten au moment du build CMake, le jeu tourne à 60 FPS constants dans un navigateur, prouvant la viabilité du moteur pour des démos web. Exportation vers Android

La seconde étape était le portage sur Android en utilisant le NDK (Native Development Kit).

Ici, le travail a consisté à :

  • Unification des Inputs : Adapter le InputManager du moteur C++ pour que les événements tactiles de l'écran Android soient traduits en actions génériques (l'action "flap" appelée dans le script Python s'active désormais aussi bien avec la barre espace qu'avec un toucher sur l'écran).
  • Gestion des Assets : Packager correctement les dossiers assets/ et scripts/ dans l'APK. Le moteur C++ utilise l'API native d'Android (AAssetManager) pour extraire ces fichiers à la volée et les fournir à l'interpréteur Python et à Raylib au lancement.

Conclusion

Le développement de Tappy Plane a parfaitement rempli son rôle. Le Foxvoid Engine est désormais capable d'exporter un jeu 2D piloté par des scripts Python sur Windows, Linux, Android et sur le Web.

C'est une étape cruciale de franchie. La prochaine consistera à continuer d'éprouver ces systèmes pour mes futurs projets, notamment en préparant une architecture client-serveur et un rendu adapté pour mon RPG 3D low-poly, Project Elyria.