052 - Combat

January 26, 2020

I'm traveling for a couple of weeks as of a couple of days ago so I didn't get as much done this week and I might have usually liked.


This week I introduced the combat system. I want combat (especially player vs. player combat) to be one of the highlights of the game - so this will be one of many times that I work on this system.

We're starting from humble beginnings - but hopefully we grow into something grand.

My general vision for the combat in the game is that it will be simple enough for beginners while still allowing more advanced players to demonstrate their skill through combining and better timing these simple attacks. I want to avoid complexity at all costs.

I'm aiming for simple rules that can be combined in interesting ways.

Chasing down a snail while attacking it. Snails aren't actually attackable at this time - this was just for the video.

Implementing Chasing

It took a bit of elbow grease to get the chasing while attacking to work properly - I'll explain.

Say we have two entities - we'll call them Attacker and Target.

Every game tick we run our MovementSystem. The gist of the MovementSystem is that it iterates through all entities that have the MovementComp and, if they need to move, moves them one square along their path.

Say Attacker is attacking Target and the following happens.

  1. MovementSystem processes Attacker. Attacker is in range. Nothing happens.

  2. MovementSystem processes Target. Target is trying to run away to a tile in the distance. MovementSystem moves the Target by one tile.

The Attacker now ends this game tick out of range. Even if the Attacker now begins chasing after the Target - it will always be out of range since the Target is also moving one tile per tick.

To avoid this we implemented a recursive system for processing entities in the MovementSystem. Let's revisit the above, but with the recursive implementation

  1. MovementSystem processes Attacker and sees that it is targeting the Target. MovementSystem always processes a target entity first.

  2. MovementSystem processes Target. Target is trying to run away to a tile in the distance. MovementSystem moves the Target by one tile.

  3. MovementSystem is now back at the Attacker. It sees that Target is out of range and moves the Attacker back into range.

// One of our test cases

/// # Start State
///
/// - All entities start in range of their target
/// - Entity 1 targets Entity 2
/// - Entity 2 targets Entity 3
/// - Entity 3 is targeting a tile
///
/// # Process order:
///  1. Entity 1 does not move
///  2. Entity 2 does not move
///  3. Entity 3 moves
///  4. Entity 2 should now move towards Entity 3
///  5. Entity 1 should now move towards Entity 2
///
/// ```text
/// ┌─────────┐     ┌─────────┐    ┌─────────┐
/// │         │     │         │    │         │
/// │Entity 1 │     │Entity 2 │    │Entity 3 │
/// │         │─────┼────▶    │────┼─────▶   │─────────▶
/// └─────────┘     └─────────┘    └─────────┘
/// ```
#[test]
fn chain_of_three() {
  // ... snippet ...
}

I implemented the chasing in a general purpose way so that all mechanics that involve an entity targeting another entity get this behavior for free.

Simplifying spawning of test entities

Made it a bit easier to create test entities for our integration tests.

There is now a ComponentsToSpawnEntity struct - basically a serializable / deserializable type that holds an option of every component and has a method to insert all of its components into the world.

/// Every component in the game in one struct that we can can use to spawn entities
#[derive(Debug, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
#[allow(missing_docs)]
pub struct ComponentsToSpawnEntity {
    // ... snippet ...
    pub attacker: Option<AttackerComp>,
    pub attackable: Option<AttackableComp>,
    // ... snippet ...
}

impl ComponentsToSpawnEntity {
    /// Insert an entity into the world with these components
    pub fn insert_with_entity(self, world: &mut World, entity: Entity) {
        // ... snippet ...
        maybe_insert_component(world, entity, self.attackable);
        maybe_insert_component(world, entity, self.attacker);
        // ... snippet ...
    }
}

fn maybe_insert_component<T: Component>(world: &mut World, entity: Entity, component: Option<T>) {
    if let Some(c) = component {
        let mut storage: WriteStorage<T> = world.write_storage::<T>();
        storage.insert(entity, c).unwrap();
    }
}

Right now I'm mainly using this from my integration tests to easily create test entities for the scenario under test (in this weeks case two entities in combat) - but in the future this will be one of the pieces that powers our entity editor tool that will allow us to create and modify entities in our world editor.

Neither the world editor nor entity editor exist yet - but they will some day - likely later this year.

Client side specs

I've begun re-organizing the client side game client around specs - writing tests as I go.

The early results are looking great and I can already see that the front-end will be much more pleasurable to work in when I'm done.

Given that I'm traveling for a couple of weeks I'm not sure if this will be finished over the next couple of dev journal entries - but I'll keep you posted.

Other misc work this week

  • Took an hour detour to remove the Send + Sync requirement on specs resources when the parallel feature is disabled specs #573 now that we're using specs in the web client (which doesn't support threading).

  • Added a way to test what gets logged from a system. We're using slog for our application logs.

    /// Log an error if we accidentally created an attacker without giving it a MainActionComp
    #[test]
    Fn log_error_if_no_main_action() {
        let mut world = create_test_world_without_entities();
    
        let target = create_target(&mut world);
        let attacker = create_attacker(&mut world, target, 1);
        remove_maintain::<MainActionComp>(&mut world, attacker);
    
        let (logger, logs) = test_logger();
        CombatSystem::new(logger).run_now(&world);
    
        let logs = logs.lock().unwrap();
        assert_eq!(logs.len(), 1);
    
        assert_eq!(
            logs[0].msg(),
            CombatSystemError::AttackerMissingMainAction.to_string()
        );
        assert_eq!(logs[0].level(), &Level::Error);
    }
    
  • Started researching alternatives to Substance Painter. I've been spoiled by Blender and have been surprised that it's difficult to find a scriptable PBR painting tool. I want to be able to automated export my PBR textures via a command line interface but SP doesn't have a headless mode

  • Fought the borrow checker for a while while trying to simplify part of the test suite and eventually learned that I was running into an issue called the sound generic drop problem. I ended up just needing to use some mutable pointers and a sprinkle of unsafe to solve my problems. This struggle ended up taking a few hours to figure out but now I can use my new knowledge the next time I run into something similar. I'm starting to gain a better understanding of when and how to use pointers in Rust and finding myself referencing the Rust nomicon a bit more as of late.

  • Entities drop the items in their inventory upon death

  • Updated ak CLI to run the up migrations when we restart our local dev and integration testing databases so that I don't have to run the command myself (which I usually forget to do)

  • Persisted the amount of remaining hitpoints to the database (as well as all other temporary stat changes)

  • Added exporting of bone groups to blender-armature so that I can animate the lower and upper body separately when needed without needing to remember the indices of every bone. I plan to use this during chases for giving the lower body a walk animation while the upper body is attacking.

  • Took some steps to simplify our skills code

Next Week

Client Side Palooza - making as much of a dent on migrating the game frontend to specs as I can while still making sure to appreciate my time traveling for the next couple of weeks!


Cya next time!

- CFN