Begins Improving Sparks/Flares Somewhat, Adds Animatable Light Overlays (#95362)

## About The Pull Request

[Adds a visual tick helper, integrates it into SSmove and
such](https://github.com/tgstation/tgstation/commit/e97035f9f74fad5c67c5bf19d8d5d3bb4bd476b4)

Basically, if we do "stuff" during verb time then the next chance
clients have to actually see it is on the next visual tick (rather then
the normal "this tick"). This is cause clients get their next frame
during maptick, and maptick runs before verbs.

We want to be able to handle this properly because if you say, create an
object and then move it on the same VISUAL tick (NOT game tick), it will
just teleport instead of playing out the move. I don't want this for
stuff like sparks, so we need a way to work around it.

[Moves most users of the _FAST flag to
_INSTANT](https://github.com/tgstation/tgstation/commit/6f96daac00519c69adc7554f52114798a65f3ad5)

These are the kids that don't immediately spawn something and the move
it, and we want to allow them to move actually as soon as possible
(important for stuff like space)

[Improves basic effect systems, makes their products delete when they
stop
moving](https://github.com/tgstation/tgstation/commit/172cb25d80ed34e1ec523172a1677fb524239fba)

Moves some stuff out to getters or vars so children can better decide
how long effects should last/how fast they should move. Uses this to
clean up weird dupe code used by explosions.

Makes all these effects delete on contact with something that stops
them. I'm doing this because an effect just hanging in the air looks
really really odd. Does have consequences for sparks that are already
moving at a wall though, might need a better way to handle that.

Makes all these effects use _FAST loops so they don't just hang in the
air for a second on spawn

Adds a setter proc on sparks for their duration, gonna use this to
improve their effects some

[Refactors overlay lights, adds support for animating their
images](https://github.com/tgstation/tgstation/commit/3ad0083cf2b536df51a6d93dca40eac20c1d62d1)

Implements light_render_source and relevant setters, this allows us to
replace the components of an overlay light with basically whatever we
want

Refactors overlay lighting to handle its images more consistently,
allowing us to hook into an image being modified

Combining the two of these will allow us to consistently copy a light's
image, modify it in some way, and then relay that modification back
down. Allowing us to animate it or do more advanced effects painlessly

Also, fixes ranges of 1 or less not rendering at all on initial set
(thank you kapu)

[In which I get fed up and add a macro helper for UID
generation](https://github.com/tgstation/tgstation/commit/aab48b03d407104d4f9cf9acb034494237def911)

[adds vv hooking for all existing lighting
vars](https://github.com/tgstation/tgstation/commit/b81c6200a0d74c36b440aa3f4c1f22c422090a2d)

[Upgrade effect system's dir picking to avoid duplicates when
possible](https://github.com/tgstation/tgstation/commit/18b622586b509c6be4c4bca4e3e7c175ad75fe91)

[Uses the technique described above to animate spark's lights out as
they
move](https://github.com/tgstation/tgstation/commit/67ba177982213799984a70e89536c5efb3d17e14)

This is a decently nice effect imo, it allows us to bump their power
(read, alpha) since it'll get animated away. I try to sync the animation
to the actual icon state's flow (it's 0.7s long). I also sped them up
somewhat to hopefully have a nicer looking effect? we'll see.

[Abstracts away intercepting overlay lights into a holder
datum](https://github.com/tgstation/tgstation/pull/95362/commits/b3f1fe74f2c3bab1d8912ab8a666bd05677ad032)

This should make it far easier to reuse this pattern!

[Fixes overlay lights flashing to double intensity when picked up off
the
ground](https://github.com/tgstation/tgstation/pull/95362/commits/1d83f2031fa2b33312b2aea4359c0c37c9d04ac7)

We needed to clear out their underlays BEFORE the animation

[Adds a flickering effect to flares and their
children](https://github.com/tgstation/tgstation/pull/95362/commits/b7a858e04a607c58b6c7fbe1476ffe2239e63bde)

I'm still not 100% happy with this, I was trying to avoid it feeling
like a heartbeat with random noise and I.. THINK it worked? it's
honestly quite hard to tell

[Adds the same flickering to lighters, welding tools and life
candles](https://github.com/tgstation/tgstation/pull/95362/commits/3ec44027e17835ae96702cec5f0b12d1f4deb32b)

Also, updated light candles to mirror the appearance of normal candles
and use overlay lighting

EDIT:
I realized while working on flares that I accidentally double applied
color, so if you saw the sparks animations before now it was different
(less vibrant). IDK if I like this better or worse but it is RIGHT and
that's what matters.

## Why It's Good For The Game

I got mad about how bad these looked, and this is a start at improving
them.
Also, adds a framework for more dynamic effects applied to overlay
lights (you could use this to apply a sort of "emergency rotating"
effect, or flicker/buzz for example).

<details>
 <summary>Before</summary>


https://github.com/user-attachments/assets/66437f27-ee3c-4f14-a7ee-4a1c3e68533a


https://github.com/user-attachments/assets/ed14fff8-a7eb-47fe-bab5-9a490ac96629

</details>

<details>
 <summary>After</summary>


https://github.com/user-attachments/assets/fb24ff2e-c745-42a5-8e11-c8a1eeef35a5


https://github.com/user-attachments/assets/fd8c2116-cb92-4fe6-ad3e-786a6538e52a

</details>

## Changelog
🆑
add: Reworks how sparks render. They're now a bit brighter, will fade
out as they move/if they hit something, will stack with each other less
and also won't start hang in the air on spawn.
add: Added a flickering effect to lighters, welding tools, flares,
torches and candles (since they're flames).
fix: Overlay based lights (think flashlights) will no longer flash to
double intensity while being picked up.
refactor: Reworked how some effects (explosion particles, sparks, some
reagent stuff) function, report any bugs!
/🆑
This commit is contained in:
LemonInTheDark
2026-03-19 18:48:57 -07:00
committed by GitHub
parent 6c838e8e96
commit 9d14e327b5
32 changed files with 534 additions and 113 deletions
@@ -57,6 +57,16 @@
var/total_effects = 0
/// Should the system delete itself after finishing?
var/autocleanup = FALSE
/// Should the system delete effects that stop moving?
var/delete_on_stop = FALSE
/// How frequently (in deciseconds) should we move our particles?
var/step_delay = 0.5 SECONDS
// Internal use
/// The length of the previous assigned moveloop in deciseconds
var/last_loop_length = 0
/// List of dirs avalible to pick, used to avoid accidential duplicates
var/list/pickable_dirs = list()
/datum/effect_system/basic/New(turf/location, amount = null, cardinals_only = null)
. = ..()
@@ -73,25 +83,48 @@
return
generate_effect()
/// Returns how many steps to attempt to move a generated effect
/datum/effect_system/basic/proc/get_step_count()
return rand(1, 3)
/// Generates a effect for our system to control, returns the generated effect
/datum/effect_system/basic/proc/generate_effect()
if(holder)
location = get_turf(holder)
var/obj/effect/effect = new effect_type(location)
total_effects++
var/direction
if(cardinals_only)
direction = pick(GLOB.cardinals)
else
direction = pick(GLOB.alldirs)
var/step_amt = rand(1, 3)
var/step_delay = 5
var/datum/move_loop/loop = GLOB.move_manager.move(effect, direction, step_delay, timeout = step_delay * step_amt, priority = MOVEMENT_ABOVE_SPACE_PRIORITY)
RegisterSignal(loop, COMSIG_QDELETING, PROC_REF(decrement_total_effect))
if(!length(pickable_dirs))
if(cardinals_only)
pickable_dirs = GLOB.cardinals.Copy()
else
pickable_dirs = GLOB.alldirs.Copy()
// Try not to reuse dirs if possible to avoid weird stacking
var/direction = pick_n_take(pickable_dirs)
/datum/effect_system/basic/proc/decrement_total_effect(datum/source)
var/step_count = get_step_count()
var/datum/move_loop/loop = GLOB.move_manager.move(effect, direction, step_delay, timeout = step_delay * step_count, priority = MOVEMENT_ABOVE_SPACE_PRIORITY, flags = MOVEMENT_LOOP_START_FAST)
RegisterSignal(loop, COMSIG_MOVELOOP_POSTPROCESS, PROC_REF(post_move))
RegisterSignal(loop, COMSIG_QDELETING, PROC_REF(loop_end))
last_loop_length = loop.lifetime
return effect
/datum/effect_system/basic/proc/post_move(datum/move_loop/source, result, visual_delay)
SIGNAL_HANDLER
if(result == MOVELOOP_FAILURE)
move_failed(source, source.moving)
/// Allows us to hook into being unable to automatically move
/datum/effect_system/basic/proc/move_failed(datum/move_loop/loop, obj/effect/failed)
if(QDELETED(failed) || !delete_on_stop)
return
qdel(failed)
/datum/effect_system/basic/proc/loop_end(datum/move_loop/source)
SIGNAL_HANDLER
total_effects--
if(delete_on_stop && !QDELETED(source.moving))
qdel(source.moving)
if(autocleanup && total_effects == 0)
QDEL_IN(src, 2 SECONDS)
@@ -4,14 +4,6 @@
opacity = TRUE
anchored = TRUE
/obj/effect/particle_effect/expl_particles/Initialize(mapload)
..()
return INITIALIZE_HINT_LATELOAD
/obj/effect/particle_effect/expl_particles/LateInitialize()
var/step_amt = pick(25;1, 50;2, 100;3, 200;4)
var/datum/move_loop/loop = GLOB.move_manager.move(src, pick(GLOB.alldirs), 1, timeout = step_amt, priority = MOVEMENT_ABOVE_SPACE_PRIORITY)
RegisterSignal(loop, COMSIG_QDELETING, PROC_REF(end_particle))
/obj/effect/particle_effect/expl_particles/proc/end_particle(datum/source)
SIGNAL_HANDLER
@@ -19,10 +11,20 @@
qdel(src)
/datum/effect_system/basic/expl_particles
effect_type = /obj/effect/particle_effect/expl_particles
amount = 10
step_delay = 0.1 SECONDS
delete_on_stop = TRUE
/datum/effect_system/basic/expl_particles/generate_effect()
new /obj/effect/particle_effect/expl_particles(location)
/datum/effect_system/basic/expl_particles/get_step_count()
return pick(25;1, 50;2, 100;3, 200;4)
/datum/effect_system/basic/expl_particles/loop_end(datum/move_loop/source)
. = ..()
var/obj/effect/explosion_particle = source.moving
if(QDELETED(explosion_particle))
return
qdel(explosion_particle)
/obj/effect/explosion
name = "fire"
@@ -18,11 +18,20 @@
anchored = TRUE
light_system = OVERLAY_LIGHT
light_range = 1.5
light_power = 0.8
light_power = 2
light_color = LIGHT_COLOR_FIRE
/// Should this spark's effect be animated
var/animated = TRUE
/// Timer id for the timer that will wipe us out
var/delete_timer_id = TIMER_ID_NULL
/// Middleman object we're using to animate our light
var/datum/light_middleman/middleman
/obj/effect/particle_effect/sparks/Initialize(mapload)
..()
if(animated)
middleman = new(src, "sparks")
middleman.being_overriding_light()
return INITIALIZE_HINT_LATELOAD
/obj/effect/particle_effect/sparks/LateInitialize()
@@ -32,7 +41,29 @@
var/turf/location = loc
if(isturf(location))
affect_location(location, just_initialized = TRUE)
QDEL_IN(src, 2 SECONDS)
decay_in(2 SECONDS)
/obj/effect/particle_effect/sparks/Destroy()
if(!isnull(middleman))
QDEL_NULL(middleman)
return ..()
/// Sets up our death effects given the passed in duration
/obj/effect/particle_effect/sparks/proc/decay_in(decay_time)
if(delete_timer_id != TIMER_ID_NULL)
deltimer(delete_timer_id)
delete_timer_id = QDEL_IN_STOPPABLE(src, decay_time + world.tick_lag)
if(!animated)
return
var/obj/effect/abstract/main_light = middleman.primary_intercept
// We're going to fade our light out so it's less jarring when we fully disappear
// Note, a refresh of the overlay light would break this, we're basically just sorta assuming that won't happen
// Would need to track time and sort of "replay" where we should be otherwise
if(decay_time >= 0.7 SECONDS) // duration of all animated spark's actual icon state animation
animate(main_light, alpha = 220, time = 0.4 SECONDS)
animate(alpha = 0, time = decay_time - 0.4 SECONDS, easing = CIRCULAR_EASING | EASE_IN)
else
animate(main_light, alpha = 0, time = decay_time)
/obj/effect/particle_effect/sparks/Destroy()
var/turf/location = loc
@@ -91,6 +122,20 @@
/datum/effect_system/basic/spark_spread
effect_type = /obj/effect/particle_effect/sparks
step_delay = 0.35 SECONDS // chosen so we will always take at least the duration of our animation to finish
/datum/effect_system/basic/spark_spread/generate_effect()
var/obj/effect/particle_effect/sparks/spark = ..()
spark.decay_in(last_loop_length)
/datum/effect_system/basic/spark_spread/get_step_count()
return rand(2, 3) // never 1 cause 1 looks dumb
/datum/effect_system/basic/spark_spread/move_failed(datum/move_loop/loop, obj/effect/failed)
if(QDELETED(failed))
return
var/obj/effect/particle_effect/sparks/spark = failed
spark.decay_in(0.1 SECONDS)
/datum/effect_system/basic/spark_spread/quantum
effect_type = /obj/effect/particle_effect/sparks/quantum
@@ -100,6 +145,8 @@
/obj/effect/particle_effect/sparks/electricity
name = "lightning"
icon_state = "electricity"
animated = FALSE
/datum/effect_system/basic/lightning_spread
delete_on_stop = TRUE
effect_type = /obj/effect/particle_effect/sparks/electricity
@@ -39,7 +39,7 @@
/// Starts the effect moving at a target with a delay in deciseconds, and a lifetime in moves
/// Returns the created loop
/obj/effect/particle_effect/water/extinguisher/proc/move_at(atom/target, delay, lifetime)
var/datum/move_loop/loop = GLOB.move_manager.move_towards_legacy(src, target, delay, timeout = delay * lifetime, flags = MOVEMENT_LOOP_START_FAST, priority = MOVEMENT_ABOVE_SPACE_PRIORITY)
var/datum/move_loop/loop = GLOB.move_manager.move_towards_legacy(src, target, delay, timeout = delay * lifetime, priority = MOVEMENT_ABOVE_SPACE_PRIORITY, flags = MOVEMENT_LOOP_START_FAST)
RegisterSignal(loop, COMSIG_MOVELOOP_POSTPROCESS, PROC_REF(post_forcemove))
RegisterSignal(loop, COMSIG_QDELETING, PROC_REF(movement_stopped))
return loop
@@ -88,4 +88,5 @@
QDEL_IN(src, 2 SECONDS)
/datum/effect_system/basic/steam_spread
delete_on_stop = TRUE
effect_type = /obj/effect/particle_effect/steam