dot-fx is visual effects as documents rather than scattered instantiate()
calls: pooled, budgeted, quality-tiered, distance-culled and safe to drop.
Requires dot-core.
One invariant
An effect never changes the simulation. It is a picture of something that already happened, and that is what makes every refusal here safe: a frame budget may drop one, a quality tier may refuse one, a distance cull may skip one, and a client whose content pack is still downloading simply does not see it — and in none of those cases do two machines disagree about anything that matters.
So dot-combat owns damage, dot-effects owns what happens over time, and this owns what it looks like.
Usage
var fx := DotFxManager.new()
fx.catalogue = my_catalogue
fx.world_ref = DotNodeRef.of_path(world.get_path())
add_child(fx)
fx.setup()
fx.viewer_position = camera.global_position # once a frame; culling needs it
fx.spawn(&"muzzle_flash", muzzle.global_transform)
fx.spawn_decal(&"bullet_hole", hit)
fx.shake_at(&"grenade", where) # scaled by distance
fx.flash(&"hurt") # capped and rate limited
fx.advance(delta)
spawn_2d sits beside spawn rather than widening its signature: a caller
holding a Vector2 is a different caller.
A game that plays the beacon’s ping itself
A game that plays the ping through its own sound catalogue sets
marker.ping_enabled = false (API level 3), before add_child or followed by
build(). No voice is built, and advance() still returns true on each pulse
so the game plays its own sound from that. Without it the ping would be heard
twice.
A low quality tier is missing effects
min_quality gates whether an effect exists, not how detailed it is.
Halving the particle count on the effect that is killing the frame does not save
the frame; not spawning it does. frame_budget is the same idea per frame — a
grenade landing in a crowd cannot cost more than one frame has. A request over the budget displaces lower-priority effects started in the same frame, lowest first and only if that frees enough, never one already on screen; a request the budget refuses retires nothing.
Camera shake is a value
fx.advance(delta)
camera.position = base + fx.shake.offset()
camera.rotation.z = base_roll + fx.shake.roll()
It computes a displacement and touches no camera, so one implementation serves a 3D rig, a 2D one, a spectator camera and a headless suite. Two details make it read as shake rather than as a fault: trauma is squared, because a linear falloff stops abruptly, and the motion is sampled noise rather than a random number per frame, which is jitter at the frame rate and looks different at 60 and 144 fps.
Two accessibility limits are enforced here, not by the caller
shake_scale must be able to reach zero — camera shake is a common cause of
simulator sickness, and it is read every frame, so turning it off takes effect
immediately rather than at the next map.
allow_flashes is a player-facing off switch and flashes_per_second defaults
to three, the published photosensitivity guidance. Both are enforced in the
manager, because the caller that does not honour them is the next feature
somebody adds and the person it harms cannot tell which effect did it.
Decals are a ring
An unbounded decal list grows with the round rather than with the number of
players, which makes it invisible in testing and fatal at minute forty.
max_decals recycles the oldest, and lowering it applies at the next decal or the next advance().
