1

Como cheguei a arquitetura que tenho hoje na minha game engine

Gostaria de compartilhar com vocês um pouco da evolução arquitetural da engine que estou desenvolvendo em Rust e ouvir o feedback da comunidade. Durante o processo, acabei pivotando bastante a arquitetura até chegar em um modelo que me desse produtividade sem perder a robustez.

1. A Inspiração Inicial: Yew e o Modelo de Atores

No começo, minha principal inspiração veio do framework web Yew. Gostei muito da ideia de ter um type Message genérico para receber mensagens através de um método on_message.

Pensando nisso, tentei aplicar o Modelo de Atores: ninguém acessa o estado de ninguém diretamente; toda a comunicação é feita enviando mensagens para a engine, que roteia para o destinatário através de um ID. Cheguei a definir sistemas de eventos próprios e subscriptions.

O problema: Conforme o escopo cresceu, gerenciar atores e a comunicação entre as cenas ficou excessivamente complexo e verboso.

2. O Pivot: Composição, Macros e a influência da Godot

Para reduzir essa complexidade de gerenciamento, decidi misturar o sistema de mensagens com um modelo de Composição e Árvore de Nós, bebendo muito da fonte da arquitetura da Godot.

A ideia central foi facilitar o encaixe de componentes e GameObjects filhos. Por debaixo dos panos, os nós operavam com uma estrutura parecida com esta:

struct Node {
    logic: Box<dyn Logic>,
    id: Id,
    metadata: MetadataManager,
    transform: Transform2D,
    nodes: Vec<Node>
}

Para deixar a API amigável para quem vai fazer o jogo, tirei proveito das Macros do Rust. Olha como ficou a definição de uma cena principal (MainScene) compondo o Player, Plataformas e Componentes físicos/sonoros:

#[derive(GameObject)]
#[game(connect(collision: TriggerEvent))]
pub struct MainScene {
    #[base]
    base: Base,
    #[object]
    player: Player,
    #[object]
    platform: Platform,
    #[component]
    tilemap: Option<Tilemap>,
    #[component]
    sensor: Collider,
    #[component]
    coin_sound: Option<Sound>,
    #[component]
    music: Option<Sound>,
}

E internamente ele faz algo como:

fn dispatch_update(
        &mut self,
        ctx: &mut impl ::alone_engine::prelude::EngineApi,
        parent_base: &::alone_engine::prelude::Base,
        delta: f32,
    ) {
        let inherit = !self.base.top_level;
        self.base.transform.apply_parent(&parent_base.transform, inherit);
        if !self.is_started() {
            self.dispatch_start(ctx, parent_base);
        }
        self.update(ctx, delta);
        self.tilemap.update(ctx, &mut self.base, delta);
        self.sensor.update(ctx, &mut self.base, delta);
        self.coin_sound.update(ctx, &mut self.base, delta);
        self.music.update(ctx, &mut self.base, delta);
        self.objects.dispatch_update(ctx, &self.base, delta);
    }

3. O Melhor dos Dois Mundos: Mensagens + Componentes

Eu não abandonei a ideia de mensagens, mas agora elas vivem dentro do ciclo de vida dos GameObjects, integradas inclusive com tarefas assíncronas usando Tokio.

Aqui está um pedaço de como o Player funciona. Defino as mensagens que ele pode receber (PlayerMessage), inicio animações e até crio uma async task que se comunica com o próprio componente após um delay de 5 segundos:

pub enum PlayerMessage {
    AsyncTask,
}

impl GameObject for Player {
    type Message = PlayerMessage;
    
    fn start(&mut self, ctx: &mut impl EngineApi) {
        /* ... setup de animação e sprites omitido ... */
        
        let async_ctx = ctx.async_ctx();
        let id = self.base.id.clone();

        // Tarefa assíncrona disparando mensagem baseada em ID!
        ctx.async_task(async move {
            sleep_tokio(5.0).await;
            async_ctx.send(id, PlayerMessage::AsyncTask);
        });
    }

    fn fixed_update(&mut self, ctx: &mut impl EngineApi, delta: f32) {
        // Lógica tradicional de física e input do loop do jogo
        let gravity = 9.7;
        if !self.body.is_on_floor() {
            self.body.velocity.y += gravity * delta;
        }
        /* ... */
    }

    // A herança da minha inspiração inicial no Yew
    fn on_message(&mut self, _ctx: &mut impl EngineApi, _msg: &Self::Message) {
        println!("Mensagem assíncrona recebida no ciclo do jogo!");
    }
}

E para receber eventos de rede, posso simplesmente definir métodos que convertem qualquer payload serializável:

fn server_event(
    &mut self,
    ctx: &mut impl EngineApi,
    event: &NetworkEvent, // Conversível em Vec<u8>
    addr: Option<SocketAddr>,
) { ... }

Somente adicionando a macro ao GameObject:

#[game(server_subscribe(server_event: NetworkEvent))]

Conclusão

Essa mistura do encapsulamento do Modelo de Atores (via on_message e async_task) com a facilidade da árvore de Nodes (estilo Godot via macros) tornou o desenvolvimento das mecânicas muito mais rápido.

Carregando publicação patrocinada...