This Week In Veloren 48
It's the last blog of 2019! With the year wrapping up, we take a look at some of the progress we've made. We've seen many new contributors join Veloren's development, as well as many milestones reached. @Adam also does a deep dive into character states and concepts surrounding them.
- AngelOnFira, TWiV Editor
Contributor Work
Thanks to this week's contributors, @AngelOnFira, @imbris, @Pfau, @Acrimon, @DylanKile, @LunarEclipse, and @Songtronix!
@Tub has been working on a new website layout, which can hopefully be implemented fully in the next few weeks. @Sharp has been continuing work on erosion and has just added debris flow erosion. @LunarEclipse added an Airshipper AUR package for easier installation in Arch. There was also a team meeting, as the release date for 0.5 is coming up!
2019
With 2019 coming to an end, it's important to look back at what we've accomplished. The images in this edition of TWiV reflect some major milestones we've hit. It's also a great way to see where we started, with just the bare-bones of the new engine. Of course, we should also remember the great contributors that have made this possible!
Note: this just counts contributors that committed to the GitLab repo. Many others have given input at meetings, created concept art, music, and stories. Big <3 to all of them as well!
(817) Joshua Barretto, (275) timokoesters, (162) imbris, (146) Forest Anderson, (139) jshipsey, (127) Pfau(90), (104) Acrimon, (78) imbris, (70) Pfau, (65) Monty Marz, (55) Marcel, (47) Piotr Korgól, (45) Louis Pearson, (42) Timo Koesters, (40) Joshua Yanovski, (31) Marcel Märtens, (26) Louis Pearson, (24) sxv20_, (23) Songtronix, (21) tommy, (20) Shane Handley, (20) Yeedo, (19) Cody, (17) Songtronix, (16) Vechro, (15) Forest, (15) LunarEclipse, (14) Dominik Broński, (14) Songtronix, (14) telastrus, (13) Songtronix, (13) scott-c, (12) haslersn, (12) robojumper, (11) Imberflur, (9) KyoZM, (9) liids, (9) scorpion(9979), (8) Andrew Pritchard, (8) Justin Shipsey, (7) Adam Fogle, (7) Christoffer Lantz, (6) Joseph Gerardot, (6) S Handley, (5) Cedric Hutchings, (5) Sheldon Knuth, (5) Sheldon Knuth, (5) soruh, (4) Adam Whitehurst, (4) Ahmed Ihsan Tawfeeq, (4) Maciej Ćwięka, (3) Cody, (3) JMS, (3) Tom Watson, (3) Wu Yu Wei, (3) stevedagiraffe, (3) telastrus, (2) Arash Outadi, (2) Demonic, (2) Geno, (2) J. R. Vidal F, (2) Nikita Puzankov, (2) Piotr, (2) Tesseract, (2) Treeco, (2) Vsevolod Timchenko, (2) kacper, (1) Adam Whitehurst, (1) Adam Whitehurst, (1) Algorhythm, (1) Artem, (1) Brian Lewis, (1) Carbonhell, (1) Christian Authmann, (1) Christoffer Lans, (1) Dylan Kile, (1) Erocs, (1) Isaac Freund, (1) Jessie Mancer, (1) Luc Fauvel, (1) Luc Fauvel, (1) MidgeOnGithub, (1) Nero, (1) Nick(12), (1) Nicolas, (1) RustyBamboo, (1) Sebastian Venter, (1) Tanner Donovan, (1) Thomas JULLIEN, (1) appcrashwin(7), (1) heydabop, (1) neronim66@gmail.com, (1) pestilence, (1) yashaslokesh, (1) yeet
This year's credits are brought to you by
git shortlog -sne --since="1 year ago"
Character States Overhaul by @Adam
Hello, @Adam here! I'm working on redesigning the Character State System so that it can (hopefully) handle all the awesome ideas that #game-design
has in mind. This will include the combat, abilities, and weapons systems of Veloren. I'm going to walk through all the guts of this new system, to illustrate how it works and why I'm so proud of it. :) But first, some background...
The CharacterState
component is what handles behaviors of "characters": Any player or NPC, human or otherwise. Each CharacterState
actually has two concurrent states: an ActionState
which is primarily concerned with how a character is interacting with the world (Attacking, Blocking, Wielding), and a MoveState
which primarily describes how a player is moving around the world (Standing, Running, Gliding, Climbing).
Existing State of Character States
When I began, the existing movement
, controller
, and combat
systems were under-equipped to handle the exciting ideas that #game-design
was coming up with. Here are a few reasons why.
First, CharacterState
update logic was spread across these three separate systems, so to make any changes or fix any bugs, one would first have to understand how all three of these systems worked, just to change one state's behavior.
Second, the controller
system was the main source for state-transitioning logic, but that logic was occurring across a series of conditional checks, with state behavior logic mixing with that of others. This meant that changing one state's implementation could have unpredictable effects on another's.
Finally, The state interactions during combat were being defined by the combat system itself. For example, when you tried to attack, the system would check if the enemy was blocking, and handled that blocking logic. So to have new, interesting ways of attacking/blocking/dodging, we would have to layer that on top of the logic of every other attack/block/dodge behavior.
What needed to happen instead was that the blocking state needed to be able to define how it would handle blocking an attack i.e: Would the block interrupt the attacker? Would it apply a strength debuff?
To summarize, the issues with the existing systems are:
- State updating logic was spread across three separate, interlocked systems leading to cognitive overhead.
- State behavior logic depended on the behavior logic of every other state. Changing one state would affect every other state in unpredictable ways.
- State handling was too static and would not scale as weapons and abilities were added.
Veloren Character State System (VCSS)
With these issues in mind, I'm going to start at the root of this new Veloren Character State System (referred to simply as 'VCSS'). Peek into the implementation file: sys/character_state.rs
. Don't worry! This part is actually pretty simple, mostly just boilerplate!
The key piece of code is this:
// Determine new move state if character can move
if let (None, false) = (move_overrides.get(entity), character.move_disabled) {
let state_update = character.move_state.handle(&EcsStateData {
entity: &entity,
uid,
character,
pos,
vel,
ori,
dt: &dt,
inputs,
stats,
body,
physics,
updater: &updater,
server_bus: &server_bus,
local_bus: &local_bus,
});
*character = state_update.character;
*pos = state_update.pos;
*vel = state_update.vel;
*ori = state_update.ori;
}
The character's action_state
update logic is similar, so we only need to focus on move_state
.
First, the code must check whether the character's MoveState can update. The character's ActionState may override the MoveState, or there may be some outside override occurring from an OverrideMove
component.
If it can, the VCSS packages all the components that any state might need to read into an EcsStateData
struct, and passes that to move_state.handle()
. More on this function later.
What's so nice about this EcsStateData
struct is: In the future, a newly implemented state might need to read a component that doesn't currently exist or isn't needed for current states. That new component can be plugged into the EcsStateData
struct, without needing to update the parameters of all the existing states. They will accept (and be able to read) that new component, through EcsStateData
automatically.
Notice, though, that this function returns something labeled state_update
. Well, that's the StateUpdate
tuple, which is really just a tuple of new CharacterState
, Pos
, Vel
, and Ori
components, that the current state has determined should be the updated state of the components.
This illuminates a very critical part of the new VCSS. State update handle()
functions are pure. They cannot mutate any components they are passed, they must return new components. The reason for this is twofold:
- State
handle()
functions only read data, and return new data, so they can be easily parallelized. - ECS data-behavior-constraint is maintained, systems handle behavior, and the VCSS here still handles manipulating the data in storage.
Alright, I bet you thought we'd move onto move_state
s handle()
function now. Not yet, but I appreciate your enthusiasm ;) First, we must know where this handle()
function comes from (and why).
Meet StateHandle trait
It's defined in common/src/comp/states/mod.rs
:
pub trait StateHandle {
fn handle(&self, ecs_data: &EcsStateData) -> StateUpdate;
}
This guy is the glue that connects the VCSS to a state and its update logic. It takes in that EcsStateData
struct, and passes back a StateUpdate
tuple like I just explained. Knowledgeable observers might be asking: "Wait... but you just said ECS systems need to separate data and behavior, but you're implementing functions on the data to handle how to update that data?"
As a newbie to the Rust language, this was a problem that made me question my entire implementation strategy (and worth as a programmer, but that's for my therapist).
In case you are unfamiliar with why ECS systems are so powerful and why this was so troubling: The slowest part of game programs is usually waiting for data to be moved between cache and storage. Data-behavior-separation makes ECS systems so efficient because only relevant data is cached when behaviors are performed. Traditional Object-Oriented data-behavior-encapsulation caches the relevant data, along with all the other data (and behavior functions) that an object has, which is just wasted space in the cache. This leads to more calls to storage, and major performance hits.
The key takeaway is that we want to minimize calls to storage by caching only relevant data. Bundling data up with their behavior would take up excess space in the cache!
So, I spent a long time looking in the mirror and asking, "Is this what we want? Is this the right approach? What am I missing about ECS?" Of course, after wasting so much time in the bathroom, I did a little research and found that missing piece: trait member functions are syntactic sugar for static functions that accept their implementors as the first argument. And that was a sweet discovery indeed. :)
What this means is that calling move_state.handle()
is the same as calling MoveState::handle(move_state)
. The function implementation is not actually bundled into the state struct, it exists somewhere outside the struct, as a static function, just like the VCSS that called it. It's not put into cache along with the data, and therefore won't waste space! The VCSS can still churn through only relevant data at lightning speed and us dumb humans can move that handle()
function somewhere else where it's easier to manage! Win-win.
Handling StateHandle
So, states impl StateHandle
trait to define how their data gets updated, but then you might notice something else! MoveState
isn't actually a state, it's the enum that defines all the possible MoveState
s! So why does it impl StateHandle
?
Well, fictitious reader-in-my-head, you are quite astute. This is another reason why StateHandle
is the glue between the VCSS and states. Peek below StateHandle
s definition at the impl StateHandle for MoveState
and you will see that its just a big ole match statement that passes EcsStateData
to the handle()
functions of the states themselves.
This was a design decision that I think is important. While this matching could have been done within the VCSS itself, I chose to put it here because...
- It keeps the VCSS concise
- This matching logic is an implementation detail of how states handle their updates and not a detail of the VCSS itself.
- There are several RFC's and plans for rust to eliminate the need for matching on enum variants just to perform the same trait function that is implemented by all variants of that enum. Thus, it could likely be refactored away in the future.
- You have to update
states/mod.rs
when implementing new states anyway, so now we don't need to touch the VCSS file every time we want to add a new state.
Okay, I'm done patting myself on the back. Before I get to the stuff you care about, I should explain this nest of matches inside impl StateHandle for ActionState
, because this was a stumbling point for me as a newbie rustacean:
/// Passes handle to variant or subvariant handlers
fn handle(&self, ecs_data: &EcsStateData) -> StateUpdate {
match self {
Attack(kind) => match kind {
BasicAttack(state) => state.handle(ecs_data),
Charge(state) => state.handle(ecs_data),
},
Block(kind) => match kind {
BasicBlock(state) => state.handle(ecs_data),
},
Dodge(kind) => match kind {
Roll(state) => state.handle(ecs_data),
},
Wield(state) => state.handle(ecs_data),
Idle(state) => state.handle(ecs_data),
// All states should be explicitly handled
// Do not use default match: _ => {},
}
}
So we're matching on self
(an ActionState
)... then if its an Attack
, we match on kind
(an Actionkind
), and ActionKind
variants have some object inside them called state
that has the handle()
we want to call. Cool. Why?
Let's look at BasicAttack
. It holds a BasicAttackState
struct that we will see defined in a moment. This struct defines all the data fields that BasicAttack
needs, along with the implementation of StateHandle
trait, because enum variants aren't allowed to impl
traits (yet). BasicAttack is an AttackKind
itself. We have different AttackKind
variants inside the Attack
ActionState
variant because this allows us to easily check character states by different levels. Sometimes we only need to check if a character is attacking, sometimes we want to know what attack they are using. Most importantly, if different attacks were added to ActionState
enum itself, every additional state would require updating other systems that match on action state but don't care about what kind of attack the character is using.
Okay, dear reader, I think it's time ...
Where it All Comes Together
Take a lil' looksee at common/src/comp/states/basic_attack.rs
. Inside you'll find nothing surprising. You've got the BasicAttackStruct
I already talked about:
pub struct BasicAttackState {
/// How long the state has until exiting
remaining_duration: Duration,
}
With a field, remaining_duration
, so the state knows when it should finish. This is all the data that's in this state that's going into cache.
'impl StateHandle' trait won't go into cache. It's handle
function initializes new components:
let mut update = StateUpdate {
pos: *ecs_data.pos,
vel: *ecs_data.vel,
ori: *ecs_data.ori,
character: *ecs_data.character,
};
It does some behavior stuff, if the remaining_duration
is 0, it goes back to wielding or idle state, otherwise, it ticks down remaining_duration
. Either way, it just returns the updated components. :) That's it, that's how a full state is implemented, from start to finish.
But let's just take a moment to appreciate what's going on here. All of a single state's implementation, data, and behavior, looks like it's in one place, so it's easy to see all the important information us humans need when writing states, but the program can still separate this into its data and behavior pieces for ECS. What's more, you don't see any of the logic for anything that isn't part of BasicAttackState
. You don't need to know how the VCSS calls this state update, you don't need to know what other state behavior looks like, you don't need to rely on a different movement
systems to move the character, you don't have to worry if this state will break parallelization in the system, none of that junk! All you have in front of you is the state you care about right now, and that's the way it should be. :)
So, with all that ogling and awing, I encourage you to look at other states' implementations and see how they work. Also, check out common/src/util/movement_utils.rs
to see what those functions can do for you, you don't have to rewrite logic that is shared across states, do you? And don't be afraid to reach out to me with questions, suggestions, or to volunteer! ;)