Last week1 I mentioned that I'd be participating in the Acerola Jam 02 and things have gone swimmingly.
A departure from what is normal, usual, or expected, typically one that is unwelcome.
Immediate thoughts about specters and ghosts come to mind, as well as things that could be described as "cursed" like un-intuitive UI elements, statues in the middle of a jungle without any humans for miles, and various strange trees formations.
Nah, truth is, I knew what I wanted to do from going in. I wanted to make a Shin Megami Tensei-like RPG. Which was perfect for this theme with all of it's super natural elements, twists in settings, and just demon this and that, but it also got me thinking: what's more aberrant then erratic spell-casting? and suddenly the theme hit me.
Combine the erratic nature of Magicka's spells, with the dice-drafting mechanics of something like Sagrada, and finally toss in some story and visual inspiration from SMT IV, and we've got ourselves a high minded ambition! Worthy of two weeks of work!
My choice is from a position of experience, after all, I've done this before, well, MOSTLY I mean3 :
Last year, during Lent I made You Must Play, a deck-building card game where actions are sequenced by the player and the ordering matters. It was a huge learning experience where I found out things like:
Turns out making a card game is difficult, but having climbed that mountain once, I'm sure I can do it again, and better this time around. Also, Acerola has given a pretty unusual submission period of the jam, at least from my experience, of two weeks. This was in order to give people with busy schedules time to submit something, I however will be exploiting this to it's fullest extent in order to make a nice, sophisticated system.
Right now what exists is the battle system, a walkable 3D environment, some animations, and a lot of ideas that need implementing. Not a great place to be halfway through a jam? Think not! Allow me to explain.
This is an abstract view of what currently exists for the battle system.
Effect
from the spell and enemy attacks is called in order over the BattleStats
, which includes the data for the player, enemies, the ledger, and the world. This doesn't modify BattleStats
, but instead produces a Change
apply_effect
takes the Change
object and runs it against the StatusEffects
for the world
, playerStats
, and enemyStats
to determine if any element of the Change
is affected, like if resistances apply, damage should be doubled, or negated out right due to some effectapply_effect
takes the Change
and checks it against TriggeredEffects
to see if any effects of the world
, playerStats
, or enemyStats
are triggered, and if so, calls apply_effect
for those TriggeredEffects
such as the case where an enemy might hurt the player when they attack them in retaliation.apply_effect
commits the change to BattleStats
, adds it to the Ledger
, and tells the AnimationSystem
to play the Change
Change
contains a reference to the on-screen representation of the Stat
it references, so the animation player can call whatever animation is appropriate for that repr.In retrospect, it was much harder to come up with this then explain it after the fact, but I believe I've addressed all the core concerns raised in You Must Play:
Ledger
is a central fixture of the game's systems. No effect happens without their being a log entry for it4, so I know exactly how BattleStats
got the way it did, show that information to the player, and any effect that wants to track something that has occured can just check the Ledger
, which opens many unique possibilities for AI and effects like If the player did 75% damage in on hit, flee or when a fellow enemy is charging a big attack, buff them become very easy to write.AnimationPlayer
can just throw some tweens and particle effects over to achieve it's aims.TriggeredEffects
and StatusEffects
, apart of a very explicit life-cycle before in You Must Play, are now just naturally part of the rest of the systems:TriggeredEffects
are just Effects
called recursively, making them easy to write whenever and and reason about.StatusEffects
are a bit different in that they don't produce changes, but just alter them instead, this makes sense when you consider that anything you'd think to do with a status, like I reflect all attacks can be done with a combination of a I don't take damage StatusEffect
to If I was about to take damage, do that amount back TriggeredEffect
Nothing in life is free. Especially composable, extensible, easy-to-work with systems. Thankfully, it's all just data and data is easy if you take a second to sit down and think about it.5
For the most part I hope this is self-evident if you understood how the BattleSystem
is written above, but some fun key points should stand out:
EffectAndTarget
is a sort of "glue" structure that holds together an Effect
and it's TargetCandidates
. When apply_effect
is called, it also passed a list of targets for that Effect
, either from the players selection or the enemies AI picks(currently just a random Attack
), so the CombatManager
reads TargetCandidate
to know what targets to pass, like EVERYONE
means everyone, ALL_ENEMIES
and SELECTED_ENEMIES
mean what they suggest, and so forth.Attack
Can be just about anything, it's really just a grouping of Effects
and their potential targets. This decoupling allows me to create attacks like deal 5 damage 5 times or deal 10 damage to everyone else, then deal 5 damage to self without having to write something custom.Spell
is a list of attacks instead of one Attack
because… I made a mistake and should fix that!LedgerEntries
are not a whole taxonomy of log classes because my instincts told me it'd be better for it to be one flat class with all the possible fields. We'll see how that plays out.Flexibility, re-use, and rapid iteration:
This is everything I need to do in order to implement a simple enemy attack. If I wanted to, I could also have the enemy deal damage back to himself after dealing damage to the player, or propose whatever other change, with whatever targets he wants and all I have to do is specify in the attack definition within whatever parameters I need. It's pretty sweet and I'm looking forward to seeing what combinations I can create. TriggeredEffects
and StatusEffects
follow similar patterns.
As stated previously, a TriggeredEffect
is just an Effect
that has some trigger, a condition
. The target for the effect is decided by the TriggeredEffect
. I'll probably refactor it to be a list of effects instead for greater flexibility, but this is fine for now. If the trigger isn't activated, apply_triggered_effect
just returns an empty list. Also notice, I've re-used the DealDamage
effect! Already removing redundancy!
StatusEffects
are more like MutateEffect
since they don't produce new LedgerEntries
like TriggeredEffect
they just mutate whatever LedgerEntry
was passed to it and call it a day. In that sense, they're not true Effects
and don't extend the Effect
class. And yes, if you stack the same status effect on an enemy over and over again it will apply each time:
Some future things I'm thinking about implementing:
StatusEffect
that decrease the effectValue
of a DAMAGE
effect and appends an effect afterwards for ARMOR_LOST
per damage taken.Stat
per stack at the end of a turn. Effects
can basically be applied at anytime so this isn't a huge lift.Okay, you got me, all of this isn't free even if I have grand plans that have worked out. Having Effects
create Changes
and LedgerEntries
means that I still have to use those changes to, you know, change the state?
# Matching the LedgerEntry type
Enums.EntryType.EFFECT_APPLIED:
# Matching the change effect in the LedgerEntry
match change_entry.effect_type:
Enums.EffectType.DAMAGE:
# Writing the change to battleStats
for stat : Stats in change_entry.effect_target:
stat.health -= change_entry.effect_value
# but wait, there's more!
# Add the entry to the ledger
ledger.append(change_entry)
# Tell everyone about the new change
EventBus.new_ledger_entry.emit(change_entry)
# Play the change
# Animation can't just wait for the `new_ledger_entry` signal because we have to
# Wait for the animation player to finish
await animation_player.play_ledger_entry(change_entry)
# Take care of any Stats that died and should no longer be considered
# Also, we emit death LedgerEntries here that may also need to be played
await cleanup_dead_targets(battle_stats,targets,caster,ledger,animation_player)
Yup, the dirty part of all this is that there exists an abstraction below Effect
called EffectType
that actually determines the mutation applied to the BattleStats
. In some sense, a good 90% of the game logic is going to go in one big switch
statement. But before you think this is the worst thing ever, consider how many different types of state changes actually occur in card games?
All things considered, once you've reasoned your way through how the life cycle of how cards operate with applying/mutating/triggering effects, the actual changes that take place to state mostly boil down to changing health values, shuffling things(quite literally sometimes) around, and ticking some clocks up and down. So, I really don't mind having all this stuff in one switch. Frankly, I think it's simpler this way.
Now animations are a bit of a different story. Each LedgerEntry
has to be played in some way so the player can see things exploding and how their decisions are affecting the world, so for every case in that switch, we also need a case in the animation player with an accompanying animation.
func death_animation(enemy_repr: Node2D) -> Signal:
"""Play the death animation for an enemy"""
var tween : Tween = create_tween().set_trans(Tween.TRANS_CUBIC)
# Just fade the enemy into nothingness
tween.tween_property(enemy_repr,"modulate",Color(0.0,0.0,0.0,0.0),0.5)
# Pass the 'finished' value back so the caller knows when the animation is done.
return tween.finished
Now the truth is, I can do a fair bit of massive cheating here by just using programmatic animations over a single enemy texture and then it doesn't matter what enemy it is, I just automatically have all the animations I created thus far.6 You can also see that the enemy_repr
is there, passed from the LedgerEntry
, so no weird reach around had to occur for the AnimationPlayer
to do it's job, it just HAS that data from the log entry.
The one annoying limitation is that animations are always played after an effect is applied and then waited on so I can't, for example, combine a lightning strike with a death animation to create a struck by lightning so hard the enemies skeleton shows, then they crumble to ash, dead animation because the DAMAGE
and the DEATH
entries are two separate logs! I'm considering potential fixes for this, such as having buffering logic for certain animations like all attacks and critical damage, but I believe I need to start making some content because I have until Wednesday evening to make something complete with these systems and this is where I draw the line.
My plan is for this jam is to have a simple 3D environment the player explores, picks of shiny rocks, finds points of interest, and encounters enemies in.
My experience with 3D is slightly above beginner at this point. I have a Blender layer for my ergo keyboard and I don't really struggle to make shapes anymore, but that doesn't mean I can design good spaces, in my mind, that takes a lot of continuous iteration and time I don't have. I've seen what alleged "hobbyist" modders can do with GZdoom, let alone the Counter Strike 2 brush editor, I can't compete with that at the moment7. My hack is going to be fog, akward textures, low light, and a future screen-space shader8 that'll hopefully push the aesthetic bar high enough to be creepy.
For movement, in order to pay homage to previous SMT games9, I've restricted it to the four cardinal directions, plus strafing left and right, this means environments have to be designed on an explicit grid, 2x2 meters here. I don't support stairs at the moment and don't think I'll bother, rather just teleport the player after some footstep sfx.
It's gonna be a grind until Wednesday baby. My do outs are:
I have a play test Saturday morning, so I'm compelling myself to finish a playable demo by then and plan to spend roughly eight hours a day on this. Time to ball.
StatusEffect
doesn't have a log entry. I'm waiting to rename it to MutateEffect
since that's more accurate to it's role, and I want to mature the animations a bit before I do this as well as experiment with how much info to show to the player.