In the previous post, we covered the details of networking. Around early to mid October, I was also implementing “missiles” an object that follow it’s target and causes side-effects. You may recognize some classic projectile spells like World of Warcraft’s Frostbolt.
And very few other player abilities. I don't know why.
The spell system (mentioned in part one) is flexible. To handle missiles, we add another variant to the SpellEffect
enum.
pub enum SpellEffect {
...,
SpawnMissile {
missile: MissileId,
missile_triggered_spell: SpellId,
},
}
Then we handle that variant in the execute_spell_system
, by pawning it off to another system with an event.
SpellEffect::SpawnMissile {
missile,
missile_triggered_spell,
} => {
let target = get_single_target_from_data(&spell.target, spell_execute_event);
if let Some(target) = target {
missile_spawn.send(SpawnMissileEvent {
source: spell_execute_event.source,
target,
spell_trigger: *missile_triggered_spell,
missile: *missile,
});
}
}
And now we can spawn missiles from spells.
MissileId
? Where does the missile’s properties come from? The model?The Missile & MissileVisuals Registry
. Registries are generic wrappers around a hashmap, defined as follows:
#[derive(Default, Resource)]
pub struct Registry<K: Eq + Hash + PartialEq, V>(pub HashMap<K, V>);
impl<K: Eq + Hash + PartialEq, V> Registry<K, V> {
pub fn get(&self, id: &K) -> Option<&V> {
self.0.get(id)
}
}
pub type MissileRegistry = Registry<MissileId, Missile>;
And contained within the Missile struct
is the data needed on the server-side to handle missiles.
#[derive(Default)]
pub struct Missile {
pub collision_radius: f32,
pub speed: f32,
}
Whilst the MissileId
itself is simply a wrapper around a u32
alongside a smither of derives.
#[derive(
Eq,
Hash,
PartialEq,
Clone,
Copy,
Debug,
Serialize,
Deserialize,
bincode::Encode,
bincode::Decode,
Default,
Component,
)]
pub struct MissileId(pub u32);
Registry
?Registries are meant to hold data from a local database such as SQLite or an actual database such as SQL-Not-Lite*.
*SQL-Not-Lite doesn't appear to exist at time of writing.
This data is then used to drive gameplay, spells and auras, are also stored in their own registries. Granting us the ability define & change all of these without recompiling the game, it also means someone without programming knowledge could create these.
This is why the ``MissileId`` struct derives Serialize & Deserialize from Serde.
One could for example, develop an editor like Bethesda’s Creation Kit that content designers could use to create anything for the game. The ESP files used by the Creation Engine are essentially databases containing all the data that drives the game’s content. Similar to the concept of registries here (though more advanced).
A screenshot from Fallout 4's Creation Kit by Bethesda.
``Form ID`` in this is the equivelant to our ``MissileId`` above.
We have missiles in the spell system and a registry telling us the properties of the missile. Next we need to get the missile to missile. Earlier in the spell system, we sent a SpawnMissileEvent
. Now we need to handle it.
We take the SpawnMissileEvent
s and create an entity, it will hold a number of properties (divided up among several components):
Entity
).Entity
)MissileId
)SpellId
).Vec3
).u32
)u32
).Vec3
).MissileState
enum with variants: Hit
, Active
, TargetLost
) Why not u64
for time? Because at this scale we can count for ~49,7 days, be fine, and fit 2x the data in cache.
Is this a good reason? Meh.
You may ask: Why keep the time of hit and not time to hit? Because it allows us to do some neat logic.
The entire missile movement system works like this:
current_time
.progress
between starting_time
& hit_time
.starting_position
& target_position
by progress
.current_time >= hit_time
, if so update MissileState
to Hit
.About ~10 times per second, we run the update_target_position_system
. This system does the following:
MissileState
to TargetLost
and return.starting_position
to current missile position.starting_time
to current_time
.When the target moves, the start position resets.
speed
toward the target entity?Performance & simplicity. For the aformentioned system we need to calculate two things:
progress
float.starting_position
& target_position
Everything we need exists in a single component that fits into half a cache-line.For comparison moving constantly, toward the target, we’d have to:
speed
& delta_time
.This is the entire missile movement system:
fn move_towards_target_system(
time: Res<Time>,
mut query: Query<(&InterpolatedMissile, &mut Transform, &mut MissileState)>,
) {
let elapsed_time_f64 = time.elapsed().as_micros() as f64 / 1000.0;
let elapsed_time_millis = time.elapsed().as_millis() as u32;
for (missile, mut transform, mut state) in query.iter_mut() {
let progress = (elapsed_time_f64 - missile.start_time as f64)
/ (missile.hit_time - missile.start_time) as f64;
transform.translation = missile
.start_pos
.lerp(missile.target_position, progress.min(1.0) as f32);
if elapsed_time_millis >= missile.hit_time {
*state = MissileState::Hit;
}
}
}
It’s incredible simple.
I love simplicity. ECS is a fantastic pattern for creating simple code.
As I’ve already alluded to, we keep a MissileState
enum, which we modify when we either hit our target or it disappears. Yet another system reacts to changes in MissileState
thanks to Bevy’s wonderful Changed<>
filter.
Changed<>
returns components that have been mutable de-referenced since the last execution of the system.
If the new state is TargetLost
, the missile is despawned. If it is Hit
, we send a SpellExecuteEvent
) targeting our target containing the spell payload defined when the missile spawned, and finally the missile is despawned.
Efficient replication is important to me. The missile system, was developed with network bandwidth in mind.
An interesting quirk of how missiles work is that we don’t have to keep replicating it’s position. We only need to send the MissileId
, starting_position
, target_position
, and the time-of-hit.
CreateMissile {
entity: Entity,
translation: [i32; 3],
missile_id: MissileId,
target_position: [i32; 3],
time_of_hit: u32,
}
We can also encode target_position
relative to translation
& the time_of_hit
relative to the server time included in our packet meaning they'll always be relatively small.
When target_position
changes, we only need to send it’s delta (which will usually be small) and the delta of the time of hit. Given a static target, we only need a single packet to initialize the missile. Given a moving target, we can tune the target_position
to only update if the target has moved a certain distance from our previous target_position
, currently I’ve set this to 0.5 units/meters, which keeps the hit close enough even if the target moves a bit.
Most of the previous text has only covered the server-side. What happens on the client? Not much. Essentially, just the same lerping as on the server but without the MissileState
. When we pass the time-of-hit, the missile despawns on the client-side.
I don’t have any particles or effects on the client-side yet, but they’d appear somewhere around here. I’m aware Bevy Hanabi exists for particle systems, so I’ll likely check it out eventually.
In writing this post I’ve realised there’s a few points that could be improved in regards to missiles. I am actually not sure if missiles like this are truly the correct solution. It’s possible that this same effect could be recreated in a simpler way. All this really does is delay an effect some amount of time and showing a visual in the world for when it will be applied.
It is probably worth testing further.
Written 12 Jan 2023 by Grim