Conception du moteur de script d'Outer Wonders

Conception du moteur de script d'Outer Wonders

#outer-wonders #technologie

Bonjour ! Dans Outer Wonders, notre jeu d'aventure à base de labyrinthes glissants, le scénario aura toute son importance. Afin de pouvoir de présenter ce scénario, il est nécessaire de disposer d'un outil de scripting, qui permet par exemple de créer des cinématiques. Mais comment fonctionne un tel outil ? Ce billet de blog technique vous propose d'en savoir plus sur ce sujet !

L'intérêt du scripting visuel

Il existe de nombreuses façons de mettre en place du scripting au sein d'un jeu ou d'un moteur de jeu. La majorité des studios de jeux vidéo se tournent, soit vers les outils proposés avec les moteurs de jeux existants (Unity avec le langage C#, Unreal Engine avec le langage C++, Godot avec le langage GDScript, etc.), soit vers des langages de programmation pensés pour les scripts, tels que Lua ou, plus rarement, JavaScript ou Python.

Une alternative à cette vision, particulièrement utile dans le cadre d'un moteur de jeu interne, consiste à mettre en place du scripting visuel ; autrement dit, une forme de scripting qui ne nécessite pas d'apprendre un langage de programmation pour être utilisé ! L'outil éducatif Scratch, par exemple, peut être considéré comme un moteur de scripting visuel, car il permet de programmer de manière purement visuelle.

Prenons par exemple la très courte cinématique suivante.

Cinématique dans laquelle Bibi roule vers la droite jusqu'à atteindre un bloc de pierre. Son chemin passe par la queue d'un castor, ce qui fait enfler ce castor. Ensuite, Bibi tente de revenir en arrière, mais son déplacement est bloqué par le castor enflé.

Cette cinématique pourrait être représentée par le script visuel suivant.

Algorigramme de la cinématique précédente

C'est donc en cela que consiste le scripting visuel : pouvoir mettre en place des événements scénarisés de façon à bien les visualiser. Mais dans notre cas, nous sommes allés plus loin.

Une méthode encore plus visuelle

Une autre façon de représenter la cinématique passerait par son découpage en 3 étapes :

  1. Bibi apparaît.
  2. Bibi se déplace vers la droite : en roulant sur le castor, il le fait enfler (sous-étape 2a), avant de poursuivre son déplacement jusqu'à atteindre le bloc de pierre (sous-étape 2b).
  3. Bibi revient vers la gauche, jusqu'à atteindre le castor, qui met fin à son déplacement.

Ces étapes peuvent être représentées visuellement par des icônes intégrées à la scène comme ci-après.

Animation montrant, via des icônes placées dans le décor de la cinématique précédente, les étapes de la cinématique telles que précédemment détaillées.

Ainsi, la personne en charge de la création de la cinématique peut mieux visualiser le déroulement de la cinématique au sein du décor.

Ces icônes, ou plutôt ces entités comme nous les appelons, représentent des actions à exécuter. Les entités peuvent être placées dans un niveau et liées entre elles via notre éditeur de niveau.

Voyez par exemple ci-dessous la configuration du point de départ de la cinématique via notre éditeur de niveau, de façon à ce que Bibi effectue un déplacement vers la droite dès que l'énigme commence.

Capture d'écran de l'éditeur de niveau montrant l'interface de modification des propriétés du point de départ d'une énigme. La propriété "Action", représentant l'action à effectuer au démarrage de l'énigme est définie comme étant un déplacement de Bibi vers un point situé à droite.

Ce fonctionnement, inspiré des premières moutures du Serious Editor, l'éditeur de niveau proposés aux joueurs de la série des Serious Sam, a été utilisée avec succès jusqu'ici pour mettre en place aussi bien les éléments de logique des énigmes que les cinématiques.

Plus en profondeur : exécution des scripts grâce au langage Rust

Spécifier les actions à exécuter via l'éditeur de niveau est une chose, être en mesure de les exécuter en jeu en est une autre. Le fonctionnement exact de l'exécution de script est complexe et au-delà de la portée de ce billet de blog ; en revanche, nous vous proposons une présentation des éléments essentiels, sous une forme simplifiée, si la programmation vous intéresse. Si ce n'est pas le cas, nous vous recommandons de passer cette longue section.

Tout d'abord, les scripts sont des ensembles d'actions. Il nous est donc nécessaire de disposer d'un concept d'action au niveau du code.

D'intuition, nous pourrions ainsi définir une énumération TypeAction, comme suit :

Code Rust
pub enum TypeAction {
DeplacementJoueur {
x_destination: u16,
y_destination: u16
},
RemplacementSprite {
// IDSprite est un type de variable identifiant
// un sprite au sein de la scène.
id_sprite_a_remplacer: IDSprite,
// IDAssetSprite est un type de variable identifiant
// un type d'élément visuel parmi ceux proposés
// par le jeu. Exemple : le sprite animé de castor.
nouvel_id_asset_sprite: IDAssetSprite
},
// Autres actions possibles…
}

Nous serons amenés à compléter cette définition. Dans l'exemple de notre cinématique, certaines actions doivent être exécutées "en même temps". Par exemple, lorsque Bibi commence à se déplacer vers la droite, le script n'attend pas que le déplacement de Bibi soit terminé avant d'exécuter d'autres actions ; une fois Bibi arrivé au niveau du castor, le script exécute l'action consistant à gonfler le castor. Et pourtant, dans ce même laps de temps, Bibi est toujours en train d'effectuer son déplacement vers la droite ! Cette notion d'exécution "en même temps" est ce qu'on appelle une exécution asynchrone.

Cette exécution est même en partie guidée par des événements, c'est-à-dire que certaines actions ne seront réalisées que dès qu'un événement particulier se produira (même si nous ne pouvons pas prédire quand il se produira). Par exemple, dès que Bibi aura roulé sur le castor, le castor se gonflera.

Pour mettre en œuvre un système de scripting offrant de telles possibilités, plusieurs éléments sont nécessaires :

  • une détection des événements. Par exemple, un système permettant de savoir quand Bibi a atteint le point où le castor se situe. Ou bien un système permettant de savoir quand Bibi a terminé son déplacement vers la droite ;
  • pouvoir faire le lien entre ces événements et les actions correspondantes à exécuter.

Pour ce faire, nous pouvons mettre en place une structure indiquant, pour chaque case d'un niveau, quelle action cette case déclenche.

Code Rust
pub struct GrilleActionsCases {
// Tableau de "largeur × hauteur" entrées indiquant chacune
// l'action éventuelle à exécuter lorsque Bibi passe sur
// la case correspondante.
actions_cases: Vec<Option<TypeAction>>
}

Ainsi, en cours de déplacement, au moment où Bibi atteint une case, il suffit de récupérer l'action correspondante éventuelle dans cette structure et de l'exécuter.

Dans le cas du système que nous avons mis en place, nous avons une solution un peu plus flexible. Au moment où Bibi débute un déplacement, nous sommes en mesure de déterminer à quel moment précis Bibi atteindra le castor, et à quel moment précis le déplacement de Bibi sera terminé. Nous mettons en place une file d'attente d'actions à exécuter, avec une information temporelle associée, comme représenté par la frise chronologique ci-dessous. Lorsqu'un déplacement débute, les actions déclenchées par le passage sur des cases sont ajoutées à cette file d'attente.

Frise chronologique des événements à exécuter lorsque Bibi débute son déplacement vers la droite. On y voit d'abord le gonflement du castor, puis le déplacement vers la gauche que Bibi débutera une fois son premier déplacement terminé.

En termes de code, cela se traduit par les structures Action et FileAttenteActions suivantes représentant respectivement une action dont on sait précisément quand elle sera exécutée, et une file d'attente de telles actions :

Code Rust
pub struct Action {
pub type_action: TypeAction,
pub instant_execution: Instant
}

pub struct FileAttenteActions {
actions: VecDeque<Action>
}

impl FileAttenteActions {
pub fn new() -> FileAttenteActions {
FileAttenteActions {
actions: VecDeque::new()
}
}

pub fn ajouter_action(&mut self, action: Action) {
// Une recherche par dichotomie serait plus efficace.
let indice = self.actions.iter()
.position(|action_file| action_file.instant_execution > action.instant_execution)
.unwrap_or(self.actions.len());
self.actions.insert(indice, action);
}

pub fn extraire_prochaine_action(&mut self, instant: Instant) -> Option<Action> {
if self.actions.front()?.instant_execution <= instant {
Some(self.actions.pop_front().unwrap())
} else {
None
}
}
}

Ainsi, le début d'un déplacement déclenchera une prédiction qui ajoutera les éventuelles actions à la file d'attente via la méthode FileAttenteActions::ajouter_action. Aussi, au fur et à mesure du déroulement de la partie, une vérification régulière de la file d'attente sera effectuée en récupérant des éventuelles actions à exécuter via la méthode FileAttenteActions::extraire_prochaine_action. Les actions ne seront fournies par la file d'attente et exécutées qu'une fois leur moment d'exécution (champ instant_execution de Action) atteint.

Il reste une dernière problématique à résoudre. Comme précédemment précisé, nos scripts comporteront parfois des actions qui devront être exécutées en réaction à des événements. Or, les actions pourront elles-mêmes déclencher des événements, à retardement. Dans notre cinématique par exemple, le déplacement de Bibi vers la droite déclenche un événement à retardement, correspondant à la fin de ce déplacement, suite auquel une autre action (se déplacer cette fois-ci vers la gauche) est exécutée. Ce qui veut dire que des actions devront parfois être capables de référencer d'autres actions ! Il est donc tentant se redéfinir l'énumération TypeAction à peu près comme suit :

Code Rust
pub enum TypeAction<'a> {
DeplacementJoueur {
x_destination: u16,
y_destination: u16,
action_fin_potentielle: Option<&'a TypeAction<'a>>
},
RemplacementSprite {
id_sprite_a_remplacer: IDSprite,
nouvel_id_asset_sprite: IDAssetSprite
},
// Autres actions possibles…
}

Mais le compilateur de langage Rust refusera un tel code. De manière générale, Rust n'autorisera pas une action à conserver durablement une référence directe à une autre action ; cela déclenchera des erreurs à la compilation, liées au borrow checker, une sécurité de Rust qui vise à assurer que votre programme n'accédera pas à la mémoire vive de l'ordinateur d'une façon potentiellement dangereuse.

C'est pourquoi, au chargement d'une énigme, notre système place l'ensemble des actions déclenchables dans un pool d'actions, et, pour référencer chaque action de façon plus sûre, nous utilisons un identifiant numéroté, plutôt qu'une référence directe, permettant de le retrouver dans ce pool.

Nous pouvons alors redéfinir notre énumération TypeAction correctement. Le code suivant présente une structure PoolActions représentant le pool d'actions, suivie d'une nouvelle définition de notre énumération TypeAction, suivie d'une structure ContexteExecutionActions chargée de l'exécution proprement dite des actions.

Code Rust
pub struct PoolActions {
actions: Vec<TypeAction>
}

impl PoolActions {
#[inline]
pub fn recup_action(&self, id_action: IDAction) -> Option<&TypeAction> {
self.actions.get(id_action)
}
}

pub type IDAction = usize;

#[derive(Clone)]
pub enum TypeAction {
DeplacementJoueur {
x_destination: u16,
y_destination: u16,
id_action_fin_potentielle: Option<IDAction>
},
RemplacementSprite {
id_sprite_a_remplacer: IDSprite,
nouvel_id_asset_sprite: IDAssetSprite
},
// Autres actions possibles…
}

pub struct ContexteExecutionActions<'a> {
pub instant: Instant,
pub pool_actions: &'a PoolActions,
pub file_attente_actions: &'a mut FileAttenteActions,
pub joueur: &'a mut Joueur,
// Autres éléments sur lesquels une action pourrait agir…
}

impl<'a> ContexteExecutionActions<'a> {
pub fn executer_actions_en_attente(&mut self) {
while let Some(action) = self.file_attente_actions.extraire_prochaine_action(self.instant) {
match action.type_action {
TypeAction::DeplacementJoueur {x_destination, y_destination, id_action_fin_potentielle} => {
// demarrer_deplacement_joueur est une fonction externe
// qui… démarre un déplacement du joueur.
let duree_deplacement: Duration = demarrer_deplacement_joueur(self.joueur, x_destination, y_destination);
if let Some(type_action_fin) = id_action_fin_potentielle.and_then(|id_action_fin| self.pool_actions.recup_action(id_action_fin)) {
self.file_attente_actions.ajouter_action(Action {
type_action: type_action_fin.clone(),
instant_execution: action.instant_execution + duree_deplacement
});
}
}
TypeAction::RemplacementSprite {id_sprite_a_remplacer, nouvel_id_asset_sprite} => {
// Remplacer le sprite.
},
// Autres actions possibles…
}
}
}
}

Ainsi, avant le rendu de chaque image, nous pouvons instancier un ContexteExecutionActions et appeler sa méthode executer_actions_en_attente pour que les actions de script dont nous venons d'atteindre le moment d'exécution soient exécutées. Notre système d'exécution de script est donc maintenant en mesure d'enchaîner les actions !

Ceci conclut ce billet de blog ! Suivez-nous sur Twitter, Facebook et Instagram pour suivre nos actualités et avoir une nouvelle énigme tous les mercredis ! Suivez aussi notre flux RSS pour être tenus au courant de la publication de nouveaux billets de blog. Un serveur communautaire Discord est également en cours de préparation.

À bientôt !