As I was writing unit tests for a perilous, convoluted part of the game logic, which I wanted to lock in place as I moved forward, I realized that to test one relatively small part of the code, I would need to create both World, the main entity of the Entity-Component System “specs”, as well as Image, which is tied to the Context of the 2D game dev “ggez” crate. World is heavy by itself to fire up for a simple unit test, but Images themselves may not even be feasible, as they are glued to the graphical context (no graphics should run during unit tests), and they are tied to a single thread, while the unit tests run in all CPUs.
Therefore, I came to the dreaded conclusion: I needed to refactor their entire code into traits (interfaces). Traits are contracts that encapsulate a behavior without implementing anything. If you program to traits (interfaces) instead of to classes, you are working with future, potentially unimplemented structures whose details are irrelevant to you or the compiler, because they are only forced to fulfill a contract (the trait/interface). It’s like telling an animal to say something; a dog would bark, a cat would meow, but both have fulfilled the contract of “talking,” which is apparently all you cared about.
It just happens that traits in Rust involve generics, and generics in Rust are unholy. Due to Rust’s welcome but tough borrowing rules and lifetime whatevers, Refactoring any behavior to traits involves dealing with bizarre generic declarations, and worse yet, nearly incomprehensible lifetime signatures.
Behold the horror:
pub struct MainState<‘a, T: ImageBehavior + ‘static + std::marker::Send + std::marker::Sync + Clone, U: WorldBehavior<T>> {
active_stage: Option<GameStage>,
base_state: BaseState<‘a, T, U>,
expedition_state: ExpeditionState<‘a, T, U>,
encounter_state: EncounterState<‘a, T, U>,
shared_game_logic: Arc<Mutex<SharedGameLogic>>,
}
That’s the definition of MainState, the head honcho of the state machine that figures out which other stage needs to update, or draw on the screen. It declares that it’s somehow involved, to start, with an ImageBehavior contract. Every image throughout the program, except in the launcher, is now unaware of what type of structure it’s actually dealing with, except that it fulfills the contract of being static (I assume images are fixed in memory or something), they Send and Sync for multithreading, and can be Cloned. That’s the hardest one. Then we have WorldBehavior, which abstracts away the entirety of the “specs” Entity-Component System into a wrapped class. The resulting contract for WorldBehavior, if I may say so myself, is a thing of beauty:
pub trait WorldBehavior<T: ImageBehavior + std::marker::Sync + std::marker::Send + Clone> {
fn new(world: World) -> Self;
fn join_coordinates_and_tile_biomes(&self) -> Vec<((i32, i32), TileBiome)>;
fn retrieve_player_coordinates(&self) -> Option<(i32, i32)>;
fn retrieve_biome_at_player_position(&self) -> Option<Biome>;
fn retrieve_unique_character_traits_of_team_members(&self)
-> HashSet<CharacterTraitIdentifier>;
fn count_team_members(&self) -> u32;
fn calculate_average_mental_strain_of_team(&self) -> f32;
fn calculate_average_health_of_team(&self) -> f32;
fn set_player_position(&mut self, x: i32, y: i32);
fn retrieve_player_entity(&self) -> Entity;
fn retrieve_name_of_entity(&self, entity: Entity) -> Name;
fn retrieve_psychological_profile_of_entity(&self, entity: Entity) -> PsychologicalProfile;
fn retrieve_health_of_entity(&self, entity: Entity) -> CharacterHealth;
fn retrieve_team_members(&self) -> Vec<Entity>;
fn retrieve_team_members_except_player(&self) -> Vec<Entity>;
fn retrieve_photo_id_of_entity(&self, entity: Entity) -> PhotoId<T>;
fn move_direction(&mut self, direction: Direction);
fn create_entity(&mut self) -> EntityBuilder<‘_>;
}
With that contract in place, there’s no more need to deal with “specs” way of working. You want to retrieve the player entity? Call “retrieve_player_entity”. Do you want a calculation of the average mental strain of the entire team? Call “calculate_average_mental_strain_of_team”. Of course, the contract can be developed further as more information or calculations need to be gathered.
I’m at ease now that I have managed to refactor those two unit test blockers into behaviors, but it took hours, and if it weren’t for the indefatigable help of GPT-4, I wouldn’t have been able to do it. But thankfully there shouldn’t be any major obstacles to unit test every part of the code now, which should speed up development significantly.
A boring entry compared with the three previous ones, perhaps, but programming is a fight against entropy: the further you build your system, the harder it becomes to change. You have to stop every few days (if not once a day) to make sure that some part of the code isn’t rotting already.
Pingback: Interdimensional Prophets (Game Dev) #3 – The Domains of the Emperor Owl
Pingback: Interdimensional Prophets (Game Dev) #5 – The Domains of the Emperor Owl