This seems to be a very recurring theme recently: actions that possible have multiple subactions and how should I combine them. Whole action subsystem of the game is probably the one that has gone through most different kinds of revisions. In a sense it’s fun, but on the other hand, sometimes I wish it were already good enough and I could work on something else. Coding how to walk around the map gets boring after couple of iterations after all.
But seems that this time I have actually made some significant progress and managed to code something that is smaller than previous version, easier to understand and has a lot less of moving parts. And yet it can be later configured to work with different kind of ruleset (one of the major ideas behind the game is to write it as an exercise on writing reusable code).
One big problem with previous version was how actions were defined and how they could be combined. For example, moving was one action and attacking was another (just like how they’re now). But it was only possible to combine whole actions. For example lunge was created by combining taking a step and then attacking. It wasn’t possible to control how much time these sub-actions took or if they had just a little bit different edge cases when combined compared to when executed alone.
Even worse, two characters switching places was mess of a code. The implementation tried to perform this by combining two movement actions and some kludge of code, but there were just tons of special cases that simply didn’t work. For example, two characters standing on traps that create area damage and then switching places would cause only one character to take damage from both traps when correct implementation would damage both characters.
For example, core portion of the moving code looks now like this:
(cond [(both-ai-characters? character₁ character₂) (do-monad-e [_ (switch-places-m character₁ direction) _ (trigger-traps-m character₁) _ (trigger-traps-m character₂)] (Right character₁))] [true (do-monad-e [_ (move-character-to-location-m character₁ new-location new-level) _ (add-tick-m character₁ Duration.fast) _ (trigger-traps-m character₁)] (Right character₁))]))))
It has two cases: upper one handles special case where two AI characters (currently only AI characters are allowed to switch places) want to switch places. Lower one is general case where character simply wants to take a step. Because I want both characters to be in the new locations before processing steps, trigger-traps-m is called only after moving has completed. Any function that has name ending in -m is designed to return an either monad.
On a side note, do-monad-e is a custom macro that works just like do-monad by chaining bunch of monadic actions together. In case any of them returns (Left error-message) that error message is printed on console. In the future I probably will add some proper logging, but now it’s enough that I get message printed on console. When everything goes fine, (Right value) is returned. do-monad automatically takes care of chaining functions together correctly and making sure that execution continues only if Right was returned. I’m using Hymn library for implementing this. I’m not really taking full advantage of the library as most of the functions are executed for the side-effects and not for their return value (thus _ as variable name).
Attack has quite a bit more things to take care of:
(do-monad-e [attack-type (attack-type-m character direction) damage-list (damage-list-m character attack-type) damage-result (apply-damage-list-m (. it target) damage-list) _ (raise-attack-hit-m character attack-type (. it target) damage-result) _ (handle-spending-ammunition-m character attack-type) _ (trigger-attack-effects-m character (. it target)) _ (check-dying-m (. it target))] (Right character))
Currently there are 3 different types of attacks: unarmed, melee and ranged. All of them can create more than one type of damage (piercing, cutting and crushing for regular weapons) and have other magical effects. Other than the inherit complexity of combat, the code is pretty straightforward and neatly hides all the gritty details behind abstractions.
Lunging forward is built from similar components as shown before. jump-forward-m and attack-m are functions defined just for lunge action and are mainly used to keep the main body of code more readable:
(do-monad-e [_ (jump-forward-m character direction) _ (if (> (. character hit-points) 0) (attack-m character direction) (Right character))] (Right character))
Again, we have do-monad-e to control execution. jump-forward-m moves character to given direction, updates their internal clock, triggers traps and checks for possible death:
(do-monad-e [_ (move-character-to-location-m character new-location (. character level)) _ (add-tick-m character Duration.instant) _ (trigger-traps-m character)] (Right character))))
If everything is still fine, character next executes an attack:
(do-monad-e [_ (attack-direction-m character direction) _ (cooldown-m character "lunge" Duration.very-slow) _ (add-tick-m character (attack-duration character))] (Right character))))
Here’s something that I had to ponder about quite a bit and I’m not completely sure yet. Cooldown of lunge skill is set only if the attack has been performed. If the character only jumped forward and didn’t perform an attack (maybe there was a wall?), cooldown isn’t set and they can perform lunge again on their next turn. Since moving while lunging is faster than while walking, this might allow crafty player to abuse the mechanic. Attack speed however is identical to regular attack.
So at least currently it looks like that I have managed to divide problem much better than previously. When new actions are added to the system, I’ll add new functions only for needed parts and reuse old ones. Slowly a DSL designed specifically for talking about actions should emerge.