From e4f04afb398eada14149d99d84ac25ab7baf955e Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:00:55 -0800 Subject: [PATCH 01/33] Stop time on a lone Reflex Surge --- tgstation.dme | 1 + voidcrew/modules/cyberware/cyberware_anim.dm | 17 +- voidcrew/modules/cyberware/ware_legend.dm | 8 + voidcrew/modules/cyberware/ware_line_sandy.dm | 2 + .../modules/cyberware/ware_sandy_timestop.dm | 277 ++++++++++++++++++ voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_cyberware_timestop.dm | 82 ++++++ 7 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 voidcrew/modules/cyberware/ware_sandy_timestop.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm diff --git a/tgstation.dme b/tgstation.dme index 51a7c2bd84a..2bf19a31c90 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7225,6 +7225,7 @@ #include "voidcrew\modules\cyberware\ware_pro_combat.dm" #include "voidcrew\modules\cyberware\ware_pro_utility.dm" #include "voidcrew\modules\cyberware\ware_registry.dm" +#include "voidcrew\modules\cyberware\ware_sandy_timestop.dm" #include "voidcrew\modules\cyberware\ware_second_wind.dm" #include "voidcrew\modules\cyberware\ware_street.dm" #include "voidcrew\modules\cyberware\ware_street_passives.dm" diff --git a/voidcrew/modules/cyberware/cyberware_anim.dm b/voidcrew/modules/cyberware/cyberware_anim.dm index 0284e0a0c88..4bb4691941b 100644 --- a/voidcrew/modules/cyberware/cyberware_anim.dm +++ b/voidcrew/modules/cyberware/cyberware_anim.dm @@ -641,16 +641,20 @@ var/ring_alpha = 235 /// How long the ring takes to roll out and fade. var/ring_time = 0.65 SECONDS + /// How far the ring rolls out when it stops time (ware_sandy_timestop.dm). + var/stop_ring_scale = 7.5 /obj/item/organ/cyberimp/cyberware/cascade/milspec ring_scale = 2.7 ring_alpha = 190 ring_time = 0.55 SECONDS + stop_ring_scale = 6.5 /obj/item/organ/cyberimp/cyberware/cascade/street ring_scale = 2.2 ring_alpha = 140 ring_time = 0.45 SECONDS + stop_ring_scale = 5.5 /obj/item/organ/cyberimp/cyberware/cascade/cyberware_worn_key() return "[..()]-[node_look]" @@ -735,8 +739,13 @@ /obj/item/organ/cyberimp/cyberware/cascade/proc/roll_ring() ring_timer = null var/turf/here = get_turf(owner) - if(here) - new /obj/effect/temp_visual/cyberware_time_ring(here, ring_scale, ring_alpha, ring_time) + if(!here) + return + var/datum/status_effect/cyberware_cascade_window/window = owner?.has_status_effect(/datum/status_effect/cyberware_cascade_window) + if(window?.time_stop) // a green ring that rolls out past the edge of the screen + new /obj/effect/temp_visual/cyberware_time_ring(here, stop_ring_scale, ring_alpha, ring_time, TRUE) + return + new /obj/effect/temp_visual/cyberware_time_ring(here, ring_scale, ring_alpha, ring_time) /// The crash: nodes die hips to neck and stay dark. /obj/item/organ/cyberimp/cyberware/cascade/proc/play_crash() @@ -784,9 +793,11 @@ randomdir = FALSE duration = 0.65 SECONDS -/obj/effect/temp_visual/cyberware_time_ring/Initialize(mapload, max_scale = 3.3, peak_alpha = 235, ring_time = 0.65 SECONDS) +/obj/effect/temp_visual/cyberware_time_ring/Initialize(mapload, max_scale = 3.3, peak_alpha = 235, ring_time = 0.65 SECONDS, green = FALSE) duration = ring_time . = ..() + if(green) + color = cyberware_time_stop_green() add_overlay(emissive_appearance(icon, icon_state, src)) transform = matrix().Scale(0.2) alpha = peak_alpha diff --git a/voidcrew/modules/cyberware/ware_legend.dm b/voidcrew/modules/cyberware/ware_legend.dm index 58e909208b7..b5bca636523 100644 --- a/voidcrew/modules/cyberware/ware_legend.dm +++ b/voidcrew/modules/cyberware/ware_legend.dm @@ -90,6 +90,8 @@ var/window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade /// What the bearer reads on activation. var/online_message = "CASCADE ONLINE." + /// How long the world stops when no other player is near (ware_sandy_timestop.dm). + var/stop_duration = 4 SECONDS /obj/item/organ/cyberimp/cyberware/cascade/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -188,6 +190,7 @@ online_message = lattice.online_message crash_duration = lattice.crash_duration crash_stamina = lattice.crash_stamina + stop_duration = lattice.stop_duration return ..() /datum/status_effect/cyberware_cascade_window/on_apply() @@ -205,9 +208,11 @@ ) owner.balloon_alert(owner, "cascade online") cyberware_cascade_window_fx(owner) + try_time_stop() // ware_sandy_timestop.dm return TRUE /datum/status_effect/cyberware_cascade_window/on_remove() + end_time_stop() owner.remove_movespeed_modifier(speed_type) owner.remove_movespeed_mod_immunities(id, /datum/movespeed_modifier/reagent) cyberware_unregister_dodge_source(owner, id) @@ -240,6 +245,9 @@ steps_taken++ if(steps_taken % afterimage_interval) // cheaper chrome leaves a sparser trail return + if(time_stop) + stopped_afterimage(old_loc) + return new /obj/effect/temp_visual/decoy/fading/cascade(old_loc, owner, trail_hue, afterimage_alpha) trail_hue = (trail_hue + CYBERWARE_CASCADE_HUE_STEP) % 360 diff --git a/voidcrew/modules/cyberware/ware_line_sandy.dm b/voidcrew/modules/cyberware/ware_line_sandy.dm index 303ff54f346..8cb63930cbe 100644 --- a/voidcrew/modules/cyberware/ware_line_sandy.dm +++ b/voidcrew/modules/cyberware/ware_line_sandy.dm @@ -52,6 +52,7 @@ afterimage_alpha = 120 window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade/aftermarket online_message = "REFLEX RIG ONLINE." + stop_duration = 2 SECONDS /datum/action/cooldown/cyberware/cascade_surge/street name = "Reflex Surge" @@ -89,6 +90,7 @@ afterimage_alpha = 160 window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade/aftermarket online_message = "LATTICE ONLINE." + stop_duration = 3 SECONDS /datum/action/cooldown/cyberware/cascade_surge/milspec name = "Reflex Surge" diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm new file mode 100644 index 00000000000..fb264d05af5 --- /dev/null +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -0,0 +1,277 @@ +/** + * # The Cascade line's time stop + * + * When a Cascade-line surge (ware_legend.dm, ware_line_sandy.dm) opens and no other player is within + * SANDY_STOP_WITNESS_RANGE tiles, the world stops for the lattice's stop_duration (street 2 s, service 3 s, + * Cascade 4 s). The rest of the window then runs as normal, then the crash. With another player near, the + * surge is the plain one. Only a player's surge stops time: an NPC criminal's lattice never does. + * + * Built on tg's own time stop (code/datums/proximity_monitor/fields/timestop.dm). The field is hosted on + * the user, so it follows them; anything it leaves behind stays frozen until it ends. + * + * No shortcuts on people: frozen mobs are held by tg's Stun, re-applied every tick by the tg field, and + * anything with an AI controller also gets TRAIT_AI_PAUSED (Voidcrew's human NPCs run a basic controller on a + * carbon body, which the tg field's basic-mob branch misses). Frozen bodies still take damage, wounds and + * bleeding through tg as normal. Bosses (a finisher user, or a boss-rank human NPC) are held for + * SANDY_STOP_BOSS_HOLD only, then let go while the field carries on. + * + * The field re-checks for players every tick and ends at once if one turns up. + * + * Look: frozen things get a grey-green tint instead of tg's inversion. Floor items, blood and the user are + * never frozen, so they keep their colour. The user's own shots stop about a tile out, keep their colour, + * and fly on when time restarts. + */ + +/// Another player within this many tiles of the user means no time stop. +#define SANDY_STOP_WITNESS_RANGE 10 +/// Radius of the frozen field around the user (a widescreen view is 9 tiles to each side). +#define SANDY_STOP_FIELD_RANGE 9 +/// How long a boss stays frozen before it shrugs the field off. +#define SANDY_STOP_BOSS_HOLD (1 SECONDS) +/// How far, in pixels, the user's own shots fly before they hang. +#define SANDY_STOP_SHOT_HANG 32 +/// Trait source for everything the time stop applies. +#define SANDY_STOP_TRAIT "sandevistan_time_stop" +/// Filter name of the user's edge glow. +#define SANDY_STOP_OUTLINE "sandy_time_stop_outline" + +/// Whether a player other than `user` is within `range` tiles: anyone with a client, or a living body with a ckey whose player has disconnected. +/proc/cyberware_time_stop_witnessed(mob/living/user, range = SANDY_STOP_WITNESS_RANGE) + var/turf/here = get_turf(user) + if(!here) + return TRUE + for(var/atom/movable/found as anything in SSspatial_grid.orthogonal_range_search(here, SPATIAL_GRID_CONTENTS_TYPE_CLIENTS, range)) + if(found == user) + continue + if(isliving(found)) + return TRUE + if(ismob(found)) // ghosts and camera eyes don't count + continue + // a locker or a mech with a player in it + for(var/mob/living/inside in found.get_all_contents()) + if(inside != user && inside.client) + return TRUE + for(var/mob/living/body in SSspatial_grid.orthogonal_range_search(here, SPATIAL_GRID_CONTENTS_TYPE_HEARING, range)) + if(body != user && body.ckey) + return TRUE + return FALSE + +/// Whether `victim` is a boss: anything with a finisher, or a boss-rank human NPC. +/proc/cyberware_time_stop_boss(mob/living/victim) + if(victim.GetComponent(/datum/component/hv_finisher_user)) + return TRUE + if(istype(victim, /mob/living/carbon/human/npc)) + var/mob/living/carbon/human/npc/person = victim + return person.npc_rank >= HUMAN_NPC_RANK_BOSS + return FALSE + +/// The grey-green wash on frozen things. +/proc/cyberware_time_stop_tint() + var/static/list/tint = list(0.26,0.28,0.27, 0.52,0.56,0.54, 0.10,0.11,0.105, 0.02,0.05,0.04) + return tint + +/// Flattens a sprite to luminance and paints it the time stop's green (#5AFF96), lifted so dark parts still glow. +/proc/cyberware_time_stop_green() + var/static/list/green + if(!green) + var/red = 90 / 255 + var/grn = 1 + var/blue = 150 / 255 + var/gain = 0.8 + var/lift = 0.5 + green = list( + 0.3 * gain * red, 0.3 * gain * grn, 0.3 * gain * blue, + 0.59 * gain * red, 0.59 * gain * grn, 0.59 * gain * blue, + 0.11 * gain * red, 0.11 * gain * grn, 0.11 * gain * blue, + lift * red, lift * grn, lift * blue, + ) + return green + +// ========================================================================= +// THE STOP +// ========================================================================= + +/// Lives as long as the stop, holds the field on the user, and ends the stop when it's deleted. +/obj/effect/timestop/sandevistan + name = "stopped moment" + desc = null + hidden = TRUE + alpha = 0 + freezerange = SANDY_STOP_FIELD_RANGE + /// Whose stop this is. The field is hosted on them. + var/mob/living/user + +/// `immune_atoms` must be list(user). +/obj/effect/timestop/sandevistan/Initialize(mapload, radius, time, list/immune_atoms, start = TRUE, silent = TRUE) + if(length(immune_atoms)) + user = immune_atoms[1] + return ..(mapload, radius, time, immune_atoms, start, TRUE) + +/obj/effect/timestop/sandevistan/Destroy() + user = null + return ..() + +/obj/effect/timestop/sandevistan/timestop() + target = get_turf(src) + QDEL_IN(src, duration) + if(QDELETED(user)) + return + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = new(user, freezerange, TRUE, immune, antimagic_flags, FALSE) + field.stopper = src + chronofield = field + +/datum/proximity_monitor/advanced/timestop/sandevistan + /// The effect that owns this field. Deleting it ends the stop. + var/obj/effect/timestop/sandevistan/stopper + /// Frozen bosses, to the world.time they shrug the field off. + var/list/boss_holds = list() + /// Bosses that already shrugged it off. The field leaves them be. + var/list/released = list() + /// The user's own shots, hanging in the air. + var/list/hung_shots = list() + +/datum/proximity_monitor/advanced/timestop/sandevistan/New(atom/_host, range, _ignore_if_not_on_turf = TRUE, list/immune, antimagic_flags, channelled) + . = ..() + RegisterSignal(host, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, PROC_REF(on_user_fire)) + +/datum/proximity_monitor/advanced/timestop/sandevistan/Destroy() + if(host) + UnregisterSignal(host, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE) + for(var/obj/projectile/shot as anything in hung_shots) + UnregisterSignal(shot, COMSIG_QDELETING) + shot.paused = FALSE + hung_shots.Cut() + stopper = null + . = ..() + released.Cut() + boss_holds.Cut() + +/datum/proximity_monitor/advanced/timestop/sandevistan/freeze_atom(atom/movable/A) + if(released[A]) + return FALSE + if(isprojectile(A)) + var/obj/projectile/shot = A + if(shot.firer && immune[shot.firer]) // the user's own shots hang on their own clock + return FALSE + return ..() + +/datum/proximity_monitor/advanced/timestop/sandevistan/freeze_mob(mob/living/victim) + . = ..() + if(victim.ai_controller) + ADD_TRAIT(victim, TRAIT_AI_PAUSED, SANDY_STOP_TRAIT) + if(cyberware_time_stop_boss(victim)) + boss_holds[victim] = world.time + SANDY_STOP_BOSS_HOLD + +/datum/proximity_monitor/advanced/timestop/sandevistan/unfreeze_mob(mob/living/victim) + REMOVE_TRAIT(victim, TRAIT_AI_PAUSED, SANDY_STOP_TRAIT) + boss_holds -= victim + return ..() + +/datum/proximity_monitor/advanced/timestop/sandevistan/process() + if(QDELETED(host) || cyberware_time_stop_witnessed(host)) + if(!QDELETED(stopper)) + qdel(stopper) // takes this field with it + return + for(var/mob/living/boss as anything in boss_holds.Copy()) + if(world.time < boss_holds[boss]) + continue + unfreeze_atom(boss) + released[boss] = TRUE + return ..() + +/datum/proximity_monitor/advanced/timestop/sandevistan/into_the_negative_zone(atom/A) + A.add_atom_colour(cyberware_time_stop_tint(), TEMPORARY_COLOUR_PRIORITY) + +/// Signal proc for [COMSIG_PROJECTILE_FIRER_BEFORE_FIRE] on the user: watch the shot leave the barrel. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/on_user_fire(datum/source, obj/projectile/shot, atom/fired_from, atom/original) + SIGNAL_HANDLER + RegisterSignal(shot, COMSIG_PROJECTILE_FIRE, PROC_REF(hang_shot)) + +/// Signal proc for [COMSIG_PROJECTILE_FIRE]: the shot flies a tile, then hangs until time restarts. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/hang_shot(obj/projectile/shot) + SIGNAL_HANDLER + UnregisterSignal(shot, COMSIG_PROJECTILE_FIRE) + if(QDELETED(shot) || shot.deletion_queued) + return + // A hitscan beam takes its first step itself once paused (process_hitscan) and stops there. + if(!shot.hitscan) + shot.process_movement(SANDY_STOP_SHOT_HANG) + if(QDELETED(shot) || shot.deletion_queued) + return + shot.paused = TRUE + hung_shots += shot + RegisterSignal(shot, COMSIG_QDELETING, PROC_REF(shot_gone)) + +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/shot_gone(obj/projectile/shot) + SIGNAL_HANDLER + hung_shots -= shot + +// ========================================================================= +// THE WINDOW'S SIDE +// ========================================================================= + +/datum/status_effect/cyberware_cascade_window + /// How long the world stops if nobody else is near when the window opens. Copied from the lattice. + var/stop_duration = 4 SECONDS + /// The running stop, if time is stopped. + var/obj/effect/timestop/sandevistan/time_stop + +/// Stops time if the bearer is a player and nobody else is near. Called as the window opens. +/datum/status_effect/cyberware_cascade_window/proc/try_time_stop() + if(stop_duration <= 0 || !owner.ckey || time_stop) + return FALSE + if(cyberware_time_stop_witnessed(owner)) + return FALSE + var/turf/here = get_turf(owner) + if(!here) + return FALSE + time_stop = new(here, SANDY_STOP_FIELD_RANGE, stop_duration, list(owner)) + if(QDELETED(time_stop)) + time_stop = null + return FALSE + RegisterSignal(time_stop, COMSIG_QDELETING, PROC_REF(time_stop_over)) + owner.remove_client_colour(id) // the user sees the frozen world as it is + owner.add_filter(SANDY_STOP_OUTLINE, 2, outline_filter(1, "#5AFF96D2")) + return TRUE + +/// Signal proc for [COMSIG_QDELETING] on the stop: time restarts and the rest of the window runs as normal. +/datum/status_effect/cyberware_cascade_window/proc/time_stop_over(datum/source) + SIGNAL_HANDLER + time_stop = null + if(QDELETED(owner)) + return + owner.remove_filter(SANDY_STOP_OUTLINE) + owner.add_client_colour(colour_type, id) + +/// Ends the stop early (the window is closing). +/datum/status_effect/cyberware_cascade_window/proc/end_time_stop() + if(!time_stop) + return + UnregisterSignal(time_stop, COMSIG_QDELETING) + QDEL_NULL(time_stop) + if(!QDELETED(owner)) + owner.remove_filter(SANDY_STOP_OUTLINE) + +/// One afterimage while time is stopped: green, gone in 0.4 s. It sits on the user's own tile, shifted back to +/// where they were, so it sorts on their row and draws behind them in every facing. +/datum/status_effect/cyberware_cascade_window/proc/stopped_afterimage(turf/old_loc) + var/turf/here = get_turf(owner) + if(!here) + return + var/obj/effect/temp_visual/decoy/fading/cascade/stopped/image = new(here, owner, 0, afterimage_alpha) + image.color = cyberware_time_stop_green() + image.filters = null + image.pixel_w = owner.pixel_w + (old_loc.x - here.x) * ICON_SIZE_X + image.pixel_z = owner.pixel_z + (old_loc.y - here.y) * ICON_SIZE_Y + image.layer = owner.layer - 0.01 + +/// The afterimage left while time is stopped. +/obj/effect/temp_visual/decoy/fading/cascade/stopped + duration = 4 + +#undef SANDY_STOP_WITNESS_RANGE +#undef SANDY_STOP_FIELD_RANGE +#undef SANDY_STOP_BOSS_HOLD +#undef SANDY_STOP_SHOT_HANG +#undef SANDY_STOP_TRAIT +#undef SANDY_STOP_OUTLINE diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index e27485cc825..e3b63e8408e 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -102,6 +102,7 @@ #include "voidcrew_cyberware_passives.dm" #include "voidcrew_cyberware_soak.dm" #include "voidcrew_cyberware_text.dm" +#include "voidcrew_cyberware_timestop.dm" #include "voidcrew_cyberware_widowline.dm" #include "voidcrew_cyberware_zoom.dm" #include "voidcrew_derelict_helpers.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm new file mode 100644 index 00000000000..a6c376d4f55 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -0,0 +1,82 @@ +/** + * # The Cascade line's time stop + * + * voidcrew/modules/cyberware/ware_sandy_timestop.dm. A player's surge with no other player near stops time: + * the field spawns, a human NPC is stunned with its AI paused, the user can still act, and the NPC is let go + * when the stop runs out. Another player near means no stop at all. Uses the street rig (2 s stop) to keep + * the waits short. + */ +/datum/unit_test/voidcrew_cyberware_timestop + abstract_type = /datum/unit_test/voidcrew_cyberware_timestop + +/// A player body (a key, no client) at `where` with the street rig in it +/datum/unit_test/voidcrew_cyberware_timestop/proc/make_user(turf/where, key) + var/mob/living/carbon/human/user = allocate(/mob/living/carbon/human/consistent, where) + user.key = key + var/obj/item/organ/cyberimp/cyberware/cascade/street/rig = allocate(/obj/item/organ/cyberimp/cyberware/cascade/street) + TEST_ASSERT(rig.Insert(user, special = TRUE), "Test fixture: the reflex rig wouldn't go in") + return user + +/// Opens the rig's window the way its surge button does +/datum/unit_test/voidcrew_cyberware_timestop/proc/surge(mob/living/carbon/human/user) + var/obj/item/organ/cyberimp/cyberware/cascade/rig = locate() in user.organs + return user.apply_status_effect(/datum/status_effect/cyberware_cascade_window, rig) + +/// A turf `dx`, `dy` from the bottom left of the test room +/datum/unit_test/voidcrew_cyberware_timestop/proc/spot(dx, dy) + return locate(run_loc_floor_bottom_left.x + dx, run_loc_floor_bottom_left.y + dy, run_loc_floor_bottom_left.z) + +/datum/unit_test/voidcrew_cyberware_timestop/Destroy() + for(var/mob/living/body in allocated) + body.key = null + return ..() + +/// Nobody else near: time stops, the NPC freezes with its AI paused, the user can still act, and it all ends on time +/datum/unit_test/voidcrew_cyberware_timestop/alone + +/datum/unit_test/voidcrew_cyberware_timestop/alone/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + var/mob/living/carbon/human/npc/raider = allocate(/mob/living/carbon/human/npc, spot(2, 0)) + var/datum/status_effect/cyberware_cascade_window/window = surge(user) + TEST_ASSERT_NOTNULL(window, "The rig's window didn't open") + sleep(0.1 SECONDS) // the field starts async + TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't stop with nobody else near") + TEST_ASSERT(istype(window.time_stop.chronofield, /datum/proximity_monitor/advanced/timestop/sandevistan), "The stop has no field") + + TEST_ASSERT(raider.IsStun(), "The NPC wasn't frozen") + TEST_ASSERT(HAS_TRAIT(raider, TRAIT_AI_PAUSED), "The NPC's AI kept running in stopped time") + TEST_ASSERT(!user.IsStun(), "The user froze in their own time stop") + TEST_ASSERT(!HAS_TRAIT(user, TRAIT_AI_PAUSED), "The user's AI was paused") + var/turf/start = get_turf(user) + user.Move(spot(0, 1), NORTH) + TEST_ASSERT(get_turf(user) != start, "The user couldn't move in stopped time") + + // Frozen bodies still get hurt through tg. + var/brute_before = raider.getBruteLoss() + raider.apply_damage(10, BRUTE, BODY_ZONE_CHEST) + TEST_ASSERT(raider.getBruteLoss() > brute_before, "A frozen NPC didn't take damage") + + sleep(2.3 SECONDS) // the street rig stops time for 2 s + TEST_ASSERT_NULL(window.time_stop, "The time stop outlived its 2 s") + TEST_ASSERT(!raider.IsStun(), "The NPC stayed frozen after time restarted") + TEST_ASSERT(!HAS_TRAIT(raider, TRAIT_AI_PAUSED), "The NPC's AI stayed paused after time restarted") + TEST_ASSERT(user.has_status_effect(/datum/status_effect/cyberware_cascade_window), "The window didn't carry on after the stop") + user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) + user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + +/// Another player near: the plain surge, no stop +/datum/unit_test/voidcrew_cyberware_timestop/witnessed + +/datum/unit_test/voidcrew_cyberware_timestop/witnessed/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + var/mob/living/carbon/human/other = allocate(/mob/living/carbon/human/consistent, spot(3, 3)) + other.key = "sandytimestopother" // a player body, client or not + var/mob/living/carbon/human/npc/raider = allocate(/mob/living/carbon/human/npc, spot(2, 0)) + var/datum/status_effect/cyberware_cascade_window/window = surge(user) + TEST_ASSERT_NOTNULL(window, "The rig's window didn't open") + sleep(0.1 SECONDS) + TEST_ASSERT_NULL(window.time_stop, "Time stopped with another player in sight") + TEST_ASSERT(!raider.IsStun(), "The NPC froze without a time stop") + TEST_ASSERT(!(locate(/obj/effect/timestop/sandevistan) in world), "A time stop field spawned with another player in sight") + user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) + user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) From 34d48420868c09bc0c69df4dd358fcf7ae33b35e Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:16:39 -0800 Subject: [PATCH 02/33] Paint the stopped-time afterimages the ring's green --- voidcrew/modules/cyberware/ware_sandy_timestop.dm | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index fb264d05af5..cc56041d8a0 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -77,8 +77,9 @@ var/red = 90 / 255 var/grn = 1 var/blue = 150 / 255 - var/gain = 0.8 - var/lift = 0.5 + // gain + lift = 1, so white maps to the green itself and black to a dim green + var/gain = 0.65 + var/lift = 0.35 green = list( 0.3 * gain * red, 0.3 * gain * grn, 0.3 * gain * blue, 0.59 * gain * red, 0.59 * gain * grn, 0.59 * gain * blue, From 86cd34433c2bb22afd7902fedf7d4a059b1f2eb0 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:29:30 -0800 Subject: [PATCH 03/33] Make the lone Reflex Surge slow the world instead of stopping it --- code/modules/projectiles/gun.dm | 11 + voidcrew/modules/cyberware/ware_legend.dm | 13 +- voidcrew/modules/cyberware/ware_line_sandy.dm | 8 +- .../modules/cyberware/ware_sandy_timestop.dm | 417 ++++++++++++++---- .../unit_tests/voidcrew_cyberware_timestop.dm | 85 ++-- 5 files changed, 409 insertions(+), 125 deletions(-) diff --git a/code/modules/projectiles/gun.dm b/code/modules/projectiles/gun.dm index 78941bbe241..371efe8e779 100644 --- a/code/modules/projectiles/gun.dm +++ b/code/modules/projectiles/gun.dm @@ -448,6 +448,11 @@ if(!user || !firing_burst) firing_burst = FALSE return FALSE + // VOIDCREW EDIT ADDITION START - cyberware time dilation (voidcrew/modules/cyberware/ware_sandy_timestop.dm): a burst queued before the world froze waits for it to move again + if(!cyberware_time_scale(user)) + addtimer(CALLBACK(src, PROC_REF(process_burst), user, target, message, params, zone_override, random_spread, burst_spread_mult, iteration), 1) + return TRUE + // VOIDCREW EDIT ADDITION END if(!issilicon(user)) if(iteration > 1 && !(user.is_holding(src))) //for burst firing firing_burst = FALSE @@ -517,6 +522,12 @@ if(user && HAS_TRAIT(user, TRAIT_DOUBLE_TAP)) modified_burst_delay = ROUND_UP(burst_delay * 0.5) modified_fire_delay = ROUND_UP(fire_delay * 0.5) + // VOIDCREW EDIT ADDITION START - cyberware time dilation (voidcrew/modules/cyberware/ware_sandy_timestop.dm): a slowed shooter's gun cycles slowly too + var/dilation = cyberware_time_scale(user) + if(dilation < 1) + modified_burst_delay /= max(dilation, 0.1) + modified_fire_delay /= max(dilation, 0.1) + // VOIDCREW EDIT ADDITION END if(burst_size > 1) firing_burst = TRUE diff --git a/voidcrew/modules/cyberware/ware_legend.dm b/voidcrew/modules/cyberware/ware_legend.dm index b5bca636523..ae0a29e8df8 100644 --- a/voidcrew/modules/cyberware/ware_legend.dm +++ b/voidcrew/modules/cyberware/ware_legend.dm @@ -90,8 +90,13 @@ var/window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade /// What the bearer reads on activation. var/online_message = "CASCADE ONLINE." - /// How long the world stops when no other player is near (ware_sandy_timestop.dm). - var/stop_duration = 4 SECONDS + // When no other player is near, the world slows (ware_sandy_timestop.dm): + /// how long it freezes first, + var/dilation_freeze = 1 SECONDS + /// how long slow-mo runs after that, + var/dilation_time = 3 SECONDS + /// and how fast the world runs in slow-mo. + var/dilation_scale = 0.2 /obj/item/organ/cyberimp/cyberware/cascade/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -190,7 +195,9 @@ online_message = lattice.online_message crash_duration = lattice.crash_duration crash_stamina = lattice.crash_stamina - stop_duration = lattice.stop_duration + dilation_freeze = lattice.dilation_freeze + dilation_time = lattice.dilation_time + dilation_scale = lattice.dilation_scale return ..() /datum/status_effect/cyberware_cascade_window/on_apply() diff --git a/voidcrew/modules/cyberware/ware_line_sandy.dm b/voidcrew/modules/cyberware/ware_line_sandy.dm index 8cb63930cbe..57850ce3801 100644 --- a/voidcrew/modules/cyberware/ware_line_sandy.dm +++ b/voidcrew/modules/cyberware/ware_line_sandy.dm @@ -52,7 +52,9 @@ afterimage_alpha = 120 window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade/aftermarket online_message = "REFLEX RIG ONLINE." - stop_duration = 2 SECONDS + dilation_freeze = 0 + dilation_time = 2 SECONDS + dilation_scale = 0.5 /datum/action/cooldown/cyberware/cascade_surge/street name = "Reflex Surge" @@ -90,7 +92,9 @@ afterimage_alpha = 160 window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade/aftermarket online_message = "LATTICE ONLINE." - stop_duration = 3 SECONDS + dilation_freeze = 0 + dilation_time = 3 SECONDS + dilation_scale = 0.35 /datum/action/cooldown/cyberware/cascade_surge/milspec name = "Reflex Surge" diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index cc56041d8a0..ae71484ebaf 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -1,59 +1,78 @@ /** - * # The Cascade line's time stop + * # The Cascade line's time dilation * * When a Cascade-line surge (ware_legend.dm, ware_line_sandy.dm) opens and no other player is within - * SANDY_STOP_WITNESS_RANGE tiles, the world stops for the lattice's stop_duration (street 2 s, service 3 s, - * Cascade 4 s). The rest of the window then runs as normal, then the crash. With another player near, the - * surge is the plain one. Only a player's surge stops time: an NPC criminal's lattice never does. + * SANDY_STOP_WITNESS_RANGE tiles, the world around the user slows down: * - * Built on tg's own time stop (code/datums/proximity_monitor/fields/timestop.dm). The field is hosted on - * the user, so it follows them; anything it leaves behind stays frozen until it ends. + * model freeze then slow-mo + * street - 50% for 2 s + * service - 35% for 3 s + * Cascade T4 1 s 20% for 3 s * - * No shortcuts on people: frozen mobs are held by tg's Stun, re-applied every tick by the tg field, and - * anything with an AI controller also gets TRAIT_AI_PAUSED (Voidcrew's human NPCs run a basic controller on a - * carbon body, which the tg field's basic-mob branch misses). Frozen bodies still take damage, wounds and - * bleeding through tg as normal. Bosses (a finisher user, or a boss-rank human NPC) are held for - * SANDY_STOP_BOSS_HOLD only, then let go while the field carries on. + * The rest of the window then runs as normal, then the crash. With another player near, the surge is the + * plain one. Only a player's surge does this (the user needs a ckey): an NPC criminal's lattice never does. * - * The field re-checks for players every tick and ends at once if one turns up. + * Built on tg's time stop field (code/datums/proximity_monitor/fields/timestop.dm), hosted on the user so + * it follows them. Anything it touches stays slowed (or frozen) until the effect ends, even once the user + * walks off. The field re-checks for players every tick and ends at once if one comes in. * - * Look: frozen things get a grey-green tint instead of tg's inversion. Floor items, blood and the user are - * never frozen, so they keep their colour. The user's own shots stop about a tile out, keep their colour, - * and fly on when time restarts. + * No shortcuts on people. A frozen mob gets tg's own TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED and + * TRAIT_AI_PAUSED, never a fake state or a Stun. A slowed mob moves under a movespeed modifier, and every + * tick the field pushes its timing deadlines back by the time it lost (next_move, ability cooldowns, AI + * behaviour cooldowns and the blackboard deadlines listed in dilated_blackboard_keys(), ranged cooldowns). + * Guns scale their fire and burst delays for a slowed shooter (VOIDCREW EDIT in gun.dm). Frozen and slowed + * bodies take damage, wounds and bleeding through tg as normal. Not covered: do_after progress bars and + * burst timers queued before the slow-mo started. + * + * Bosses (a finisher user, or a boss-rank human NPC) are held for SANDY_STOP_BOSS_HOLD at most. + * + * Look: affected things get a grey-green tint instead of tg's inversion. Floor items, blood decals and the + * user are never touched, so they keep their colour, and so do the user's own shots. Shots the user fires + * while the world is frozen fly a tile, hang there, and go on when it starts moving again. */ -/// Another player within this many tiles of the user means no time stop. -#define SANDY_STOP_WITNESS_RANGE 10 -/// Radius of the frozen field around the user (a widescreen view is 9 tiles to each side). +/// Another player within this many tiles of the user means no dilation. +#define SANDY_STOP_WITNESS_RANGE 11 +/// Radius of the field around the user (a widescreen view is 9 tiles to each side). #define SANDY_STOP_FIELD_RANGE 9 -/// How long a boss stays frozen before it shrugs the field off. +/// How long a boss stays frozen or slowed before it shrugs the field off. #define SANDY_STOP_BOSS_HOLD (1 SECONDS) -/// How far, in pixels, the user's own shots fly before they hang. +/// How far, in pixels, the user's own shots fly before they hang in a frozen world. #define SANDY_STOP_SHOT_HANG 32 -/// Trait source for everything the time stop applies. +/// Trait source for everything the dilation applies. #define SANDY_STOP_TRAIT "sandevistan_time_stop" /// Filter name of the user's edge glow. #define SANDY_STOP_OUTLINE "sandy_time_stop_outline" -/// Whether a player other than `user` is within `range` tiles: anyone with a client, or a living body with a ckey whose player has disconnected. +/// Every atom the dilation has hold of, to its field. +GLOBAL_LIST_EMPTY(cyberware_time_dilated) + +/// How fast time runs for `thing`: 1 normally, 0 frozen, in between in slow-mo. +/proc/cyberware_time_scale(atom/thing) + if(isnull(thing)) + return 1 + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = GLOB.cyberware_time_dilated[thing] + return field ? field.time_scale : 1 + +/// Whether a player other than `user` is within `range` tiles: any living body with a ckey, connected or not. /proc/cyberware_time_stop_witnessed(mob/living/user, range = SANDY_STOP_WITNESS_RANGE) var/turf/here = get_turf(user) if(!here) return TRUE - for(var/atom/movable/found as anything in SSspatial_grid.orthogonal_range_search(here, SPATIAL_GRID_CONTENTS_TYPE_CLIENTS, range)) + for(var/atom/movable/found as anything in SSspatial_grid.orthogonal_range_search(here, SPATIAL_GRID_CONTENTS_TYPE_HEARING, range)) if(found == user) continue if(isliving(found)) - return TRUE - if(ismob(found)) // ghosts and camera eyes don't count + var/mob/living/body = found + if(body.ckey) + return TRUE + continue + if(ismob(found) || isitem(found)) // ghosts and camera eyes don't count; radios hold nobody continue // a locker or a mech with a player in it for(var/mob/living/inside in found.get_all_contents()) - if(inside != user && inside.client) + if(inside != user && inside.ckey) return TRUE - for(var/mob/living/body in SSspatial_grid.orthogonal_range_search(here, SPATIAL_GRID_CONTENTS_TYPE_HEARING, range)) - if(body != user && body.ckey) - return TRUE return FALSE /// Whether `victim` is a boss: anything with a finisher, or a boss-rank human NPC. @@ -65,12 +84,12 @@ return person.npc_rank >= HUMAN_NPC_RANK_BOSS return FALSE -/// The grey-green wash on frozen things. +/// The grey-green wash on dilated things. /proc/cyberware_time_stop_tint() var/static/list/tint = list(0.26,0.28,0.27, 0.52,0.56,0.54, 0.10,0.11,0.105, 0.02,0.05,0.04) return tint -/// Flattens a sprite to luminance and paints it the time stop's green (#5AFF96), lifted so dark parts still glow. +/// Flattens a sprite to luminance and paints it the dilation's green (#5AFF96). /proc/cyberware_time_stop_green() var/static/list/green if(!green) @@ -88,24 +107,56 @@ ) return green +/// Blackboard keys holding world.time deadlines that slow down with their owner. +/proc/dilated_blackboard_keys() + var/static/list/keys = list( + BB_HUMAN_NPC_NEXT_SHOT, + BB_HUMAN_NPC_RELOAD_UNTIL, + BB_HUMAN_NPC_ARMING_RETRY_AT, + BB_HUMAN_NPC_NEXT_TREAT, + BB_HUMAN_NPC_FLEE_UNTIL, + BB_BOUNTY_NEXT_STEP_AT, + BB_BOUNTY_BACKSTEP_AT, + BB_BOUNTY_BOSS_HUNT_AT, + BB_BOUNTY_BOSS_RELOAD_UNTIL, + BB_BASIC_MOB_REINFORCEMENTS_COOLDOWN, + // defined and undefined inside their own modules + "convict_hit_until", + "convict_lost_since", + "pirate_windup_until", + "pirate_breach_started", + "pirate_no_path_at", + "pirate_breach_progress_at", + ) + return keys + +/datum/movespeed_modifier/cyberware_dilation + variable = TRUE + // ========================================================================= -// THE STOP +// THE EFFECT // ========================================================================= -/// Lives as long as the stop, holds the field on the user, and ends the stop when it's deleted. +/// Lives as long as the dilation, holds the field on the user, and ends it when deleted. /obj/effect/timestop/sandevistan name = "stopped moment" desc = null hidden = TRUE alpha = 0 freezerange = SANDY_STOP_FIELD_RANGE - /// Whose stop this is. The field is hosted on them. + /// Whose dilation this is. The field is hosted on them. var/mob/living/user + /// How long the world is frozen first. + var/freeze_time = 0 + /// How fast the world runs after that. + var/slow_scale = 1 -/// `immune_atoms` must be list(user). -/obj/effect/timestop/sandevistan/Initialize(mapload, radius, time, list/immune_atoms, start = TRUE, silent = TRUE) +/// `immune_atoms` must be list(user). `time` is the whole effect; `freeze` of it is frozen, the rest at `scale`. +/obj/effect/timestop/sandevistan/Initialize(mapload, radius, time, list/immune_atoms, start = TRUE, silent = TRUE, freeze = 0, scale = 1) if(length(immune_atoms)) user = immune_atoms[1] + freeze_time = freeze + slow_scale = scale return ..(mapload, radius, time, immune_atoms, start, TRUE) /obj/effect/timestop/sandevistan/Destroy() @@ -117,78 +168,207 @@ QDEL_IN(src, duration) if(QDELETED(user)) return - var/datum/proximity_monitor/advanced/timestop/sandevistan/field = new(user, freezerange, TRUE, immune, antimagic_flags, FALSE) + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = new(user, freezerange, TRUE, immune, antimagic_flags, FALSE, freeze_time > 0 ? 0 : slow_scale) field.stopper = src + field.slow_scale = slow_scale + if(freeze_time > 0) + field.thaw_at = world.time + freeze_time chronofield = field +// ========================================================================= +// THE FIELD +// ========================================================================= + /datum/proximity_monitor/advanced/timestop/sandevistan - /// The effect that owns this field. Deleting it ends the stop. + /// The effect that owns this field. Deleting it ends the dilation. var/obj/effect/timestop/sandevistan/stopper - /// Frozen bosses, to the world.time they shrug the field off. + /// How fast time runs in here right now. 0 is frozen. + var/time_scale = 0 + /// The slow-mo scale the field drops to once the freeze ends. + var/slow_scale = 1 + /// world.time the freeze ends and slow-mo starts, or 0. + var/thaw_at = 0 + /// world.time of the last tick, for how much time each tick dilated. + var/last_tick = 0 + /// Mobs in hand. + var/list/held_mobs = list() + /// Projectiles in hand, to their own speed. + var/list/held_shots = list() + /// Thrown things in hand. + var/list/held_throws = list() + /// The user's own shots: never tinted. + var/list/user_shots = list() + /// Bosses in hand, to the world.time they shrug the field off. var/list/boss_holds = list() /// Bosses that already shrugged it off. The field leaves them be. var/list/released = list() - /// The user's own shots, hanging in the air. - var/list/hung_shots = list() -/datum/proximity_monitor/advanced/timestop/sandevistan/New(atom/_host, range, _ignore_if_not_on_turf = TRUE, list/immune, antimagic_flags, channelled) +/datum/proximity_monitor/advanced/timestop/sandevistan/New(atom/_host, range, _ignore_if_not_on_turf = TRUE, list/immune, antimagic_flags, channelled, start_scale = 0) + time_scale = start_scale + last_tick = world.time . = ..() RegisterSignal(host, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, PROC_REF(on_user_fire)) /datum/proximity_monitor/advanced/timestop/sandevistan/Destroy() if(host) UnregisterSignal(host, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE) - for(var/obj/projectile/shot as anything in hung_shots) - UnregisterSignal(shot, COMSIG_QDELETING) - shot.paused = FALSE - hung_shots.Cut() stopper = null - . = ..() + . = ..() // unfreeze_all() lets everything go released.Cut() - boss_holds.Cut() /datum/proximity_monitor/advanced/timestop/sandevistan/freeze_atom(atom/movable/A) - if(released[A]) + if(!istype(A) || GLOB.cyberware_time_dilated[A] || global_frozen_atoms[A] || released[A] || immune[A]) return FALSE - if(isprojectile(A)) - var/obj/projectile/shot = A - if(shot.firer && immune[shot.firer]) // the user's own shots hang on their own clock + if(ismob(A)) + var/mob/M = A + if(M.can_block_magic(antimagic_flags)) + immune[A] = TRUE return FALSE - return ..() + var/tint = TRUE + if(isliving(A)) + hold_mob(A) + else if(isprojectile(A)) + var/obj/projectile/shot = A + if(shot.firer && immune[shot.firer]) + if(time_scale <= 0) // hang_shot takes these + return FALSE + user_shots[shot] = TRUE + tint = FALSE + hold_shot(shot) + else if((ismachinery(A) && !istype(A, /obj/machinery/light)) || isstructure(A) || ismecha(A)) + frozen_structures += A // looks only + else if(!A.throwing) + return FALSE + if(A.throwing) + held_throws[A] = TRUE + if(time_scale <= 0) + A.throwing.paused = TRUE + GLOB.cyberware_time_dilated[A] = src + frozen_things[A] = TRUE + if(tint) + into_the_negative_zone(A) + SEND_SIGNAL(A, COMSIG_ATOM_TIMESTOP_FREEZE, src) + return TRUE -/datum/proximity_monitor/advanced/timestop/sandevistan/freeze_mob(mob/living/victim) - . = ..() - if(victim.ai_controller) - ADD_TRAIT(victim, TRAIT_AI_PAUSED, SANDY_STOP_TRAIT) +/datum/proximity_monitor/advanced/timestop/sandevistan/unfreeze_all() + for(var/atom/movable/thing as anything in frozen_things) + unfreeze_atom(thing) + for(var/turf/ground as anything in frozen_turfs) + unfreeze_turf(ground) + frozen_turfs.Cut() + +/datum/proximity_monitor/advanced/timestop/sandevistan/unfreeze_atom(atom/movable/A) + if(held_mobs[A]) + let_go_mob(A) + if(held_shots[A]) + let_go_shot(A) + if(held_throws[A]) + var/datum/thrownthing/throw_datum = A.throwing + if(throw_datum) + throw_datum.paused = FALSE + held_throws -= A + frozen_structures -= A + user_shots -= A + escape_the_negative_zone(A) + frozen_things -= A + GLOB.cyberware_time_dilated -= A + SEND_SIGNAL(A, COMSIG_ATOM_TIMESTOP_UNFREEZE, src) + +// ----- mobs ----- + +/// Takes hold of a mob: frozen or slowed, whichever the field is doing. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/hold_mob(mob/living/victim) + held_mobs[victim] = TRUE + if(time_scale <= 0) + freeze_mob(victim) + else + slow_mob(victim) if(cyberware_time_stop_boss(victim)) boss_holds[victim] = world.time + SANDY_STOP_BOSS_HOLD -/datum/proximity_monitor/advanced/timestop/sandevistan/unfreeze_mob(mob/living/victim) - REMOVE_TRAIT(victim, TRAIT_AI_PAUSED, SANDY_STOP_TRAIT) +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/let_go_mob(mob/living/victim) + unfreeze_mob(victim) + unslow_mob(victim) + held_mobs -= victim boss_holds -= victim - return ..() -/datum/proximity_monitor/advanced/timestop/sandevistan/process() - if(QDELETED(host) || cyberware_time_stop_witnessed(host)) - if(!QDELETED(stopper)) - qdel(stopper) // takes this field with it +/datum/proximity_monitor/advanced/timestop/sandevistan/freeze_mob(mob/living/victim) + frozen_mobs[victim] = TRUE + victim.add_traits(list(TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED, TRAIT_AI_PAUSED, TRAIT_MUTE, TRAIT_EMOTEMUTE), SANDY_STOP_TRAIT) + GLOB.move_manager.stop_looping(victim) + if(isanimal(victim)) // the old simple mob AI doesn't read TRAIT_AI_PAUSED + var/mob/living/simple_animal/animal_victim = victim + animal_victim.toggle_ai(AI_OFF) + +/datum/proximity_monitor/advanced/timestop/sandevistan/unfreeze_mob(mob/living/victim) + if(!frozen_mobs[victim]) return - for(var/mob/living/boss as anything in boss_holds.Copy()) - if(world.time < boss_holds[boss]) - continue - unfreeze_atom(boss) - released[boss] = TRUE - return ..() + frozen_mobs -= victim + victim.remove_traits(list(TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED, TRAIT_AI_PAUSED, TRAIT_MUTE, TRAIT_EMOTEMUTE), SANDY_STOP_TRAIT) + if(isanimal(victim)) + var/mob/living/simple_animal/animal_victim = victim + animal_victim.toggle_ai(initial(animal_victim.AIStatus)) -/datum/proximity_monitor/advanced/timestop/sandevistan/into_the_negative_zone(atom/A) - A.add_atom_colour(cyberware_time_stop_tint(), TEMPORARY_COLOUR_PRIORITY) +/// Slows a mob's walk to the field's pace. Its timers are pushed back every tick in process(). +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/slow_mob(mob/living/victim) + var/base_delay = max(victim.cached_multiplicative_slowdown, 1) + victim.add_or_update_variable_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation, multiplicative_slowdown = base_delay * (1 / time_scale - 1)) + if(istype(victim, /mob/living/simple_animal/hostile)) + var/mob/living/simple_animal/hostile/animal_victim = victim + animal_victim.move_to_delay = initial(animal_victim.move_to_delay) / time_scale + +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/unslow_mob(mob/living/victim) + victim.remove_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation) + if(istype(victim, /mob/living/simple_animal/hostile)) + var/mob/living/simple_animal/hostile/animal_victim = victim + animal_victim.move_to_delay = initial(animal_victim.move_to_delay) + +/// Pushes every timing deadline `victim` has that hasn't passed yet back by `push` deciseconds. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/push_deadlines(mob/living/victim, push) + var/now = world.time + if(victim.next_move > now) + victim.next_move += push + for(var/datum/action/cooldown/ability in victim.actions) + if(ability.next_use_time > now) + ability.next_use_time += push + var/datum/ai_controller/controller = victim.ai_controller + if(controller) + for(var/behavior in controller.behavior_cooldowns) + if(controller.behavior_cooldowns[behavior] > now) + controller.behavior_cooldowns[behavior] += push + for(var/key in dilated_blackboard_keys()) + var/deadline = controller.blackboard[key] + if(isnum(deadline) && deadline > now) + controller.blackboard[key] = deadline + push + var/datum/component/ranged_attacks/ranged = victim.GetComponent(/datum/component/ranged_attacks) + if(ranged && ranged.fire_cooldown > now) + ranged.fire_cooldown += push + if(istype(victim, /mob/living/simple_animal/hostile)) + var/mob/living/simple_animal/hostile/animal_victim = victim + if(animal_victim.ranged_cooldown > now) + animal_victim.ranged_cooldown += push + +// ----- projectiles ----- + +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/hold_shot(obj/projectile/shot) + held_shots[shot] = shot.speed + if(time_scale <= 0) + shot.paused = TRUE + else + shot.speed = held_shots[shot] * time_scale + +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/let_go_shot(obj/projectile/shot) + shot.speed = held_shots[shot] + shot.paused = FALSE + held_shots -= shot /// Signal proc for [COMSIG_PROJECTILE_FIRER_BEFORE_FIRE] on the user: watch the shot leave the barrel. /datum/proximity_monitor/advanced/timestop/sandevistan/proc/on_user_fire(datum/source, obj/projectile/shot, atom/fired_from, atom/original) SIGNAL_HANDLER - RegisterSignal(shot, COMSIG_PROJECTILE_FIRE, PROC_REF(hang_shot)) + if(time_scale <= 0) + RegisterSignal(shot, COMSIG_PROJECTILE_FIRE, PROC_REF(hang_shot)) -/// Signal proc for [COMSIG_PROJECTILE_FIRE]: the shot flies a tile, then hangs until time restarts. +/// Signal proc for [COMSIG_PROJECTILE_FIRE]: in a frozen world the user's shot flies a tile, then hangs. /datum/proximity_monitor/advanced/timestop/sandevistan/proc/hang_shot(obj/projectile/shot) SIGNAL_HANDLER UnregisterSignal(shot, COMSIG_PROJECTILE_FIRE) @@ -199,43 +379,100 @@ shot.process_movement(SANDY_STOP_SHOT_HANG) if(QDELETED(shot) || shot.deletion_queued) return - shot.paused = TRUE - hung_shots += shot - RegisterSignal(shot, COMSIG_QDELETING, PROC_REF(shot_gone)) + user_shots[shot] = TRUE + hold_shot(shot) + GLOB.cyberware_time_dilated[shot] = src + frozen_things[shot] = TRUE + RegisterSignal(shot, COMSIG_QDELETING, PROC_REF(held_gone)) -/datum/proximity_monitor/advanced/timestop/sandevistan/proc/shot_gone(obj/projectile/shot) +/// Forgets a held thing that got deleted. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/held_gone(atom/movable/thing) SIGNAL_HANDLER - hung_shots -= shot + held_shots -= thing + user_shots -= thing + frozen_things -= thing + GLOB.cyberware_time_dilated -= thing + +// ----- the clock ----- + +/// Moves the whole field to a new pace: frozen things start moving, slowed things change speed. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/set_time_scale(new_scale) + time_scale = new_scale + for(var/mob/living/victim as anything in held_mobs) + unfreeze_mob(victim) + if(time_scale <= 0) + unslow_mob(victim) + freeze_mob(victim) + else + slow_mob(victim) + for(var/obj/projectile/shot as anything in held_shots) + shot.paused = time_scale <= 0 + if(time_scale > 0) + shot.speed = held_shots[shot] * time_scale + for(var/atom/movable/thing as anything in held_throws) + if(thing.throwing) + thing.throwing.paused = time_scale <= 0 + +/datum/proximity_monitor/advanced/timestop/sandevistan/process() + if(QDELETED(host) || cyberware_time_stop_witnessed(host)) + if(!QDELETED(stopper)) + qdel(stopper) // takes this field with it + return + var/elapsed = world.time - last_tick + last_tick = world.time + var/push = elapsed * (1 - time_scale) + if(push > 0) + for(var/mob/living/victim as anything in held_mobs) + push_deadlines(victim, push) + for(var/atom/movable/thing as anything in held_throws) + var/datum/thrownthing/throw_datum = thing.throwing + if(throw_datum && !throw_datum.paused) + throw_datum.start_time += push + for(var/mob/living/boss as anything in boss_holds.Copy()) + if(world.time < boss_holds[boss]) + continue + unfreeze_atom(boss) + released[boss] = TRUE + if(thaw_at && world.time >= thaw_at) + thaw_at = 0 + set_time_scale(slow_scale) + +/datum/proximity_monitor/advanced/timestop/sandevistan/into_the_negative_zone(atom/A) + A.add_atom_colour(cyberware_time_stop_tint(), TEMPORARY_COLOUR_PRIORITY) // ========================================================================= // THE WINDOW'S SIDE // ========================================================================= /datum/status_effect/cyberware_cascade_window - /// How long the world stops if nobody else is near when the window opens. Copied from the lattice. - var/stop_duration = 4 SECONDS - /// The running stop, if time is stopped. + /// How long the world is frozen if nobody else is near when the window opens. Copied from the lattice. + var/dilation_freeze = 1 SECONDS + /// How long slow-mo runs after the freeze. + var/dilation_time = 3 SECONDS + /// How fast the world runs in slow-mo. + var/dilation_scale = 0.2 + /// The running dilation, if any. var/obj/effect/timestop/sandevistan/time_stop -/// Stops time if the bearer is a player and nobody else is near. Called as the window opens. +/// Slows the world if the bearer is a player and nobody else is near. Called as the window opens. /datum/status_effect/cyberware_cascade_window/proc/try_time_stop() - if(stop_duration <= 0 || !owner.ckey || time_stop) + if(dilation_freeze + dilation_time <= 0 || !owner.ckey || time_stop) return FALSE if(cyberware_time_stop_witnessed(owner)) return FALSE var/turf/here = get_turf(owner) if(!here) return FALSE - time_stop = new(here, SANDY_STOP_FIELD_RANGE, stop_duration, list(owner)) + time_stop = new(here, SANDY_STOP_FIELD_RANGE, dilation_freeze + dilation_time, list(owner), TRUE, TRUE, dilation_freeze, dilation_scale) if(QDELETED(time_stop)) time_stop = null return FALSE RegisterSignal(time_stop, COMSIG_QDELETING, PROC_REF(time_stop_over)) - owner.remove_client_colour(id) // the user sees the frozen world as it is + owner.remove_client_colour(id) // the user sees the slowed world as it is owner.add_filter(SANDY_STOP_OUTLINE, 2, outline_filter(1, "#5AFF96D2")) return TRUE -/// Signal proc for [COMSIG_QDELETING] on the stop: time restarts and the rest of the window runs as normal. +/// Signal proc for [COMSIG_QDELETING] on the dilation: time runs normally and the rest of the window carries on. /datum/status_effect/cyberware_cascade_window/proc/time_stop_over(datum/source) SIGNAL_HANDLER time_stop = null @@ -244,7 +481,7 @@ owner.remove_filter(SANDY_STOP_OUTLINE) owner.add_client_colour(colour_type, id) -/// Ends the stop early (the window is closing). +/// Ends the dilation early (the window is closing). /datum/status_effect/cyberware_cascade_window/proc/end_time_stop() if(!time_stop) return @@ -253,7 +490,7 @@ if(!QDELETED(owner)) owner.remove_filter(SANDY_STOP_OUTLINE) -/// One afterimage while time is stopped: green, gone in 0.4 s. It sits on the user's own tile, shifted back to +/// One afterimage while time is dilated: green, gone in 0.4 s. It sits on the user's own tile, shifted back to /// where they were, so it sorts on their row and draws behind them in every facing. /datum/status_effect/cyberware_cascade_window/proc/stopped_afterimage(turf/old_loc) var/turf/here = get_turf(owner) @@ -266,7 +503,7 @@ image.pixel_z = owner.pixel_z + (old_loc.y - here.y) * ICON_SIZE_Y image.layer = owner.layer - 0.01 -/// The afterimage left while time is stopped. +/// The afterimage left while time is dilated. /obj/effect/temp_visual/decoy/fading/cascade/stopped duration = 4 diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm index a6c376d4f55..b4d09339dae 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -1,26 +1,28 @@ /** - * # The Cascade line's time stop + * # The Cascade line's time dilation * - * voidcrew/modules/cyberware/ware_sandy_timestop.dm. A player's surge with no other player near stops time: - * the field spawns, a human NPC is stunned with its AI paused, the user can still act, and the NPC is let go - * when the stop runs out. Another player near means no stop at all. Uses the street rig (2 s stop) to keep - * the waits short. + * voidcrew/modules/cyberware/ware_sandy_timestop.dm. A player's surge with no other player near slows the + * world: the Cascade lattice freezes it for 1 s (a human NPC is held by tg's immobilise, hand-block and + * AI-pause traits, and its timers stop), then runs it at 20% for 3 s (the NPC walks under a movespeed + * modifier), then lets go. The user can act the whole time. Another player near means no dilation at all. + * + * Unit-test files compile before voidcrew/_DEFINES/, so literals stand in for module defines. */ /datum/unit_test/voidcrew_cyberware_timestop abstract_type = /datum/unit_test/voidcrew_cyberware_timestop -/// A player body (a key, no client) at `where` with the street rig in it +/// A player body (a key, no client) at `where` with a Cascade lattice in it /datum/unit_test/voidcrew_cyberware_timestop/proc/make_user(turf/where, key) var/mob/living/carbon/human/user = allocate(/mob/living/carbon/human/consistent, where) user.key = key - var/obj/item/organ/cyberimp/cyberware/cascade/street/rig = allocate(/obj/item/organ/cyberimp/cyberware/cascade/street) - TEST_ASSERT(rig.Insert(user, special = TRUE), "Test fixture: the reflex rig wouldn't go in") + var/obj/item/organ/cyberimp/cyberware/cascade/lattice = allocate(/obj/item/organ/cyberimp/cyberware/cascade) + TEST_ASSERT(lattice.Insert(user, special = TRUE), "Test fixture: the lattice wouldn't go in") return user -/// Opens the rig's window the way its surge button does +/// Opens the lattice's window the way its surge button does /datum/unit_test/voidcrew_cyberware_timestop/proc/surge(mob/living/carbon/human/user) - var/obj/item/organ/cyberimp/cyberware/cascade/rig = locate() in user.organs - return user.apply_status_effect(/datum/status_effect/cyberware_cascade_window, rig) + var/obj/item/organ/cyberimp/cyberware/cascade/lattice = locate() in user.organs + return user.apply_status_effect(/datum/status_effect/cyberware_cascade_window, lattice) /// A turf `dx`, `dy` from the bottom left of the test room /datum/unit_test/voidcrew_cyberware_timestop/proc/spot(dx, dy) @@ -31,40 +33,52 @@ body.key = null return ..() -/// Nobody else near: time stops, the NPC freezes with its AI paused, the user can still act, and it all ends on time +/// Nobody else near: the world freezes, then slows, then lets go on time; the user can act throughout /datum/unit_test/voidcrew_cyberware_timestop/alone /datum/unit_test/voidcrew_cyberware_timestop/alone/Run() var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") var/mob/living/carbon/human/npc/raider = allocate(/mob/living/carbon/human/npc, spot(2, 0)) + var/next_move_was = world.time + 5 SECONDS + raider.next_move = next_move_was var/datum/status_effect/cyberware_cascade_window/window = surge(user) - TEST_ASSERT_NOTNULL(window, "The rig's window didn't open") + TEST_ASSERT_NOTNULL(window, "The lattice's window didn't open") sleep(0.1 SECONDS) // the field starts async - TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't stop with nobody else near") - TEST_ASSERT(istype(window.time_stop.chronofield, /datum/proximity_monitor/advanced/timestop/sandevistan), "The stop has no field") + TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") + TEST_ASSERT(istype(window.time_stop.chronofield, /datum/proximity_monitor/advanced/timestop/sandevistan), "The dilation has no field") - TEST_ASSERT(raider.IsStun(), "The NPC wasn't frozen") - TEST_ASSERT(HAS_TRAIT(raider, TRAIT_AI_PAUSED), "The NPC's AI kept running in stopped time") - TEST_ASSERT(!user.IsStun(), "The user froze in their own time stop") - TEST_ASSERT(!HAS_TRAIT(user, TRAIT_AI_PAUSED), "The user's AI was paused") + // The freeze: tg's own traits, no Stun. + TEST_ASSERT(HAS_TRAIT(raider, TRAIT_IMMOBILIZED), "The NPC wasn't held still") + TEST_ASSERT(HAS_TRAIT(raider, TRAIT_HANDS_BLOCKED), "The NPC's hands weren't stopped") + TEST_ASSERT(HAS_TRAIT(raider, TRAIT_AI_PAUSED), "The NPC's AI kept running in frozen time") + TEST_ASSERT(!raider.IsStun(), "The freeze stunned the NPC instead of holding it") + TEST_ASSERT(!HAS_TRAIT(user, TRAIT_IMMOBILIZED) && !HAS_TRAIT(user, TRAIT_HANDS_BLOCKED), "The user froze in their own time stop") var/turf/start = get_turf(user) user.Move(spot(0, 1), NORTH) - TEST_ASSERT(get_turf(user) != start, "The user couldn't move in stopped time") + TEST_ASSERT(get_turf(user) != start, "The user couldn't move in frozen time") // Frozen bodies still get hurt through tg. var/brute_before = raider.getBruteLoss() raider.apply_damage(10, BRUTE, BODY_ZONE_CHEST) TEST_ASSERT(raider.getBruteLoss() > brute_before, "A frozen NPC didn't take damage") - sleep(2.3 SECONDS) // the street rig stops time for 2 s - TEST_ASSERT_NULL(window.time_stop, "The time stop outlived its 2 s") - TEST_ASSERT(!raider.IsStun(), "The NPC stayed frozen after time restarted") - TEST_ASSERT(!HAS_TRAIT(raider, TRAIT_AI_PAUSED), "The NPC's AI stayed paused after time restarted") - TEST_ASSERT(user.has_status_effect(/datum/status_effect/cyberware_cascade_window), "The window didn't carry on after the stop") + sleep(0.8 SECONDS) // still inside the Cascade's 1 s freeze + TEST_ASSERT(raider.next_move > next_move_was + 0.5 SECONDS, "The NPC's timers ran on through frozen time") + + sleep(0.5 SECONDS) // then the world runs at 20% + TEST_ASSERT_NOTNULL(window.time_stop, "The dilation ended with the freeze") + TEST_ASSERT(!HAS_TRAIT(raider, TRAIT_IMMOBILIZED) && !HAS_TRAIT(raider, TRAIT_AI_PAUSED), "The NPC stayed frozen into the slow-mo") + TEST_ASSERT(raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "The NPC isn't slowed in slow-mo") + + sleep(2.9 SECONDS) // the rest of the 3 s of slow-mo + TEST_ASSERT_NULL(window.time_stop, "The dilation outlived its 4 s") + TEST_ASSERT(!raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "The NPC stayed slow after the dilation") + TEST_ASSERT(!HAS_TRAIT(raider, TRAIT_AI_PAUSED), "The NPC's AI stayed paused after the dilation") + TEST_ASSERT(user.has_status_effect(/datum/status_effect/cyberware_cascade_window), "The window didn't carry on after the dilation") user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) -/// Another player near: the plain surge, no stop +/// Another player near: the plain surge, no dilation /datum/unit_test/voidcrew_cyberware_timestop/witnessed /datum/unit_test/voidcrew_cyberware_timestop/witnessed/Run() @@ -73,10 +87,21 @@ other.key = "sandytimestopother" // a player body, client or not var/mob/living/carbon/human/npc/raider = allocate(/mob/living/carbon/human/npc, spot(2, 0)) var/datum/status_effect/cyberware_cascade_window/window = surge(user) - TEST_ASSERT_NOTNULL(window, "The rig's window didn't open") + TEST_ASSERT_NOTNULL(window, "The lattice's window didn't open") + sleep(0.1 SECONDS) + TEST_ASSERT_NULL(window.time_stop, "Time slowed with another player in sight") + TEST_ASSERT(!HAS_TRAIT(raider, TRAIT_IMMOBILIZED), "The NPC froze without a dilation") + user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) + user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + +/// An NPC's surge never slows the world, whoever is near +/datum/unit_test/voidcrew_cyberware_timestop/npc_surge + +/datum/unit_test/voidcrew_cyberware_timestop/npc_surge/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), null) + var/datum/status_effect/cyberware_cascade_window/window = surge(user) + TEST_ASSERT_NOTNULL(window, "The lattice's window didn't open") sleep(0.1 SECONDS) - TEST_ASSERT_NULL(window.time_stop, "Time stopped with another player in sight") - TEST_ASSERT(!raider.IsStun(), "The NPC froze without a time stop") - TEST_ASSERT(!(locate(/obj/effect/timestop/sandevistan) in world), "A time stop field spawned with another player in sight") + TEST_ASSERT_NULL(window.time_stop, "A surge with no player behind it slowed the world") user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) From df73bd639fa4aa68f597dbb1f2672839f002a8eb Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:45:05 -0800 Subject: [PATCH 04/33] Run the dilation on its own timer and keep the user's shots in colour --- voidcrew/modules/cyberware/ware_sandy_timestop.dm | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index ae71484ebaf..e7a2435c9c9 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -190,6 +190,8 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) var/thaw_at = 0 /// world.time of the last tick, for how much time each tick dilated. var/last_tick = 0 + /// The field's own clock. A timer, not SSfastprocess, which sleeps outside RUNLEVEL_GAME. + var/tick_timer /// Mobs in hand. var/list/held_mobs = list() /// Projectiles in hand, to their own speed. @@ -207,9 +209,13 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) time_scale = start_scale last_tick = world.time . = ..() + STOP_PROCESSING(SSfastprocess, src) // tick() runs on tick_timer instead + tick_timer = addtimer(CALLBACK(src, PROC_REF(tick)), 0.2 SECONDS, TIMER_LOOP | TIMER_STOPPABLE | TIMER_DELETE_ME) RegisterSignal(host, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, PROC_REF(on_user_fire)) /datum/proximity_monitor/advanced/timestop/sandevistan/Destroy() + deltimer(tick_timer) + tick_timer = null if(host) UnregisterSignal(host, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE) stopper = null @@ -234,6 +240,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) return FALSE user_shots[shot] = TRUE tint = FALSE + escape_the_negative_zone(shot) // in case it was tinted before it had a firer hold_shot(shot) else if((ismachinery(A) && !istype(A, /obj/machinery/light)) || isstructure(A) || ismecha(A)) frozen_structures += A // looks only @@ -380,7 +387,9 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) if(QDELETED(shot) || shot.deletion_queued) return user_shots[shot] = TRUE - hold_shot(shot) + escape_the_negative_zone(shot) // in case it was tinted before it had a firer + if(!held_shots[shot]) + hold_shot(shot) GLOB.cyberware_time_dilated[shot] = src frozen_things[shot] = TRUE RegisterSignal(shot, COMSIG_QDELETING, PROC_REF(held_gone)) @@ -414,6 +423,10 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) thing.throwing.paused = time_scale <= 0 /datum/proximity_monitor/advanced/timestop/sandevistan/process() + return PROCESS_KILL + +/// Every 0.2 s: end if a player came in, push back the timers of everything held, let bosses go, end the freeze. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/tick() if(QDELETED(host) || cyberware_time_stop_witnessed(host)) if(!QDELETED(stopper)) qdel(stopper) // takes this field with it From 5d39c969996c01929d9f05ae398f359c524fdd5d Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:52:40 -0800 Subject: [PATCH 05/33] Keep the user's slow-mo shots in colour and drop deleted things from the field --- .../modules/cyberware/ware_sandy_timestop.dm | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index e7a2435c9c9..98a3190cee3 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -252,6 +252,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) A.throwing.paused = TRUE GLOB.cyberware_time_dilated[A] = src frozen_things[A] = TRUE + RegisterSignal(A, COMSIG_QDELETING, PROC_REF(held_gone), override = TRUE) if(tint) into_the_negative_zone(A) SEND_SIGNAL(A, COMSIG_ATOM_TIMESTOP_FREEZE, src) @@ -276,6 +277,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) held_throws -= A frozen_structures -= A user_shots -= A + UnregisterSignal(A, COMSIG_QDELETING) escape_the_negative_zone(A) frozen_things -= A GLOB.cyberware_time_dilated -= A @@ -372,33 +374,50 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) /// Signal proc for [COMSIG_PROJECTILE_FIRER_BEFORE_FIRE] on the user: watch the shot leave the barrel. /datum/proximity_monitor/advanced/timestop/sandevistan/proc/on_user_fire(datum/source, obj/projectile/shot, atom/fired_from, atom/original) SIGNAL_HANDLER - if(time_scale <= 0) - RegisterSignal(shot, COMSIG_PROJECTILE_FIRE, PROC_REF(hang_shot)) + RegisterSignal(shot, COMSIG_PROJECTILE_FIRE, PROC_REF(hang_shot)) -/// Signal proc for [COMSIG_PROJECTILE_FIRE]: in a frozen world the user's shot flies a tile, then hangs. +/// Signal proc for [COMSIG_PROJECTILE_FIRE]: the user's shot keeps its colour. In a frozen world it flies a +/// tile, then hangs; in slow-mo it crawls like everything else. /datum/proximity_monitor/advanced/timestop/sandevistan/proc/hang_shot(obj/projectile/shot) SIGNAL_HANDLER UnregisterSignal(shot, COMSIG_PROJECTILE_FIRE) if(QDELETED(shot) || shot.deletion_queued) return + if(time_scale > 0) + user_shots[shot] = TRUE + escape_the_negative_zone(shot) // in case it was tinted before it had a firer + if(!held_shots[shot]) + hold_shot(shot) + GLOB.cyberware_time_dilated[shot] = src + frozen_things[shot] = TRUE + RegisterSignal(shot, COMSIG_QDELETING, PROC_REF(held_gone), override = TRUE) + return // A hitscan beam takes its first step itself once paused (process_hitscan) and stops there. if(!shot.hitscan) + shot.paused = FALSE // it may have been caught before it had a firer shot.process_movement(SANDY_STOP_SHOT_HANG) if(QDELETED(shot) || shot.deletion_queued) return user_shots[shot] = TRUE escape_the_negative_zone(shot) // in case it was tinted before it had a firer - if(!held_shots[shot]) + if(held_shots[shot]) + shot.paused = TRUE + else hold_shot(shot) GLOB.cyberware_time_dilated[shot] = src frozen_things[shot] = TRUE - RegisterSignal(shot, COMSIG_QDELETING, PROC_REF(held_gone)) + RegisterSignal(shot, COMSIG_QDELETING, PROC_REF(held_gone), override = TRUE) /// Forgets a held thing that got deleted. /datum/proximity_monitor/advanced/timestop/sandevistan/proc/held_gone(atom/movable/thing) SIGNAL_HANDLER held_shots -= thing user_shots -= thing + held_mobs -= thing + frozen_mobs -= thing + held_throws -= thing + boss_holds -= thing + frozen_structures -= thing frozen_things -= thing GLOB.cyberware_time_dilated -= thing From e29bb287f8caebb3d082eb17647258512a0e31d0 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:59:56 -0800 Subject: [PATCH 06/33] Slow do_after, queued bursts, mechs, turrets and grenade fuses in the dilation field --- code/__HELPERS/mobs.dm | 8 + code/datums/components/ranged_attacks.dm | 6 +- code/game/objects/items/grenades/_grenade.dm | 2 +- code/modules/projectiles/gun.dm | 14 +- code/modules/vehicles/mecha/_mecha.dm | 2 +- .../mecha/equipment/mecha_equipment.dm | 2 +- .../modules/cyberware/ware_sandy_timestop.dm | 264 +++++++++++++++++- .../unit_tests/voidcrew_cyberware_timestop.dm | 74 +++++ 8 files changed, 357 insertions(+), 15 deletions(-) diff --git a/code/__HELPERS/mobs.dm b/code/__HELPERS/mobs.dm index 252ed732ca4..06f5da9acea 100644 --- a/code/__HELPERS/mobs.dm +++ b/code/__HELPERS/mobs.dm @@ -246,10 +246,18 @@ GLOBAL_LIST_INIT(skin_tone_names, list( var/endtime = world.time + delay var/starttime = world.time + var/dilation_tick = world.time // VOIDCREW EDIT ADDITION - cyberware time dilation . = TRUE while (world.time < endtime) stoplag(1) + // VOIDCREW EDIT ADDITION START - cyberware time dilation (voidcrew/modules/cyberware/ware_sandy_timestop.dm): a slowed body works slower, a frozen one not at all + var/dilation_lost = cyberware_time_lost(user, world.time - dilation_tick) + dilation_tick = world.time + endtime += dilation_lost + starttime += dilation_lost + // VOIDCREW EDIT ADDITION END + if(!QDELETED(progbar)) progbar.update(world.time - starttime) diff --git a/code/datums/components/ranged_attacks.dm b/code/datums/components/ranged_attacks.dm index b998362cfc6..787c3b0b527 100644 --- a/code/datums/components/ranged_attacks.dm +++ b/code/datums/components/ranged_attacks.dm @@ -66,8 +66,12 @@ INVOKE_ASYNC(src, PROC_REF(async_fire_ranged_attack), firer, target, modifiers) if(isnull(burst_shots)) return + // VOIDCREW EDIT CHANGE START - cyberware time dilation (voidcrew/modules/cyberware/ware_sandy_timestop.dm): each burst shot waits for the mob's own clock, so a slowed mob's burst slows down too + cyberware_burst_start = cyberware_burst_clock(firer) for(var/i in 1 to (burst_shots - 1)) - addtimer(CALLBACK(src, PROC_REF(async_fire_ranged_attack), firer, target, modifiers), i * burst_intervals) + addtimer(CALLBACK(src, PROC_REF(cyberware_burst_shot), firer, target, modifiers, i), i * burst_intervals) + // ORIGINAL: addtimer(CALLBACK(src, PROC_REF(async_fire_ranged_attack), firer, target, modifiers), i * burst_intervals) + // VOIDCREW EDIT CHANGE END /// Actually fire the damn thing /datum/component/ranged_attacks/proc/async_fire_ranged_attack(mob/living/basic/firer, atom/target, modifiers) diff --git a/code/game/objects/items/grenades/_grenade.dm b/code/game/objects/items/grenades/_grenade.dm index 262a493cc75..dc7317e696c 100644 --- a/code/game/objects/items/grenades/_grenade.dm +++ b/code/game/objects/items/grenades/_grenade.dm @@ -166,7 +166,7 @@ active = TRUE icon_state = (base_icon_state || initial(icon_state)) + "_active" SEND_SIGNAL(src, COMSIG_GRENADE_ARMED, det_time, delayoverride) - addtimer(CALLBACK(src, PROC_REF(detonate)), isnull(delayoverride)? det_time : delayoverride) + addtimer(CALLBACK(src, PROC_REF(cyberware_fuse_done)), isnull(delayoverride)? det_time : delayoverride) // VOIDCREW EDIT CHANGE - cyberware time dilation (voidcrew/modules/cyberware/ware_sandy_timestop.dm): the fuse burns slow in a slowed field. ORIGINAL: addtimer(CALLBACK(src, PROC_REF(detonate)), isnull(delayoverride)? det_time : delayoverride) /** * detonate (formerly prime) refers to when the grenade actually delivers its payload (whether or not a boom/bang/detonation is involved) diff --git a/code/modules/projectiles/gun.dm b/code/modules/projectiles/gun.dm index 371efe8e779..52ff771aac3 100644 --- a/code/modules/projectiles/gun.dm +++ b/code/modules/projectiles/gun.dm @@ -448,9 +448,10 @@ if(!user || !firing_burst) firing_burst = FALSE return FALSE - // VOIDCREW EDIT ADDITION START - cyberware time dilation (voidcrew/modules/cyberware/ware_sandy_timestop.dm): a burst queued before the world froze waits for it to move again - if(!cyberware_time_scale(user)) - addtimer(CALLBACK(src, PROC_REF(process_burst), user, target, message, params, zone_override, random_spread, burst_spread_mult, iteration), 1) + // VOIDCREW EDIT ADDITION START - cyberware time dilation (voidcrew/modules/cyberware/ware_sandy_timestop.dm): each burst shot waits for the shooter's own clock, so a burst slows or stops with them + var/dilation_wait = cyberware_burst_wait(user, cyberware_burst_start, cyberware_burst_gap, iteration - 1) + if(dilation_wait > 0) + addtimer(CALLBACK(src, PROC_REF(process_burst), user, target, message, params, zone_override, random_spread, burst_spread_mult, iteration), dilation_wait) return TRUE // VOIDCREW EDIT ADDITION END if(!issilicon(user)) @@ -523,10 +524,9 @@ modified_burst_delay = ROUND_UP(burst_delay * 0.5) modified_fire_delay = ROUND_UP(fire_delay * 0.5) // VOIDCREW EDIT ADDITION START - cyberware time dilation (voidcrew/modules/cyberware/ware_sandy_timestop.dm): a slowed shooter's gun cycles slowly too - var/dilation = cyberware_time_scale(user) - if(dilation < 1) - modified_burst_delay /= max(dilation, 0.1) - modified_fire_delay /= max(dilation, 0.1) + cyberware_burst_start = cyberware_burst_clock(user) + cyberware_burst_gap = modified_burst_delay + modified_fire_delay = cyberware_dilated_time(user, modified_fire_delay) // VOIDCREW EDIT ADDITION END if(burst_size > 1) diff --git a/code/modules/vehicles/mecha/_mecha.dm b/code/modules/vehicles/mecha/_mecha.dm index 4c9cc36adb4..b2e64b15edf 100644 --- a/code/modules/vehicles/mecha/_mecha.dm +++ b/code/modules/vehicles/mecha/_mecha.dm @@ -708,7 +708,7 @@ SEND_SIGNAL(user, COMSIG_MOB_USED_CLICK_MECH_MELEE, src) if(target.mech_melee_attack(src, user)) - TIMER_COOLDOWN_START(src, COOLDOWN_MECHA_MELEE_ATTACK, melee_cooldown) + TIMER_COOLDOWN_START(src, COOLDOWN_MECHA_MELEE_ATTACK, cyberware_dilated_time(src, melee_cooldown)) // VOIDCREW EDIT CHANGE - cyberware time dilation (voidcrew/modules/cyberware/ware_sandy_timestop.dm). ORIGINAL: TIMER_COOLDOWN_START(src, COOLDOWN_MECHA_MELEE_ATTACK, melee_cooldown) /// Driver alt clicks anything while in mech /obj/vehicle/sealed/mecha/proc/on_click_alt(mob/user, atom/target, params) diff --git a/code/modules/vehicles/mecha/equipment/mecha_equipment.dm b/code/modules/vehicles/mecha/equipment/mecha_equipment.dm index c03240b4a66..223f5d09c50 100644 --- a/code/modules/vehicles/mecha/equipment/mecha_equipment.dm +++ b/code/modules/vehicles/mecha/equipment/mecha_equipment.dm @@ -114,7 +114,7 @@ return TRUE /obj/item/mecha_parts/mecha_equipment/proc/action(mob/source, atom/target, list/modifiers) - TIMER_COOLDOWN_START(chassis, COOLDOWN_MECHA_EQUIPMENT(type), equip_cooldown)//Cooldown is on the MECH so people dont bypass it by switching equipment + TIMER_COOLDOWN_START(chassis, COOLDOWN_MECHA_EQUIPMENT(type), cyberware_dilated_time(chassis, equip_cooldown)) // VOIDCREW EDIT CHANGE - cyberware time dilation (voidcrew/modules/cyberware/ware_sandy_timestop.dm): a slowed mech's weapons cycle slowly. ORIGINAL: TIMER_COOLDOWN_START(chassis, COOLDOWN_MECHA_EQUIPMENT(type), equip_cooldown)//Cooldown is on the MECH so people dont bypass it by switching equipment SEND_SIGNAL(source, COMSIG_MOB_USED_MECH_EQUIPMENT, chassis) chassis.use_energy(energy_drain) return TRUE diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index 98a3190cee3..2d13d482e49 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -20,9 +20,19 @@ * TRAIT_AI_PAUSED, never a fake state or a Stun. A slowed mob moves under a movespeed modifier, and every * tick the field pushes its timing deadlines back by the time it lost (next_move, ability cooldowns, AI * behaviour cooldowns and the blackboard deadlines listed in dilated_blackboard_keys(), ranged cooldowns). - * Guns scale their fire and burst delays for a slowed shooter (VOIDCREW EDIT in gun.dm). Frozen and slowed - * bodies take damage, wounds and bleeding through tg as normal. Not covered: do_after progress bars and - * burst timers queued before the slow-mo started. + * Frozen and slowed bodies take damage, wounds and bleeding through tg as normal. + * + * Everything else in the field runs on the same slowed clock, through a few helpers below: + * - cyberware_dilated_time() and cyberware_dilated_steps() turn a length of local time into real time, exact + * through the freeze, the slow-mo and the end. Hyperviolence gore uses them (flinches, death throes, blood + * paint, flying gibs), and so do a mech's weapon and melee cooldowns and a gun's fire delay. + * - cyberware_time_lost() stretches do_after for a slowed body (VOIDCREW EDIT in do_after). + * - Burst shots wait on a burst clock the field pushes back (cyberware_burst_wait()), so a burst queued before + * the slow-mo slows with it: guns (gun.dm) and basic mob ranged attacks (ranged_attacks.dm). + * - The field pushes back mech move cooldowns, turret fire cooldowns and lit grenade fuses (_grenade.dm), and + * shuts a mech down for the freeze. + * Things in hand stay held until the effect ends; gore and blood that merely stand in the field are slowed + * while they are within its range. The user and anything the user carries are never slowed. * * Bosses (a finisher user, or a boss-rank human NPC) are held for SANDY_STOP_BOSS_HOLD at most. * @@ -54,6 +64,95 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) var/datum/proximity_monitor/advanced/timestop/sandevistan/field = GLOB.cyberware_time_dilated[thing] return field ? field.time_scale : 1 +/// Every dilation field running now. +GLOBAL_LIST_EMPTY(cyberware_time_fields) + +/** + * The field `thing` runs slow in: the one holding it, or one it stands inside (blood, gibs, a grenade on the + * floor). Never for the user, anything the user carries, or a living thing the field didn't take. + */ +/proc/cyberware_time_field(atom/thing) + if(isnull(thing) || !length(GLOB.cyberware_time_fields)) + return null + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = GLOB.cyberware_time_dilated[thing] + if(field) + return field + if(isliving(thing)) + return null + var/turf/here = get_turf(thing) + if(!here) + return null + var/atom/on_turf = ismovable(thing) ? get_atom_on_turf(thing) : thing + for(field as anything in GLOB.cyberware_time_fields) + if(field.immune[thing] || field.immune[on_turf] || field.released[on_turf]) + continue + var/turf/centre = get_turf(field.host) + if(centre && centre.z == here.z && get_dist(centre, here) <= field.current_range) + return field + return null + +/// How long, in real time, `time` deciseconds of `thing`'s own time take from now. Exact through the freeze, +/// the slow-mo and the end of the effect, as the field stands now. +/proc/cyberware_dilated_time(atom/thing, time) + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = cyberware_time_field(thing) + if(!field) + return time + return field.real_time_for(thing, time) + +/** + * Real-time lengths for a chain of animation steps `times` on `thing`: list(hold, step 1, step 2, ...), or null + * when it isn't slowed. `hold` is how long it sits still first (the freeze). A looping chain just plays at the + * field's pace. + */ +/proc/cyberware_dilated_steps(atom/thing, list/times, loop = 1) + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = cyberware_time_field(thing) + if(!field) + return null + var/list/real = list() + if(loop != 1) + var/scale = field.time_scale > 0 ? field.time_scale : field.slow_scale + real += 0 + for(var/time in times) + real += time / max(scale, 0.05) + return real + var/hold = field.real_time_for(thing, 0) + real += hold + var/local = 0 + var/reached = hold + for(var/time in times) + local += time + var/at = field.real_time_for(thing, local) + real += max(at - reached, 0) + reached = at + return real + +/// Time `thing` lost in the last `elapsed` deciseconds: all of it frozen, none of it outside a field. +/proc/cyberware_time_lost(atom/thing, elapsed) + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = GLOB.cyberware_time_dilated[thing] + if(!field || elapsed <= 0) + return 0 + return elapsed * (1 - field.time_scale) + +/// Time a held `thing` has lost since the field last pushed its deadlines back. +/proc/cyberware_time_lag(atom/thing) + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = GLOB.cyberware_time_dilated[thing] + if(!field) + return 0 + return (world.time - field.last_tick) * (1 - field.time_scale) + +/// A burst clock's start for a burst beginning now. The field pushes it back with the shooter's deadlines. +/proc/cyberware_burst_clock(atom/shooter) + return world.time - cyberware_time_lag(shooter) + +/// How much longer shot `index` (0 for the first) of a burst begun at `start`, `gap` apart, has to wait. +/proc/cyberware_burst_wait(atom/shooter, start, gap, index) + if(!GLOB.cyberware_time_dilated[shooter]) + return max(start + gap * index - world.time, 0) + if(!cyberware_time_scale(shooter)) + return world.tick_lag + var/due = start + cyberware_time_lag(shooter) + gap * index + return due - world.time >= world.tick_lag ? due - world.time : 0 + /// Whether a player other than `user` is within `range` tiles: any living body with a ckey, connected or not. /proc/cyberware_time_stop_witnessed(mob/living/user, range = SANDY_STOP_WITNESS_RANGE) var/turf/here = get_turf(user) @@ -173,6 +272,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) field.slow_scale = slow_scale if(freeze_time > 0) field.thaw_at = world.time + freeze_time + field.ends_at = world.time + duration chronofield = field // ========================================================================= @@ -204,10 +304,19 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) var/list/boss_holds = list() /// Bosses that already shrugged it off. The field leaves them be. var/list/released = list() + /// world.time the effect ends, when time runs normally again. + var/ends_at = INFINITY + /// Mechs held, to whether the field shut them down for the freeze. + var/list/held_mechs = list() + /// Turrets held. Their fire cooldown runs slow. + var/list/held_turrets = list() + /// Armed grenades held. Their fuse runs slow. + var/list/held_grenades = list() /datum/proximity_monitor/advanced/timestop/sandevistan/New(atom/_host, range, _ignore_if_not_on_turf = TRUE, list/immune, antimagic_flags, channelled, start_scale = 0) time_scale = start_scale last_tick = world.time + GLOB.cyberware_time_fields += src . = ..() STOP_PROCESSING(SSfastprocess, src) // tick() runs on tick_timer instead tick_timer = addtimer(CALLBACK(src, PROC_REF(tick)), 0.2 SECONDS, TIMER_LOOP | TIMER_STOPPABLE | TIMER_DELETE_ME) @@ -219,9 +328,32 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) if(host) UnregisterSignal(host, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE) stopper = null + GLOB.cyberware_time_fields -= src . = ..() // unfreeze_all() lets everything go released.Cut() +/// How long, in real time from now, `local` deciseconds of `thing`'s own time take: the rest of the freeze, +/// then the slow-mo until the effect (or a boss's hold) ends, then real time. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/real_time_for(atom/thing, local) + var/now = world.time + var/ends = ends_at + if(boss_holds[thing]) + ends = min(ends, boss_holds[thing]) + if(ends <= now) + return local + var/at = now + var/scale = time_scale + if(scale <= 0) + at = thaw_at ? min(thaw_at, ends) : ends + scale = slow_scale + if(at < ends && scale > 0) + var/span = (ends - at) * scale + if(local <= span) + return at - now + local / scale + local -= span + at = ends + return at - now + local + /datum/proximity_monitor/advanced/timestop/sandevistan/freeze_atom(atom/movable/A) if(!istype(A) || GLOB.cyberware_time_dilated[A] || global_frozen_atoms[A] || released[A] || immune[A]) return FALSE @@ -242,8 +374,18 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) tint = FALSE escape_the_negative_zone(shot) // in case it was tinted before it had a firer hold_shot(shot) - else if((ismachinery(A) && !istype(A, /obj/machinery/light)) || isstructure(A) || ismecha(A)) + else if(ismecha(A)) + hold_mech(A) + else if(istype(A, /obj/machinery/porta_turret)) + frozen_structures += A + held_turrets[A] = TRUE + if(time_scale <= 0) + hold_turret_fire(A) + else if((ismachinery(A) && !istype(A, /obj/machinery/light)) || isstructure(A)) frozen_structures += A // looks only + else if(cyberware_lit_grenade(A)) + held_grenades[A] = TRUE + tint = !!A.throwing // floor items keep their colour else if(!A.throwing) return FALSE if(A.throwing) @@ -275,6 +417,11 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) if(throw_datum) throw_datum.paused = FALSE held_throws -= A + if(!isnull(held_mechs[A])) + start_mech(A) + held_mechs -= A + held_turrets -= A + held_grenades -= A frozen_structures -= A user_shots -= A UnregisterSignal(A, COMSIG_QDELETING) @@ -357,6 +504,54 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) if(animal_victim.ranged_cooldown > now) animal_victim.ranged_cooldown += push + for(var/obj/item/gun/gun in victim.held_items) + gun.cyberware_burst_start += push + if(ranged) + ranged.cyberware_burst_start += push + +// ----- mechs, turrets, grenades ----- + +/// Takes hold of a mech and whoever is driving it. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/hold_mech(obj/vehicle/sealed/mecha/mech) + frozen_structures += mech + held_mechs[mech] = FALSE + if(time_scale <= 0) + stop_mech(mech) + for(var/mob/living/pilot in mech.occupants) + freeze_atom(pilot) + +/// The freeze shuts a mech down, as tg's own time stop does. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/stop_mech(obj/vehicle/sealed/mecha/mech) + if(held_mechs[mech] || mech.completely_disabled) + return + mech.completely_disabled = TRUE + held_mechs[mech] = TRUE + +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/start_mech(obj/vehicle/sealed/mecha/mech) + if(!held_mechs[mech]) + return + mech.completely_disabled = FALSE + held_mechs[mech] = FALSE + +/// A frozen turret's cooldown stays a little ahead of the clock, so it can't fire until time moves. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/hold_turret_fire(obj/machinery/porta_turret/turret) + turret.last_fired = max(turret.last_fired, world.time + 0.3 SECONDS - turret.shot_delay) + +/// Pushes back the clocks of held machines and grenades by `push` deciseconds. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/push_machine_deadlines(push) + var/now = world.time + for(var/obj/vehicle/sealed/mecha/mech as anything in held_mechs) + if(mech.cooldown_vehicle_move > now) + mech.cooldown_vehicle_move += push + for(var/obj/machinery/porta_turret/turret as anything in held_turrets) + if(turret.last_fired + turret.shot_delay > now) + turret.last_fired += push + if(time_scale <= 0) + hold_turret_fire(turret) + for(var/obj/item/grenade/bomb as anything in held_grenades) + if(bomb.cyberware_fuse_at > now) + bomb.cyberware_fuse_at += push + // ----- projectiles ----- /datum/proximity_monitor/advanced/timestop/sandevistan/proc/hold_shot(obj/projectile/shot) @@ -416,6 +611,9 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) held_mobs -= thing frozen_mobs -= thing held_throws -= thing + held_mechs -= thing + held_turrets -= thing + held_grenades -= thing boss_holds -= thing frozen_structures -= thing frozen_things -= thing @@ -440,6 +638,11 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) for(var/atom/movable/thing as anything in held_throws) if(thing.throwing) thing.throwing.paused = time_scale <= 0 + for(var/obj/vehicle/sealed/mecha/mech as anything in held_mechs) + if(time_scale <= 0) + stop_mech(mech) + else + start_mech(mech) /datum/proximity_monitor/advanced/timestop/sandevistan/process() return PROCESS_KILL @@ -460,6 +663,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) var/datum/thrownthing/throw_datum = thing.throwing if(throw_datum && !throw_datum.paused) throw_datum.start_time += push + push_machine_deadlines(push) for(var/mob/living/boss as anything in boss_holds.Copy()) if(world.time < boss_holds[boss]) continue @@ -539,6 +743,58 @@ GLOBAL_LIST_EMPTY(cyberware_time_dilated) /obj/effect/temp_visual/decoy/fading/cascade/stopped duration = 4 +// ========================================================================= +// CLOCKS THAT RUN SLOW +// ========================================================================= + +/obj/item/gun + /// world.time the current burst began, pushed back while the shooter is slowed (cyberware time dilation). + var/cyberware_burst_start = 0 + /// The gap between the current burst's shots, in the shooter's own time. + var/cyberware_burst_gap = 0 + +/datum/component/ranged_attacks + /// world.time the current burst began, pushed back while the mob is slowed (cyberware time dilation). + var/cyberware_burst_start = 0 + +/// One shot of a burst (`index` 1 is the second shot), fired when the mob's own clock gets there. +/datum/component/ranged_attacks/proc/cyberware_burst_shot(mob/living/basic/firer, atom/target, modifiers, index) + if(QDELETED(firer)) + return + var/wait = cyberware_burst_wait(firer, cyberware_burst_start, burst_intervals, index) + if(wait > 0) + addtimer(CALLBACK(src, PROC_REF(cyberware_burst_shot), firer, target, modifiers, index), wait, TIMER_DELETE_ME) + return + async_fire_ranged_attack(firer, target, modifiers) + +/obj/item/grenade + /// world.time the fuse runs out, pushed back while the grenade sits in a dilation field. 0 when not lit. + var/cyberware_fuse_at = 0 + +/// Whether `thing` is a grenade with its fuse burning. +/proc/cyberware_lit_grenade(atom/thing) + if(!istype(thing, /obj/item/grenade)) + return FALSE + var/obj/item/grenade/bomb = thing + return bomb.active && bomb.cyberware_fuse_at + +/obj/item/grenade/arm_grenade(mob/user, delayoverride, msg = TRUE, volume = 60) + cyberware_fuse_at = world.time + (isnull(delayoverride) ? det_time : delayoverride) + . = ..() + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = cyberware_time_field(src) + field?.freeze_atom(src) + +/// The fuse timer ran out: go off, unless the grenade's own clock is still behind. +/obj/item/grenade/proc/cyberware_fuse_done() + if(!cyberware_fuse_at) + return + var/wait = cyberware_fuse_at + cyberware_time_lag(src) - world.time + if(wait >= world.tick_lag || (GLOB.cyberware_time_dilated[src] && !cyberware_time_scale(src))) + addtimer(CALLBACK(src, PROC_REF(cyberware_fuse_done)), max(wait, world.tick_lag), TIMER_DELETE_ME) + return + cyberware_fuse_at = 0 + detonate() + #undef SANDY_STOP_WITNESS_RANGE #undef SANDY_STOP_FIELD_RANGE #undef SANDY_STOP_BOSS_HOLD diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm index b4d09339dae..598188abd1c 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -105,3 +105,77 @@ TEST_ASSERT_NULL(window.time_stop, "A surge with no player behind it slowed the world") user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + +/// Everything in the field runs on its own slowed clock: gore timing, do_after, bursts, mechs, turrets, fuses +/datum/unit_test/voidcrew_cyberware_timestop/slow_everything + +/datum/unit_test/voidcrew_cyberware_timestop/slow_everything/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + var/mob/living/carbon/human/npc/raider = allocate(/mob/living/carbon/human/npc, spot(2, 0)) + var/obj/item/gun/ballistic/automatic/pistol/raider_gun = allocate(/obj/item/gun/ballistic/automatic/pistol) + raider.put_in_hands(raider_gun) + var/obj/item/gun/ballistic/automatic/pistol/user_gun = allocate(/obj/item/gun/ballistic/automatic/pistol) + user.put_in_hands(user_gun) + var/mob/living/basic/mouse/rat = allocate(/mob/living/basic/mouse, spot(1, 2)) + rat.AddComponent(/datum/component/ranged_attacks, projectile_type = /obj/projectile/beam, burst_shots = 3) + var/obj/vehicle/sealed/mecha/ripley/mech = allocate(/obj/vehicle/sealed/mecha/ripley, spot(3, 2)) + var/obj/machinery/porta_turret/turret = allocate(/obj/machinery/porta_turret, spot(3, 1)) + var/obj/effect/decal/cleanable/blood/pool = allocate(/obj/effect/decal/cleanable/blood, spot(2, 1)) + var/obj/item/grenade/bomb = allocate(/obj/item/grenade, spot(1, 1)) + bomb.arm_grenade(null, 3 SECONDS, msg = FALSE) + + var/datum/status_effect/cyberware_cascade_window/window = surge(user) + TEST_ASSERT_NOTNULL(window, "The lattice's window didn't open") + sleep(0.1 SECONDS) + TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield + + // The freeze: the NPC's own clock stands still, the user's doesn't. + TEST_ASSERT_EQUAL(cyberware_time_lost(raider, 10), 10, "A frozen NPC's clock kept running") + TEST_ASSERT_EQUAL(cyberware_time_lost(user, 10), 0, "The user's clock slowed") + TEST_ASSERT_EQUAL(cyberware_dilated_time(user, 10), 10, "The user's animations would be slowed") + TEST_ASSERT_NULL(cyberware_time_field(user_gun), "Something the user holds would be slowed") + TEST_ASSERT(cyberware_dilated_time(raider, 1) > 5, "A frozen NPC's gore wouldn't wait out the freeze") + TEST_ASSERT_NOTNULL(cyberware_time_field(pool), "Blood on the floor in the field isn't slowed") + var/list/steps = cyberware_dilated_steps(raider, list(2, 2)) + TEST_ASSERT(length(steps) == 3 && steps[1] > 5 && steps[2] > 2, "A frozen NPC's flinch wouldn't hold, then play slow") + + // Bursts queued before the freeze wait for it. + raider_gun.cyberware_burst_start = cyberware_burst_clock(raider) + raider_gun.cyberware_burst_gap = 1 + var/datum/component/ranged_attacks/rat_gun = rat.GetComponent(/datum/component/ranged_attacks) + rat_gun.cyberware_burst_start = cyberware_burst_clock(rat) + var/gun_start = raider_gun.cyberware_burst_start + var/rat_start = rat_gun.cyberware_burst_start + + // Machines and fuses. + TEST_ASSERT(mech.completely_disabled, "A mech kept running in frozen time") + TEST_ASSERT(turret.last_fired + turret.shot_delay > world.time, "A turret could fire in frozen time") + TEST_ASSERT(field.held_grenades[bomb], "A lit grenade in the field wasn't taken") + + sleep(0.5 SECONDS) + TEST_ASSERT(cyberware_burst_wait(raider, raider_gun.cyberware_burst_start, 1, 1) > 0, "A queued burst shot fired in frozen time") + TEST_ASSERT(raider_gun.cyberware_burst_start > gun_start, "The NPC's burst clock wasn't pushed back") + TEST_ASSERT(rat_gun.cyberware_burst_start > rat_start, "A basic mob's burst clock wasn't pushed back") + TEST_ASSERT(turret.last_fired + turret.shot_delay > world.time, "A turret could fire in frozen time") + + sleep(0.6 SECONDS) // into the 20% slow-mo + TEST_ASSERT(!mech.completely_disabled, "A mech stayed shut down into the slow-mo") + var/lost = cyberware_time_lost(raider, 10) + TEST_ASSERT(lost > 7.9 && lost < 8.1, "A slowed NPC's clock didn't run at 20% (lost [lost] of 10)") + mech.cooldown_vehicle_move = world.time + 5 + var/move_at = mech.cooldown_vehicle_move + sleep(0.5 SECONDS) + TEST_ASSERT(mech.cooldown_vehicle_move > move_at, "A slowed mech's move cooldown wasn't pushed back") + TEST_ASSERT(!(bomb.dud_flags & GRENADE_USED), "A grenade's fuse burned through at full speed") + + sleep(2.6 SECONDS) // past the end + TEST_ASSERT_NULL(window.time_stop, "The dilation outlived its 4 s") + TEST_ASSERT_EQUAL(cyberware_time_lost(raider, 10), 0, "The NPC's clock stayed slow after the dilation") + TEST_ASSERT_NULL(cyberware_time_field(pool), "Blood stayed slowed after the dilation") + TEST_ASSERT(!mech.completely_disabled, "The mech stayed shut down after the dilation") + TEST_ASSERT(!(bomb.dud_flags & GRENADE_USED), "A grenade's fuse didn't lose its frozen and slowed time") + TEST_ASSERT(bomb.cyberware_fuse_at > world.time, "A grenade's fuse has nothing left after losing time") + bomb.cyberware_fuse_at = 0 // don't let it go off after the test + user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) + user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) From c865602dbe2ed5529514b1ce0810abc88d974ed6 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:59:56 -0800 Subject: [PATCH 07/33] Slow gore animation in the dilation field --- voidcrew/modules/hyperviolence/_core.dm | 19 +++++-- .../hyperviolence/visuals/_motion_core.dm | 57 +++++++++++++++---- .../hyperviolence/visuals/blood_motion.dm | 11 +++- .../hyperviolence/visuals/death_falls.dm | 4 +- .../hyperviolence/visuals/execution_motion.dm | 8 ++- .../hyperviolence/visuals/flying_gore.dm | 20 ++++--- 6 files changed, 90 insertions(+), 29 deletions(-) diff --git a/voidcrew/modules/hyperviolence/_core.dm b/voidcrew/modules/hyperviolence/_core.dm index 2aafc284d62..f28b6a2c84a 100644 --- a/voidcrew/modules/hyperviolence/_core.dm +++ b/voidcrew/modules/hyperviolence/_core.dm @@ -37,17 +37,26 @@ var/matrix/fallen = matrix(transform) var/matrix/upright = matrix(transform) upright.Turn(-angle) + // In a Sandevistan field (cyberware/ware_sandy_timestop.dm) it holds through the freeze and runs slow. + var/list/real = cyberware_dilated_steps(src, list(HV_DEATH_THROES_TIME, UPDATE_TRANSFORM_ANIMATION_TIME)) || list(0, HV_DEATH_THROES_TIME, UPDATE_TRANSFORM_ANIMATION_TIME) animate(src, transform = upright, time = 0) - animate(transform = upright, time = HV_DEATH_THROES_TIME) - animate(transform = fallen, time = UPDATE_TRANSFORM_ANIMATION_TIME, easing = EASE_IN|EASE_OUT) + animate(transform = upright, time = real[1] + real[2]) + animate(transform = fallen, time = real[3], easing = EASE_IN|EASE_OUT) // The shudder: sideways offsets that die down, each step relative to the last, ending where it began. var/static/list/shudder = list(3, -3, 2, -2, 2, -1, 1, 0) var/step_time = HV_DEATH_THROES_TIME / length(shudder) + var/list/shudder_times = list() + for(var/i in 1 to length(shudder)) + shudder_times += step_time + var/list/shudder_real = cyberware_dilated_steps(src, shudder_times) + if(shudder_real && shudder_real[1] > 0) + animate(src, pixel_w = 0, time = shudder_real[1], flags = ANIMATION_RELATIVE|ANIMATION_PARALLEL) var/offset = 0 for(var/i in 1 to length(shudder)) var/shift = shudder[i] - offset offset = shudder[i] - if(i == 1) - animate(src, pixel_w = shift, time = step_time, flags = ANIMATION_RELATIVE|ANIMATION_PARALLEL) + var/time = shudder_real ? shudder_real[i + 1] : step_time + if(i == 1 && !(shudder_real && shudder_real[1] > 0)) + animate(src, pixel_w = shift, time = time, flags = ANIMATION_RELATIVE|ANIMATION_PARALLEL) else - animate(pixel_w = shift, time = step_time, flags = ANIMATION_RELATIVE) + animate(pixel_w = shift, time = time, flags = ANIMATION_RELATIVE) diff --git a/voidcrew/modules/hyperviolence/visuals/_motion_core.dm b/voidcrew/modules/hyperviolence/visuals/_motion_core.dm index 9b45e989263..06986535e34 100644 --- a/voidcrew/modules/hyperviolence/visuals/_motion_core.dm +++ b/voidcrew/modules/hyperviolence/visuals/_motion_core.dm @@ -202,12 +202,30 @@ /atom/proc/hv_vis_play(tag, list/steps, loop = 1) if(!length(steps)) return + // In a Sandevistan field (cyberware/ware_sandy_timestop.dm) it plays at the field's pace, and holds still + // through the freeze once any jump to its first pose is done. + var/list/times = list() + for(var/list/step as anything in steps) + times += step[4] + var/list/real = cyberware_dilated_steps(src, times, loop) + var/hold = real ? real[1] : 0 + var/started = FALSE for(var/i in 1 to length(steps)) var/list/step = steps[i] - if(i == 1) - animate(src, pixel_w = step[1], pixel_z = step[2], transform = step[3], time = step[4], easing = step[5], loop = loop, tag = tag) + if(hold > 0 && step[4] > 0) + var/list/still = i > 1 ? steps[i - 1] : list(pixel_w, pixel_z, transform) + if(!started) + animate(src, pixel_w = still[1], pixel_z = still[2], transform = still[3], time = hold, loop = loop, tag = tag) + started = TRUE + else + animate(pixel_w = still[1], pixel_z = still[2], transform = still[3], time = hold) + hold = 0 + var/time = real ? real[i + 1] : step[4] + if(!started) + animate(src, pixel_w = step[1], pixel_z = step[2], transform = step[3], time = time, easing = step[5], loop = loop, tag = tag) + started = TRUE else - animate(pixel_w = step[1], pixel_z = step[2], transform = step[3], time = step[4], easing = step[5]) + animate(pixel_w = step[1], pixel_z = step[2], transform = step[3], time = time, easing = step[5]) /** * Plays steps that take over this atom's whole animation: each step is list(pixel_y, transform, time, @@ -218,15 +236,33 @@ /atom/proc/hv_vis_play_over(list/steps) if(!length(steps)) return + // Slowed and held like hv_vis_play() in a Sandevistan field. + var/list/times = list() + for(var/list/step as anything in steps) + times += step[3] + var/list/real = cyberware_dilated_steps(src, times) + var/hold = real ? real[1] : 0 + var/started = FALSE for(var/i in 1 to length(steps)) var/list/step = steps[i] - if(i == 1) - animate(src, pixel_y = step[1], transform = step[2], time = step[3], easing = step[4]) + if(hold > 0 && step[3] > 0) + var/list/still = i > 1 ? steps[i - 1] : list(pixel_y, transform) + if(!started) + animate(src, pixel_y = still[1], transform = still[2], time = hold) + started = TRUE + else + animate(pixel_y = still[1], transform = still[2], time = hold) + hold = 0 + var/time = real ? real[i + 1] : step[3] + if(!started) + animate(src, pixel_y = step[1], transform = step[2], time = time, easing = step[4]) + started = TRUE else - animate(pixel_y = step[1], transform = step[2], time = step[3], easing = step[4]) + animate(pixel_y = step[1], transform = step[2], time = time, easing = step[4]) /// Eases a named sequence back to where the atom really is over `time`, from wherever it has got to. /atom/proc/hv_vis_release(tag, time = 2) + time = cyberware_dilated_time(src, time) animate(src, pixel_w = pixel_w, pixel_z = pixel_z, transform = transform, time = time, easing = SINE_EASING, tag = tag) /// Stops named sequences at once. @@ -288,10 +324,11 @@ var/flash = get_filter(HV_VIS_FLASH_FILTER) if(!flash) return - animate(flash, color = list(0.6,0,0, 0,0.6,0, 0,0,0.6, 0.4,0.4,0.4), time = 0.5, easing = LINEAR_EASING) - animate(color = list(0.6,0,0, 0,0.6,0, 0,0,0.6, 0.4,0.4,0.4), time = HV_VIS_HITSTOP_TIME, easing = LINEAR_EASING) - animate(color = list(1,0,0, 0,1,0, 0,0,1, 0,0,0), time = 1, easing = LINEAR_EASING) - addtimer(CALLBACK(src, TYPE_PROC_REF(/datum, remove_filter), HV_VIS_FLASH_FILTER), 0.5 SECONDS, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_DELETE_ME) + var/list/real = cyberware_dilated_steps(src, list(0.5, HV_VIS_HITSTOP_TIME, 1)) || list(0, 0.5, HV_VIS_HITSTOP_TIME, 1) + animate(flash, color = list(0.6,0,0, 0,0.6,0, 0,0,0.6, 0.4,0.4,0.4), time = real[1] + real[2], easing = LINEAR_EASING) + animate(color = list(0.6,0,0, 0,0.6,0, 0,0,0.6, 0.4,0.4,0.4), time = real[3], easing = LINEAR_EASING) + animate(color = list(1,0,0, 0,1,0, 0,0,1, 0,0,0), time = real[4], easing = LINEAR_EASING) + addtimer(CALLBACK(src, TYPE_PROC_REF(/datum, remove_filter), HV_VIS_FLASH_FILTER), cyberware_dilated_time(src, 0.5 SECONDS), TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_DELETE_ME) /// The attacker's side of a hit-stop: the blow sticks for a few frames. A melee attacker stays leaned /// into the victim; a shooter stays rocked back, even with the barrel against them (`shot`). diff --git a/voidcrew/modules/hyperviolence/visuals/blood_motion.dm b/voidcrew/modules/hyperviolence/visuals/blood_motion.dm index 7133927011e..22aa43f7bdd 100644 --- a/voidcrew/modules/hyperviolence/visuals/blood_motion.dm +++ b/voidcrew/modules/hyperviolence/visuals/blood_motion.dm @@ -28,8 +28,14 @@ GLOBAL_VAR(hv_vis_spray_origin) /obj/effect/decal/cleanable/blood/proc/hv_vis_paint(list/keys) var/reached = world.time var/list/timed = list() + // In a Sandevistan field (cyberware/ware_sandy_timestop.dm) the paint waits out the freeze and runs slow. + var/list/times = list() for(var/list/key as anything in keys) - reached += key[3] + times += key[3] + var/list/real = cyberware_dilated_steps(src, times) + for(var/i in 1 to length(keys)) + var/list/key = keys[i] + reached += real ? real[i + 1] + (i == 1 ? real[1] : 0) : key[3] timed += list(list(key[1], key[2], reached, key[4])) hv_vis_painting = timed hv_vis_paint_on() @@ -58,6 +64,9 @@ GLOBAL_VAR(hv_vis_spray_origin) var/matrix/from = behind[1] var/matrix/current = from.Interpolate(ahead[1], progress) animate(src, transform = current, alpha = behind[2] + (ahead[2] - behind[2]) * progress, time = 0, tag = HV_VIS_TAG_PAINT) + if(now < behind[3]) // held by a frozen moment before it starts + animate(transform = current, alpha = behind[2], time = behind[3] - now) + now = behind[3] animate(transform = ahead[1], alpha = ahead[2], time = ahead[3] - now, easing = ahead[4]) for(var/i in heading + 1 to length(keys)) var/list/key = keys[i] diff --git a/voidcrew/modules/hyperviolence/visuals/death_falls.dm b/voidcrew/modules/hyperviolence/visuals/death_falls.dm index 667d976d1fd..c531b78d7ed 100644 --- a/voidcrew/modules/hyperviolence/visuals/death_falls.dm +++ b/voidcrew/modules/hyperviolence/visuals/death_falls.dm @@ -125,7 +125,7 @@ hv_vis_play_over(hv_vis_hold_then_fall_steps(HV_VIS_HITSTOP_TIME, 2)) if(hv_vis_hit_damage >= HV_VIS_FULL_SLIDE_DAMAGE && hv_vis_can_slide_tile(hit_dir)) // Next tick: the blow that killed it may still be resolving (a shotgun's other pellets). - addtimer(CALLBACK(src, PROC_REF(hv_vis_slide_tile), hit_dir), 1, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(hv_vis_slide_tile), hit_dir), cyberware_dilated_time(src, 1), TIMER_DELETE_ME) // waits out a Sandevistan freeze return hv_vis_slide_in_tile(hit_dir, hv_vis_hit_damage, big ? HV_VIS_HITSTOP_TIME : 0) hv_vis_twitches(rand(3, 5), big ? 6 : 4) @@ -176,7 +176,7 @@ /mob/living/carbon/proc/hv_vis_slide_tile(slide_dir) if(QDELETED(src) || stat != DEAD) return - if(hv_vis_can_slide_tile(slide_dir) && Move(get_step(loc, slide_dir), slide_dir, DELAY_TO_GLIDE_SIZE(HV_VIS_SLIDE_TIME))) + if(hv_vis_can_slide_tile(slide_dir) && Move(get_step(loc, slide_dir), slide_dir, DELAY_TO_GLIDE_SIZE(cyberware_dilated_time(src, HV_VIS_SLIDE_TIME)))) // Thrown back as it falls: a little lift over the slide. var/matrix/base = matrix(transform) hv_vis_play(HV_VIS_TAG_FALL, list( diff --git a/voidcrew/modules/hyperviolence/visuals/execution_motion.dm b/voidcrew/modules/hyperviolence/visuals/execution_motion.dm index 35aa47d1605..fe3f5474b4a 100644 --- a/voidcrew/modules/hyperviolence/visuals/execution_motion.dm +++ b/voidcrew/modules/hyperviolence/visuals/execution_motion.dm @@ -68,9 +68,11 @@ var/loops = max(1, round(duration / 2)) var/base_w = pixel_w var/base_z = pixel_z - animate(src, pixel_w = base_w + strength, pixel_z = base_z, transform = base, time = 0.5, loop = loops, tag = HV_VIS_TAG_TREMBLE) - animate(pixel_w = base_w - strength, pixel_z = base_z, transform = base, time = 1) - animate(pixel_w = base_w, pixel_z = base_z, transform = base, time = 0.5) + // Slower in a Sandevistan field (cyberware/ware_sandy_timestop.dm). + var/list/real = cyberware_dilated_steps(src, list(0.5, 1, 0.5), loops) || list(0, 0.5, 1, 0.5) + animate(src, pixel_w = base_w + strength, pixel_z = base_z, transform = base, time = real[1] + real[2], loop = loops, tag = HV_VIS_TAG_TREMBLE) + animate(pixel_w = base_w - strength, pixel_z = base_z, transform = base, time = real[3]) + animate(pixel_w = base_w, pixel_z = base_z, transform = base, time = real[4]) /** * A muzzle flash much bigger than a normal shot's, with a flash of light. It is where the barrel's end diff --git a/voidcrew/modules/hyperviolence/visuals/flying_gore.dm b/voidcrew/modules/hyperviolence/visuals/flying_gore.dm index 60c87030104..b103ab527c4 100644 --- a/voidcrew/modules/hyperviolence/visuals/flying_gore.dm +++ b/voidcrew/modules/hyperviolence/visuals/flying_gore.dm @@ -184,7 +184,7 @@ GLOBAL_LIST(hv_vis_arcing_gibs) else if(!grounded) step_time *= 3 rest_layer = part.layer - addtimer(CALLBACK(src, PROC_REF(give_up)), 5 SECONDS, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(give_up)), cyberware_dilated_time(part, 5 SECONDS), TIMER_DELETE_ME) /datum/component/hv_tumble/RegisterWithParent() RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) @@ -230,7 +230,8 @@ GLOBAL_LIST(hv_vis_arcing_gibs) /// where it is once it has dropped. /datum/component/hv_tumble/proc/launch() start_flight() - step_timer = addtimer(CALLBACK(src, PROC_REF(take_step)), length(path) ? world.tick_lag : flight_time(), TIMER_STOPPABLE|TIMER_DELETE_ME) + // In a Sandevistan field (cyberware/ware_sandy_timestop.dm) it waits out the freeze and flies slow. + step_timer = addtimer(CALLBACK(src, PROC_REF(take_step)), cyberware_dilated_time(parent, length(path) ? world.tick_lag : flight_time()), TIMER_STOPPABLE|TIMER_DELETE_ME) /datum/component/hv_tumble/proc/start_flight() var/obj/item/part = parent @@ -305,12 +306,13 @@ GLOBAL_LIST(hv_vis_arcing_gibs) steps_taken++ path.Cut(1, 2) stepping = TRUE - part.set_glide_size(DELAY_TO_GLIDE_SIZE(step_time)) + var/real_step = cyberware_dilated_time(part, step_time) + part.set_glide_size(DELAY_TO_GLIDE_SIZE(real_step)) part.forceMove(next) stepping = FALSE if(QDELETED(src)) return - step_timer = addtimer(CALLBACK(src, PROC_REF(take_step)), step_time, TIMER_STOPPABLE|TIMER_DELETE_ME) + step_timer = addtimer(CALLBACK(src, PROC_REF(take_step)), real_step, TIMER_STOPPABLE|TIMER_DELETE_ME) /// Blood on each tile it crosses. Anything else moving it (a hand, a conveyor) ends the flight. /datum/component/hv_tumble/proc/on_moved(obj/item/source, atom/old_loc, movement_dir, forced, list/old_locs) @@ -430,10 +432,12 @@ GLOBAL_LIST(hv_vis_arcing_gibs) var/list/pivot = part.hv_vis_spin_pivot() var/start_x = part.pixel_x var/start_y = part.pixel_y - animate(part, pixel_x = start_x, pixel_y = start_y, transform = base, time = 2.5, tag = HV_VIS_TAG_SPIN) - animate(pixel_x = start_x + (end_x - start_x) / 3, pixel_y = start_y + (end_y - start_y) / 3, transform = hv_vis_turned(base, spin * 120, pivot[2], pivot[1]), time = 1.5, easing = LINEAR_EASING) - animate(pixel_x = start_x + (end_x - start_x) * 2 / 3, pixel_y = start_y + (end_y - start_y) * 2 / 3, transform = hv_vis_turned(base, spin * 240, pivot[2], pivot[1]), time = 2, easing = LINEAR_EASING) - animate(pixel_x = end_x, pixel_y = end_y, transform = base, time = 3, easing = QUAD_EASING|EASE_OUT) + // Slower in a Sandevistan field (cyberware/ware_sandy_timestop.dm); the opening pause takes the freeze. + var/list/real = cyberware_dilated_steps(part, list(2.5, 1.5, 2, 3)) || list(0, 2.5, 1.5, 2, 3) + animate(part, pixel_x = start_x, pixel_y = start_y, transform = base, time = real[1] + real[2], tag = HV_VIS_TAG_SPIN) + animate(pixel_x = start_x + (end_x - start_x) / 3, pixel_y = start_y + (end_y - start_y) / 3, transform = hv_vis_turned(base, spin * 120, pivot[2], pivot[1]), time = real[3], easing = LINEAR_EASING) + animate(pixel_x = start_x + (end_x - start_x) * 2 / 3, pixel_y = start_y + (end_y - start_y) * 2 / 3, transform = hv_vis_turned(base, spin * 240, pivot[2], pivot[1]), time = real[4], easing = LINEAR_EASING) + animate(pixel_x = end_x, pixel_y = end_y, transform = base, time = real[5], easing = QUAD_EASING|EASE_OUT) // ---- Limbs and heads coming off ---- From e348ff445d3af57c08abd75faa064d4b5302b9b5 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:17:20 -0800 Subject: [PATCH 08/33] Turn off the reflex dodge while the Sandevistan user has the world slowed --- voidcrew/modules/cyberware/ware_pro_combat.dm | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/voidcrew/modules/cyberware/ware_pro_combat.dm b/voidcrew/modules/cyberware/ware_pro_combat.dm index 30371c5e83e..327ac8ad9f3 100644 --- a/voidcrew/modules/cyberware/ware_pro_combat.dm +++ b/voidcrew/modules/cyberware/ware_pro_combat.dm @@ -85,6 +85,8 @@ SIGNAL_HANDLER if(world.time - last_move_time > CYBERWARE_DODGE_MOVE_WINDOW) return NONE + if(cyberware_time_stopping(source)) // the slowed world is the dodge (ware_sandy_timestop.dm) + return NONE if(source.stat != CONSCIOUS || source.body_position == LYING_DOWN || source.buckled) return NONE if(!istype(hitting_projectile) || !hitting_projectile.is_hostile_projectile()) @@ -112,7 +114,7 @@ /// Signal proc for [COMSIG_LIVING_CHECK_BLOCK]: the melee dodge roll, from tg's own check_block(). /datum/component/cyberware_dodge/proc/on_check_block(mob/living/source, atom/hit_by, damage, attack_text, attack_type, armour_penetration, damage_type) SIGNAL_HANDLER - if(!length(melee_sources) || !cyberware_can_dodge_blow(source, hit_by, damage, attack_text, attack_type)) + if(!length(melee_sources) || cyberware_time_stopping(source) || !cyberware_can_dodge_blow(source, hit_by, damage, attack_text, attack_type)) return FAILED_BLOCK var/best_chance = best_chance(melee_sources) if(best_chance <= 0 || !prob(best_chance)) From 1a9b11b95ccaab70318db00850371c94625d0ce4 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:17:21 -0800 Subject: [PATCH 09/33] Keep the green afterimage trail for the rest of a surge that slowed the world --- voidcrew/modules/cyberware/ware_legend.dm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/voidcrew/modules/cyberware/ware_legend.dm b/voidcrew/modules/cyberware/ware_legend.dm index ae0a29e8df8..28ec059cfe7 100644 --- a/voidcrew/modules/cyberware/ware_legend.dm +++ b/voidcrew/modules/cyberware/ware_legend.dm @@ -252,7 +252,7 @@ steps_taken++ if(steps_taken % afterimage_interval) // cheaper chrome leaves a sparser trail return - if(time_stop) + if(time_stop || dilated) // a surge that slowed the world keeps its green trail to the end of the window stopped_afterimage(old_loc) return new /obj/effect/temp_visual/decoy/fading/cascade(old_loc, owner, trail_hue, afterimage_alpha) From 2af43b430920083d2f26d91a4a77880e07902c45 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:17:21 -0800 Subject: [PATCH 10/33] Let mobs on their own Cascade surge run at full speed in a dilation field --- .../modules/cyberware/ware_sandy_timestop.dm | 101 ++++++++++++++++- .../hyperviolence/visuals/_motion_core.dm | 2 +- .../unit_tests/voidcrew_cyberware_timestop.dm | 102 ++++++++++++++++++ 3 files changed, 200 insertions(+), 5 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index 2d13d482e49..6d7c10d1040 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -36,6 +36,13 @@ * * Bosses (a finisher user, or a boss-rank human NPC) are held for SANDY_STOP_BOSS_HOLD at most. * + * A mob running its own Cascade surge (a bounty NPC's lattice) is never slowed, frozen or tinted while its window + * is open, and neither are its shots. If it surges while already held, the field lets it go at once and drops + * every slow it was carrying (surge_free()). When its window closes the field may take it again. + * + * The user's reflex dodge (ware_pro_combat.dm) is off while the world is slowed: the speed gap already does that + * job. It comes back for the rest of the window. + * * Look: affected things get a grey-green tint instead of tg's inversion. Floor items, blood decals and the * user are never touched, so they keep their colour, and so do the user's own shots. Shots the user fires * while the world is frozen fly a tile, hang there, and go on when it starts moving again. @@ -84,13 +91,33 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) return null var/atom/on_turf = ismovable(thing) ? get_atom_on_turf(thing) : thing for(field as anything in GLOB.cyberware_time_fields) - if(field.immune[thing] || field.immune[on_turf] || field.released[on_turf]) + if(field.immune[thing] || field.immune[on_turf] || field.released[on_turf] || field.surging[on_turf]) continue var/turf/centre = get_turf(field.host) if(centre && centre.z == here.z && get_dist(centre, here) <= field.current_range) return field return null +/// Whether `bearer` is slowing the world around them right now (the freeze or the slow-mo). +/proc/cyberware_time_stopping(mob/living/bearer) + for(var/datum/proximity_monitor/advanced/timestop/sandevistan/field as anything in GLOB.cyberware_time_fields) + if(field.host == bearer) + return TRUE + return FALSE + +/// Whether `thing` is a mob with its own Cascade surge window open. Such a mob runs at its own speed in any field. +/proc/cyberware_own_surge(atom/thing) + if(!isliving(thing)) + return FALSE + var/mob/living/body = thing + return !!body.has_status_effect(/datum/status_effect/cyberware_cascade_window) + +/// `surger` just opened its own surge window: every field lets it go and leaves it be until the window closes. +/proc/cyberware_surge_frees(mob/living/surger) + for(var/datum/proximity_monitor/advanced/timestop/sandevistan/field as anything in GLOB.cyberware_time_fields) + if(field.host != surger) + field.surge_free(surger) + /// How long, in real time, `time` deciseconds of `thing`'s own time take from now. Exact through the freeze, /// the slow-mo and the end of the effect, as the field stands now. /proc/cyberware_dilated_time(atom/thing, time) @@ -102,12 +129,14 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) /** * Real-time lengths for a chain of animation steps `times` on `thing`: list(hold, step 1, step 2, ...), or null * when it isn't slowed. `hold` is how long it sits still first (the freeze). A looping chain just plays at the - * field's pace. + * field's pace. `tag` names the animation, so a held mob that breaks free (surge_free()) can drop it. */ -/proc/cyberware_dilated_steps(atom/thing, list/times, loop = 1) +/proc/cyberware_dilated_steps(atom/thing, list/times, loop = 1, tag) var/datum/proximity_monitor/advanced/timestop/sandevistan/field = cyberware_time_field(thing) if(!field) return null + if(tag && field.held_mobs[thing]) + LAZYOR(field.slowed_tags[thing], tag) var/list/real = list() if(loop != 1) var/scale = field.time_scale > 0 ? field.time_scale : field.slow_scale @@ -312,6 +341,10 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/list/held_turrets = list() /// Armed grenades held. Their fuse runs slow. var/list/held_grenades = list() + /// Mobs in range running their own surge. The field leaves them and their shots be until their window closes. + var/list/surging = list() + /// Held mobs, to the tags of their slowed hv_vis_play() animations, to drop if they break free. + var/list/slowed_tags = list() /datum/proximity_monitor/advanced/timestop/sandevistan/New(atom/_host, range, _ignore_if_not_on_turf = TRUE, list/immune, antimagic_flags, channelled, start_scale = 0) time_scale = start_scale @@ -329,6 +362,9 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) UnregisterSignal(host, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE) stopper = null GLOB.cyberware_time_fields -= src + for(var/mob/living/surger as anything in surging) + UnregisterSignal(surger, COMSIG_QDELETING) + surging.Cut() . = ..() // unfreeze_all() lets everything go released.Cut() @@ -355,7 +391,10 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) return at - now + local /datum/proximity_monitor/advanced/timestop/sandevistan/freeze_atom(atom/movable/A) - if(!istype(A) || GLOB.cyberware_time_dilated[A] || global_frozen_atoms[A] || released[A] || immune[A]) + if(!istype(A) || GLOB.cyberware_time_dilated[A] || global_frozen_atoms[A] || released[A] || immune[A] || surging[A]) + return FALSE + if(cyberware_own_surge(A)) + leave_surger(A) return FALSE if(ismob(A)) var/mob/M = A @@ -367,6 +406,8 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) hold_mob(A) else if(isprojectile(A)) var/obj/projectile/shot = A + if(shot.firer && (surging[shot.firer] || cyberware_own_surge(shot.firer))) // a surging mob's shots fly at its speed + return FALSE if(shot.firer && immune[shot.firer]) if(time_scale <= 0) // hang_shot takes these return FALSE @@ -424,6 +465,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) held_grenades -= A frozen_structures -= A user_shots -= A + slowed_tags -= A UnregisterSignal(A, COMSIG_QDELETING) escape_the_negative_zone(A) frozen_things -= A @@ -509,6 +551,50 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) if(ranged) ranged.cyberware_burst_start += push +// ----- mobs on their own surge ----- + +/// Leaves `surger` be while its own window runs. It isn't held. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/leave_surger(mob/living/surger) + surging[surger] = TRUE + RegisterSignal(surger, COMSIG_QDELETING, PROC_REF(held_gone), override = TRUE) + +/** + * `surger` opened its own surge: if the field holds it, let it go now and drop every slow it carried (its walk, + * its pauses, its last bit of lost time, its slowed animations and the shots it fired), then leave it be. + */ +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/surge_free(mob/living/surger) + if(surging[surger]) + return + if(GLOB.cyberware_time_dilated[surger] == src) + var/lag = cyberware_time_lag(surger) + if(lag > 0) + push_deadlines(surger, lag) // the time it really lost since the last tick + var/list/tags = slowed_tags[surger] + unfreeze_atom(surger) + for(var/tag in tags) + surger.hv_vis_release(tag, 1) // back to its real pose at once, not at the slowed pace + for(var/obj/projectile/shot as anything in held_shots.Copy()) + if(shot.firer == surger) + unfreeze_atom(shot) + else + var/turf/centre = get_turf(host) + var/turf/here = get_turf(surger) + if(!centre || !here || here.z != centre.z || get_dist(centre, here) > current_range) + return // out of reach: if it walks in still surging, freeze_atom() leaves it be + leave_surger(surger) + +/// Every tick: surgers whose window has closed are fair game again, if they are still in range. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/check_surgers() + var/turf/centre = get_turf(host) + for(var/mob/living/surger as anything in surging.Copy()) + if(cyberware_own_surge(surger)) + continue + surging -= surger + UnregisterSignal(surger, COMSIG_QDELETING) + var/turf/here = get_turf(surger) + if(centre && here && here.z == centre.z && get_dist(centre, here) <= current_range) + freeze_atom(surger) + // ----- mechs, turrets, grenades ----- /// Takes hold of a mech and whoever is driving it. @@ -615,6 +701,8 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) held_turrets -= thing held_grenades -= thing boss_holds -= thing + surging -= thing + slowed_tags -= thing frozen_structures -= thing frozen_things -= thing GLOB.cyberware_time_dilated -= thing @@ -664,6 +752,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) if(throw_datum && !throw_datum.paused) throw_datum.start_time += push push_machine_deadlines(push) + check_surgers() for(var/mob/living/boss as anything in boss_holds.Copy()) if(world.time < boss_holds[boss]) continue @@ -689,9 +778,12 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/dilation_scale = 0.2 /// The running dilation, if any. var/obj/effect/timestop/sandevistan/time_stop + /// Whether this window slowed the world. Its trail stays green after the dilation ends. + var/dilated = FALSE /// Slows the world if the bearer is a player and nobody else is near. Called as the window opens. /datum/status_effect/cyberware_cascade_window/proc/try_time_stop() + cyberware_surge_frees(owner) // anyone's surge breaks them out of someone else's field if(dilation_freeze + dilation_time <= 0 || !owner.ckey || time_stop) return FALSE if(cyberware_time_stop_witnessed(owner)) @@ -704,6 +796,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) time_stop = null return FALSE RegisterSignal(time_stop, COMSIG_QDELETING, PROC_REF(time_stop_over)) + dilated = TRUE owner.remove_client_colour(id) // the user sees the slowed world as it is owner.add_filter(SANDY_STOP_OUTLINE, 2, outline_filter(1, "#5AFF96D2")) return TRUE diff --git a/voidcrew/modules/hyperviolence/visuals/_motion_core.dm b/voidcrew/modules/hyperviolence/visuals/_motion_core.dm index 06986535e34..1acccda933b 100644 --- a/voidcrew/modules/hyperviolence/visuals/_motion_core.dm +++ b/voidcrew/modules/hyperviolence/visuals/_motion_core.dm @@ -207,7 +207,7 @@ var/list/times = list() for(var/list/step as anything in steps) times += step[4] - var/list/real = cyberware_dilated_steps(src, times, loop) + var/list/real = cyberware_dilated_steps(src, times, loop, tag) var/hold = real ? real[1] : 0 var/started = FALSE for(var/i in 1 to length(steps)) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm index 598188abd1c..16c7b40edf5 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -179,3 +179,105 @@ bomb.cyberware_fuse_at = 0 // don't let it go off after the test user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + +/// Whether `thing` wears the dilation's tint +/datum/unit_test/voidcrew_cyberware_timestop/proc/tinted(atom/thing) + return length(thing.atom_colours) >= TEMPORARY_COLOUR_PRIORITY && !isnull(thing.atom_colours[TEMPORARY_COLOUR_PRIORITY]) + +/// A raider NPC at `where` with its own Cascade lattice +/datum/unit_test/voidcrew_cyberware_timestop/proc/make_surger(turf/where) + var/mob/living/carbon/human/npc/raider = allocate(/mob/living/carbon/human/npc, where) + var/obj/item/organ/cyberimp/cyberware/cascade/lattice = allocate(/obj/item/organ/cyberimp/cyberware/cascade) + TEST_ASSERT(lattice.Insert(raider, special = TRUE), "Test fixture: the lattice wouldn't go in the NPC") + return raider + +/// No reflex dodge while the user's own world is slowed; it comes back for the rest of the window +/datum/unit_test/voidcrew_cyberware_timestop/no_dodge_while_slowed + +/datum/unit_test/voidcrew_cyberware_timestop/no_dodge_while_slowed/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + var/obj/item/knife/kitchen/knife = allocate(/obj/item/knife/kitchen, spot(1, 0)) + var/datum/status_effect/cyberware_cascade_window/window = surge(user) + TEST_ASSERT_NOTNULL(window, "The lattice's window didn't open") + window.dodge_chance = 100 + sleep(0.1 SECONDS) + TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") + var/datum/component/cyberware_dodge/arbiter = user.GetComponent(/datum/component/cyberware_dodge) + TEST_ASSERT_NOTNULL(arbiter, "The window has no dodge") + TEST_ASSERT(cyberware_time_stopping(user), "The user isn't marked as slowing time") + TEST_ASSERT_EQUAL(arbiter.on_check_block(user, knife, 10, "the knife", MELEE_ATTACK), FAILED_BLOCK, "The user dodged a blow in frozen time") + sleep(1.2 SECONDS) + TEST_ASSERT_EQUAL(arbiter.on_check_block(user, knife, 10, "the knife", MELEE_ATTACK), FAILED_BLOCK, "The user dodged a blow in slow-mo") + sleep(3 SECONDS) // past the dilation, still in the window + TEST_ASSERT_NULL(window.time_stop, "The dilation outlived its 4 s") + TEST_ASSERT(!cyberware_time_stopping(user), "The user still counts as slowing time") + TEST_ASSERT_EQUAL(arbiter.on_check_block(user, knife, 10, "the knife", MELEE_ATTACK), SUCCESSFUL_BLOCK, "The dodge didn't come back after the dilation") + user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) + user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + +/// An NPC already surging when the user slows the world runs at its own speed, and so do its shots +/datum/unit_test/voidcrew_cyberware_timestop/surging_npc_immune + +/datum/unit_test/voidcrew_cyberware_timestop/surging_npc_immune/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + var/mob/living/carbon/human/npc/raider = make_surger(spot(2, 0)) + var/obj/projectile/bullet/shot = allocate(/obj/projectile/bullet, spot(1, 1)) + shot.firer = raider + var/shot_speed = shot.speed + TEST_ASSERT_NOTNULL(surge(raider), "The NPC's window didn't open") + var/datum/status_effect/cyberware_cascade_window/window = surge(user) + sleep(0.1 SECONDS) + TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield + TEST_ASSERT(field.surging[raider], "The field didn't notice the NPC's surge") + TEST_ASSERT(!HAS_TRAIT(raider, TRAIT_IMMOBILIZED) && !HAS_TRAIT(raider, TRAIT_AI_PAUSED), "A surging NPC was frozen") + TEST_ASSERT(!tinted(raider), "A surging NPC was tinted") + TEST_ASSERT_EQUAL(cyberware_time_lost(raider, 10), 0, "A surging NPC's clock slowed") + TEST_ASSERT_NULL(cyberware_time_field(raider), "A surging NPC's gore would be slowed") + TEST_ASSERT(!field.held_shots[shot] && !shot.paused && shot.speed == shot_speed, "A surging NPC's shot was held") + sleep(1.2 SECONDS) // into the slow-mo + TEST_ASSERT(!raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "A surging NPC was slowed") + + // Its window closes: the field takes it like anyone else. + raider.remove_status_effect(/datum/status_effect/cyberware_cascade_window) + sleep(0.3 SECONDS) + TEST_ASSERT(!field.surging[raider], "The field kept leaving the NPC be after its window closed") + TEST_ASSERT(raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "The NPC wasn't slowed once its window closed") + user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) + user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + raider.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + +/// An NPC held in slow-mo that surges breaks free at once and drops every slow it carried +/datum/unit_test/voidcrew_cyberware_timestop/late_surge_breaks_free + +/datum/unit_test/voidcrew_cyberware_timestop/late_surge_breaks_free/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + var/mob/living/carbon/human/npc/raider = make_surger(spot(2, 0)) + var/obj/projectile/bullet/shot = allocate(/obj/projectile/bullet, spot(1, 1)) + shot.firer = raider + var/shot_speed = shot.speed + var/datum/status_effect/cyberware_cascade_window/window = surge(user) + sleep(0.1 SECONDS) + TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield + sleep(1.2 SECONDS) // into the slow-mo + TEST_ASSERT(raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "Test fixture: the NPC isn't slowed") + TEST_ASSERT(field.held_shots[shot], "Test fixture: the NPC's shot isn't held") + raider.hv_vis_play("sandy_test_flinch", list(list(2, 0, matrix(), 2, LINEAR_EASING), list(0, 0, matrix(), 2, LINEAR_EASING))) + TEST_ASSERT("sandy_test_flinch" in field.slowed_tags[raider], "A held NPC's slowed animation wasn't noted") + + TEST_ASSERT_NOTNULL(surge(raider), "The NPC's window didn't open") + TEST_ASSERT(field.surging[raider], "The field didn't let the surging NPC go") + TEST_ASSERT(!raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "The surging NPC stayed slowed") + TEST_ASSERT(!HAS_TRAIT(raider, TRAIT_IMMOBILIZED) && !HAS_TRAIT(raider, TRAIT_AI_PAUSED), "The surging NPC stayed held") + TEST_ASSERT(!tinted(raider), "The surging NPC stayed tinted") + TEST_ASSERT_NULL(GLOB.cyberware_time_dilated[raider], "The surging NPC is still in the field's hands") + TEST_ASSERT_EQUAL(cyberware_time_lost(raider, 10), 0, "The surging NPC's clock stayed slow") + TEST_ASSERT_NULL(field.slowed_tags[raider], "The surging NPC's slowed animations weren't dropped") + TEST_ASSERT(!field.held_shots[shot] && shot.speed == shot_speed, "The surging NPC's shot stayed slowed") + sleep(0.5 SECONDS) // a few field ticks + TEST_ASSERT(!raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "The field took the surging NPC back") + user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) + user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + raider.remove_status_effect(/datum/status_effect/cyberware_cascade_window) + raider.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) From 53d794712ee6b9ea325e93603f7a941df08d8fcf Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:36:31 -0800 Subject: [PATCH 11/33] Run surging mobs at their tier's rate relative to the Sandevistan user Each surge model carries a time multiplier (street 2x, service about 2.86x, Cascade 5x). In a user's field a mob on its own surge runs at its multiplier over the user's, capped at twice normal speed, through the freeze and the slow-mo. A faster surger runs the dilation machinery the other way: sooner deadlines, a quicker walk, faster shots and gore. Surgers are never tinted, a dead surger is let go for good, a Cascade NPC's tell shows through the tint, and a surger's rainbow trail skips the greens inside a field. The user's own shots now fly at full speed in their slow-mo, and shots hung in the freeze fly on at full speed when it ends. --- .../bounties/chrome/bounty_chrome_cascade.dm | 8 + voidcrew/modules/cyberware/ware_legend.dm | 5 + voidcrew/modules/cyberware/ware_line_sandy.dm | 2 + .../modules/cyberware/ware_sandy_timestop.dm | 223 +++++++++++++----- .../unit_tests/voidcrew_cyberware_timestop.dm | 151 +++++++++++- 5 files changed, 329 insertions(+), 60 deletions(-) diff --git a/voidcrew/modules/bounties/chrome/bounty_chrome_cascade.dm b/voidcrew/modules/bounties/chrome/bounty_chrome_cascade.dm index b4fbd38602a..862a53012c9 100644 --- a/voidcrew/modules/bounties/chrome/bounty_chrome_cascade.dm +++ b/voidcrew/modules/bounties/chrome/bounty_chrome_cascade.dm @@ -60,6 +60,14 @@ return FALSE return ..() +/datum/bounty_chrome_move/cascade/tell(atom/target) + cyberware_time_untint(owner) // its glow shows through a Sandevistan's slowed world (ware_sandy_timestop.dm) + return ..() + +/datum/bounty_chrome_move/cascade/clear_tell() + . = ..() + cyberware_time_retint(owner) + /datum/bounty_chrome_move/cascade/tell_text(atom/target) return "[owner]'s spine lights up with a rising whine." diff --git a/voidcrew/modules/cyberware/ware_legend.dm b/voidcrew/modules/cyberware/ware_legend.dm index 28ec059cfe7..bed5d4a5298 100644 --- a/voidcrew/modules/cyberware/ware_legend.dm +++ b/voidcrew/modules/cyberware/ware_legend.dm @@ -97,6 +97,9 @@ var/dilation_time = 3 SECONDS /// and how fast the world runs in slow-mo. var/dilation_scale = 0.2 + /// How much faster than normal the bearer runs in the window. A surging mob in someone else's field runs at its + /// multiplier over theirs. + var/time_multiplier = 5 /obj/item/organ/cyberimp/cyberware/cascade/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -198,6 +201,7 @@ dilation_freeze = lattice.dilation_freeze dilation_time = lattice.dilation_time dilation_scale = lattice.dilation_scale + time_multiplier = lattice.time_multiplier return ..() /datum/status_effect/cyberware_cascade_window/on_apply() @@ -255,6 +259,7 @@ if(time_stop || dilated) // a surge that slowed the world keeps its green trail to the end of the window stopped_afterimage(old_loc) return + trail_hue = cyberware_time_trail_hue(owner, trail_hue) // ware_sandy_timestop.dm new /obj/effect/temp_visual/decoy/fading/cascade(old_loc, owner, trail_hue, afterimage_alpha) trail_hue = (trail_hue + CYBERWARE_CASCADE_HUE_STEP) % 360 diff --git a/voidcrew/modules/cyberware/ware_line_sandy.dm b/voidcrew/modules/cyberware/ware_line_sandy.dm index 57850ce3801..ac92f7b4efb 100644 --- a/voidcrew/modules/cyberware/ware_line_sandy.dm +++ b/voidcrew/modules/cyberware/ware_line_sandy.dm @@ -55,6 +55,7 @@ dilation_freeze = 0 dilation_time = 2 SECONDS dilation_scale = 0.5 + time_multiplier = 2 /datum/action/cooldown/cyberware/cascade_surge/street name = "Reflex Surge" @@ -95,6 +96,7 @@ dilation_freeze = 0 dilation_time = 3 SECONDS dilation_scale = 0.35 + time_multiplier = 1 / 0.35 /datum/action/cooldown/cyberware/cascade_surge/milspec name = "Reflex Surge" diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index 6d7c10d1040..4053fc844ec 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -36,16 +36,23 @@ * * Bosses (a finisher user, or a boss-rank human NPC) are held for SANDY_STOP_BOSS_HOLD at most. * - * A mob running its own Cascade surge (a bounty NPC's lattice) is never slowed, frozen or tinted while its window - * is open, and neither are its shots. If it surges while already held, the field lets it go at once and drops - * every slow it was carrying (surge_free()). When its window closes the field may take it again. + * Each surge model speeds its bearer up by a time multiplier (the lattice's time_multiplier): street 2x, service + * about 2.86x, Cascade 5x. A mob on its own surge (a bounty NPC's lattice) runs at its multiplier over the user's, + * at most SANDY_STOP_MAX_RELATIVE: a street NPC in a Cascade's field at 40%, an equal tier at 100% (left alone), + * a Cascade NPC in a street user's field at 200% (2.5x, capped). The user's freeze doesn't change that: a surging + * mob keeps its relative rate through it, so the freeze only stops those with no surge. Faster than the user runs + * the same machinery the other way (its deadlines, cooldowns, walk, shots and gore come sooner). A surging mob is + * never tinted, at any rate. If it surges while already held, the field lets it go at once, drops every slow it + * was carrying (surge_free()) and takes it back at its relative rate. When its window closes the field takes it at + * the field's own pace, unless it is dead: a corpse freed by a surge is left as it fell. * * The user's reflex dodge (ware_pro_combat.dm) is off while the world is slowed: the speed gap already does that * job. It comes back for the rest of the window. * * Look: affected things get a grey-green tint instead of tg's inversion. Floor items, blood decals and the * user are never touched, so they keep their colour, and so do the user's own shots. Shots the user fires - * while the world is frozen fly a tile, hang there, and go on when it starts moving again. + * while the world is frozen fly a tile and hang there until the freeze ends, then fly at full speed. Shots the + * user fires in the slow-mo are never held: they fly at full speed. */ /// Another player within this many tiles of the user means no dilation. @@ -56,6 +63,10 @@ #define SANDY_STOP_BOSS_HOLD (1 SECONDS) /// How far, in pixels, the user's own shots fly before they hang in a frozen world. #define SANDY_STOP_SHOT_HANG 32 +/// The fastest a surging mob runs in someone else's field, relative to normal speed. +#define SANDY_STOP_MAX_RELATIVE 2 +/// Relative rates this close to 1 count as the same speed: the mob is left alone. +#define SANDY_STOP_SAME_RATE 0.01 /// Trait source for everything the dilation applies. #define SANDY_STOP_TRAIT "sandevistan_time_stop" /// Filter name of the user's edge glow. @@ -64,12 +75,12 @@ /// Every atom the dilation has hold of, to its field. GLOBAL_LIST_EMPTY(cyberware_time_dilated) -/// How fast time runs for `thing`: 1 normally, 0 frozen, in between in slow-mo. +/// How fast time runs for `thing`: 1 normally, 0 frozen, in between in slow-mo, above 1 for a faster surger. /proc/cyberware_time_scale(atom/thing) if(isnull(thing)) return 1 var/datum/proximity_monitor/advanced/timestop/sandevistan/field = GLOB.cyberware_time_dilated[thing] - return field ? field.time_scale : 1 + return field ? field.scale_for(thing) : 1 /// Every dilation field running now. GLOBAL_LIST_EMPTY(cyberware_time_fields) @@ -112,11 +123,39 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/mob/living/body = thing return !!body.has_status_effect(/datum/status_effect/cyberware_cascade_window) -/// `surger` just opened its own surge window: every field lets it go and leaves it be until the window closes. -/proc/cyberware_surge_frees(mob/living/surger) +/// `surger` just opened its own surge window at `multiplier`: every field lets it go and runs it at its relative +/// rate until the window closes. +/proc/cyberware_surge_frees(mob/living/surger, multiplier) for(var/datum/proximity_monitor/advanced/timestop/sandevistan/field as anything in GLOB.cyberware_time_fields) if(field.host != surger) - field.surge_free(surger) + field.surge_free(surger, multiplier) + +/// The time multiplier of `body`'s open surge window, or 1 with none. +/proc/cyberware_surge_multiplier(mob/living/body) + if(!isliving(body)) + return 1 + var/datum/status_effect/cyberware_cascade_window/window = body.has_status_effect(/datum/status_effect/cyberware_cascade_window) + return window ? window.time_multiplier : 1 + +/// A surge's tell is about to play on `body`: let its own colours show through the field's tint while it winds up. +/proc/cyberware_time_untint(mob/living/body) + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = GLOB.cyberware_time_dilated[body] + field?.escape_the_negative_zone(body) + +/// The tell on `body` is over: if the field still holds it at its own pace, tint it again. +/proc/cyberware_time_retint(mob/living/body) + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = GLOB.cyberware_time_dilated[body] + if(field && !field.surging[body]) + field.into_the_negative_zone(body) + +/// The next hue for a surging `bearer`'s rainbow trail: inside someone's field it skips the greens, which read as the field's own tint. +/proc/cyberware_time_trail_hue(mob/living/bearer, hue) + if(hue < 70 || hue > 170) + return hue + for(var/datum/proximity_monitor/advanced/timestop/sandevistan/field as anything in GLOB.cyberware_time_fields) + if(field.surging[bearer]) + return 170 + return hue /// How long, in real time, `time` deciseconds of `thing`'s own time take from now. Exact through the freeze, /// the slow-mo and the end of the effect, as the field stands now. @@ -139,7 +178,9 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) LAZYOR(field.slowed_tags[thing], tag) var/list/real = list() if(loop != 1) - var/scale = field.time_scale > 0 ? field.time_scale : field.slow_scale + var/scale = field.scale_for(thing) + if(scale <= 0) + scale = field.slow_scale real += 0 for(var/time in times) real += time / max(scale, 0.05) @@ -155,19 +196,20 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) reached = at return real -/// Time `thing` lost in the last `elapsed` deciseconds: all of it frozen, none of it outside a field. +/// Time `thing` lost in the last `elapsed` deciseconds: all of it frozen, none of it outside a field. Negative for a +/// surger running faster than normal: it gained time. /proc/cyberware_time_lost(atom/thing, elapsed) var/datum/proximity_monitor/advanced/timestop/sandevistan/field = GLOB.cyberware_time_dilated[thing] if(!field || elapsed <= 0) return 0 - return elapsed * (1 - field.time_scale) + return elapsed * (1 - field.scale_for(thing)) /// Time a held `thing` has lost since the field last pushed its deadlines back. /proc/cyberware_time_lag(atom/thing) var/datum/proximity_monitor/advanced/timestop/sandevistan/field = GLOB.cyberware_time_dilated[thing] if(!field) return 0 - return (world.time - field.last_tick) * (1 - field.time_scale) + return (world.time - field.last_tick) * (1 - field.scale_for(thing)) /// A burst clock's start for a burst beginning now. The field pushes it back with the shooter's deadlines. /proc/cyberware_burst_clock(atom/shooter) @@ -278,13 +320,17 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/freeze_time = 0 /// How fast the world runs after that. var/slow_scale = 1 + /// The user's own time multiplier (their surge model's), for surging mobs' relative rates. + var/user_multiplier = 1 /// `immune_atoms` must be list(user). `time` is the whole effect; `freeze` of it is frozen, the rest at `scale`. -/obj/effect/timestop/sandevistan/Initialize(mapload, radius, time, list/immune_atoms, start = TRUE, silent = TRUE, freeze = 0, scale = 1) +/// `multiplier` is the user's surge model's time multiplier. +/obj/effect/timestop/sandevistan/Initialize(mapload, radius, time, list/immune_atoms, start = TRUE, silent = TRUE, freeze = 0, scale = 1, multiplier = 1) if(length(immune_atoms)) user = immune_atoms[1] freeze_time = freeze slow_scale = scale + user_multiplier = multiplier return ..(mapload, radius, time, immune_atoms, start, TRUE) /obj/effect/timestop/sandevistan/Destroy() @@ -296,7 +342,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) QDEL_IN(src, duration) if(QDELETED(user)) return - var/datum/proximity_monitor/advanced/timestop/sandevistan/field = new(user, freezerange, TRUE, immune, antimagic_flags, FALSE, freeze_time > 0 ? 0 : slow_scale) + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = new(user, freezerange, TRUE, immune, antimagic_flags, FALSE, freeze_time > 0 ? 0 : slow_scale, user_multiplier) field.stopper = src field.slow_scale = slow_scale if(freeze_time > 0) @@ -345,9 +391,14 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/list/surging = list() /// Held mobs, to the tags of their slowed hv_vis_play() animations, to drop if they break free. var/list/slowed_tags = list() + /// The user's time multiplier. A surging mob runs at its own over this. + var/user_multiplier = 1 + /// Surging mobs held at their relative rate, and their shots, to that rate. Never 0, never tinted. + var/list/own_scales = list() -/datum/proximity_monitor/advanced/timestop/sandevistan/New(atom/_host, range, _ignore_if_not_on_turf = TRUE, list/immune, antimagic_flags, channelled, start_scale = 0) +/datum/proximity_monitor/advanced/timestop/sandevistan/New(atom/_host, range, _ignore_if_not_on_turf = TRUE, list/immune, antimagic_flags, channelled, start_scale = 0, _user_multiplier = 1) time_scale = start_scale + user_multiplier = _user_multiplier > 0 ? _user_multiplier : 1 last_tick = world.time GLOB.cyberware_time_fields += src . = ..() @@ -366,6 +417,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) UnregisterSignal(surger, COMSIG_QDELETING) surging.Cut() . = ..() // unfreeze_all() lets everything go + own_scales.Cut() released.Cut() /// How long, in real time from now, `local` deciseconds of `thing`'s own time take: the rest of the freeze, @@ -377,6 +429,14 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) ends = min(ends, boss_holds[thing]) if(ends <= now) return local + var/own = own_scales[thing] + if(own) // a surger at its own steady rate, freeze or not + var/own_span = (ends - now) * own + if(local <= own_span) + return local / own + return ends - now + local - own_span + if(surging[thing]) + return local var/at = now var/scale = time_scale if(scale <= 0) @@ -390,11 +450,26 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) at = ends return at - now + local +/// How fast time runs for `thing` in this field: a surger's own relative rate, 1 for a surger left alone, else the field's pace. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/scale_for(atom/thing) + var/own = own_scales[thing] + if(own) + return own + if(surging[thing]) + return 1 + return time_scale + +/// The rate, against normal speed, of a mob surging at `multiplier` in this field. Capped at SANDY_STOP_MAX_RELATIVE. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/relative_rate(multiplier) + if(!multiplier || multiplier <= 0) + multiplier = 1 + return min(multiplier / user_multiplier, SANDY_STOP_MAX_RELATIVE) + /datum/proximity_monitor/advanced/timestop/sandevistan/freeze_atom(atom/movable/A) if(!istype(A) || GLOB.cyberware_time_dilated[A] || global_frozen_atoms[A] || released[A] || immune[A] || surging[A]) return FALSE if(cyberware_own_surge(A)) - leave_surger(A) + take_surger(A, cyberware_surge_multiplier(A)) return FALSE if(ismob(A)) var/mob/M = A @@ -406,14 +481,14 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) hold_mob(A) else if(isprojectile(A)) var/obj/projectile/shot = A - if(shot.firer && (surging[shot.firer] || cyberware_own_surge(shot.firer))) // a surging mob's shots fly at its speed + if(shot.firer && immune[shot.firer]) // the user's own: hang_shot takes these in the freeze; in the slow-mo they fly free return FALSE - if(shot.firer && immune[shot.firer]) - if(time_scale <= 0) // hang_shot takes these + if(shot.firer && (surging[shot.firer] || cyberware_own_surge(shot.firer))) // a surging mob's shots fly at its rate + var/rate = surging[shot.firer] ? scale_for(shot.firer) : relative_rate(cyberware_surge_multiplier(shot.firer)) + if(abs(rate - 1) < SANDY_STOP_SAME_RATE) return FALSE - user_shots[shot] = TRUE + own_scales[shot] = rate tint = FALSE - escape_the_negative_zone(shot) // in case it was tinted before it had a firer hold_shot(shot) else if(ismecha(A)) hold_mech(A) @@ -466,6 +541,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) frozen_structures -= A user_shots -= A slowed_tags -= A + own_scales -= A UnregisterSignal(A, COMSIG_QDELETING) escape_the_negative_zone(A) frozen_things -= A @@ -474,14 +550,15 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) // ----- mobs ----- -/// Takes hold of a mob: frozen or slowed, whichever the field is doing. +/// Takes hold of a mob: frozen or slowed (or sped up, for a faster surger), whichever its rate is. /datum/proximity_monitor/advanced/timestop/sandevistan/proc/hold_mob(mob/living/victim) held_mobs[victim] = TRUE - if(time_scale <= 0) + var/rate = scale_for(victim) + if(rate <= 0) freeze_mob(victim) else slow_mob(victim) - if(cyberware_time_stop_boss(victim)) + if(rate < 1 && cyberware_time_stop_boss(victim)) boss_holds[victim] = world.time + SANDY_STOP_BOSS_HOLD /datum/proximity_monitor/advanced/timestop/sandevistan/proc/let_go_mob(mob/living/victim) @@ -507,13 +584,16 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/mob/living/simple_animal/animal_victim = victim animal_victim.toggle_ai(initial(animal_victim.AIStatus)) -/// Slows a mob's walk to the field's pace. Its timers are pushed back every tick in process(). +/// Paces a mob's walk to its rate in the field: slower, or faster for a faster surger. Its timers move with it every tick(). /datum/proximity_monitor/advanced/timestop/sandevistan/proc/slow_mob(mob/living/victim) - var/base_delay = max(victim.cached_multiplicative_slowdown, 1) - victim.add_or_update_variable_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation, multiplicative_slowdown = base_delay * (1 / time_scale - 1)) + var/rate = scale_for(victim) + victim.remove_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation) + // A slowed walk gets at least a decisecond's delay to stretch; a faster one divides what it really has. + var/base_delay = rate < 1 ? max(victim.cached_multiplicative_slowdown, 1) : max(victim.cached_multiplicative_slowdown, 0) + victim.add_or_update_variable_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation, multiplicative_slowdown = base_delay * (1 / rate - 1)) if(istype(victim, /mob/living/simple_animal/hostile)) var/mob/living/simple_animal/hostile/animal_victim = victim - animal_victim.move_to_delay = initial(animal_victim.move_to_delay) / time_scale + animal_victim.move_to_delay = initial(animal_victim.move_to_delay) / rate /datum/proximity_monitor/advanced/timestop/sandevistan/proc/unslow_mob(mob/living/victim) victim.remove_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation) @@ -521,7 +601,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/mob/living/simple_animal/hostile/animal_victim = victim animal_victim.move_to_delay = initial(animal_victim.move_to_delay) -/// Pushes every timing deadline `victim` has that hasn't passed yet back by `push` deciseconds. +/// Pushes every timing deadline `victim` has that hasn't passed yet back by `push` deciseconds (forward, if negative). /datum/proximity_monitor/advanced/timestop/sandevistan/proc/push_deadlines(mob/living/victim, push) var/now = world.time if(victim.next_move > now) @@ -553,18 +633,31 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) // ----- mobs on their own surge ----- -/// Leaves `surger` be while its own window runs. It isn't held. -/datum/proximity_monitor/advanced/timestop/sandevistan/proc/leave_surger(mob/living/surger) +/** + * `surger` runs its own surge at `multiplier`: at the same rate as the user it is left alone; otherwise the field + * holds it at its relative rate, slower or faster, untinted. The user's freeze doesn't touch it. + */ +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/take_surger(mob/living/surger, multiplier) surging[surger] = TRUE RegisterSignal(surger, COMSIG_QDELETING, PROC_REF(held_gone), override = TRUE) + var/rate = relative_rate(multiplier) + if(abs(rate - 1) < SANDY_STOP_SAME_RATE) + return + own_scales[surger] = rate + hold_mob(surger) + GLOB.cyberware_time_dilated[surger] = src + frozen_things[surger] = TRUE + SEND_SIGNAL(surger, COMSIG_ATOM_TIMESTOP_FREEZE, src) /** - * `surger` opened its own surge: if the field holds it, let it go now and drop every slow it carried (its walk, - * its pauses, its last bit of lost time, its slowed animations and the shots it fired), then leave it be. + * `surger` opened its own surge at `multiplier`: if the field holds it, let it go now and drop every slow it + * carried (its walk, its pauses, its last bit of lost time, its slowed animations and the shots it fired), then + * take it back at its relative rate, shots and all. */ -/datum/proximity_monitor/advanced/timestop/sandevistan/proc/surge_free(mob/living/surger) +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/surge_free(mob/living/surger, multiplier) if(surging[surger]) return + var/list/its_shots = list() if(GLOB.cyberware_time_dilated[surger] == src) var/lag = cyberware_time_lag(surger) if(lag > 0) @@ -576,21 +669,34 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) for(var/obj/projectile/shot as anything in held_shots.Copy()) if(shot.firer == surger) unfreeze_atom(shot) + its_shots += shot else var/turf/centre = get_turf(host) var/turf/here = get_turf(surger) if(!centre || !here || here.z != centre.z || get_dist(centre, here) > current_range) - return // out of reach: if it walks in still surging, freeze_atom() leaves it be - leave_surger(surger) - -/// Every tick: surgers whose window has closed are fair game again, if they are still in range. + return // out of reach: if it walks in still surging, freeze_atom() takes it at its rate + take_surger(surger, multiplier) + for(var/obj/projectile/shot as anything in its_shots) + if(!QDELETED(shot)) + freeze_atom(shot) + +/// Every tick: surgers whose window has closed go back to the field's pace, if they are still in range. A surger +/// that died is let go for good: its corpse is never taken back and tinted. /datum/proximity_monitor/advanced/timestop/sandevistan/proc/check_surgers() var/turf/centre = get_turf(host) for(var/mob/living/surger as anything in surging.Copy()) - if(cyberware_own_surge(surger)) + if(surger.stat != DEAD && cyberware_own_surge(surger)) continue + if(GLOB.cyberware_time_dilated[surger] == src) + var/lag = cyberware_time_lag(surger) + if(lag) + push_deadlines(surger, lag) + unfreeze_atom(surger) surging -= surger UnregisterSignal(surger, COMSIG_QDELETING) + if(surger.stat == DEAD) + released[surger] = TRUE + continue var/turf/here = get_turf(surger) if(centre && here && here.z == centre.z && get_dist(centre, here) <= current_range) freeze_atom(surger) @@ -642,10 +748,11 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) /datum/proximity_monitor/advanced/timestop/sandevistan/proc/hold_shot(obj/projectile/shot) held_shots[shot] = shot.speed - if(time_scale <= 0) + var/rate = scale_for(shot) + if(rate <= 0) shot.paused = TRUE else - shot.speed = held_shots[shot] * time_scale + shot.speed = held_shots[shot] * rate /datum/proximity_monitor/advanced/timestop/sandevistan/proc/let_go_shot(obj/projectile/shot) shot.speed = held_shots[shot] @@ -658,20 +765,15 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) RegisterSignal(shot, COMSIG_PROJECTILE_FIRE, PROC_REF(hang_shot)) /// Signal proc for [COMSIG_PROJECTILE_FIRE]: the user's shot keeps its colour. In a frozen world it flies a -/// tile, then hangs; in slow-mo it crawls like everything else. +/// tile, then hangs until the freeze ends (tick() lets it go at full speed); in the slow-mo it flies at full speed. /datum/proximity_monitor/advanced/timestop/sandevistan/proc/hang_shot(obj/projectile/shot) SIGNAL_HANDLER UnregisterSignal(shot, COMSIG_PROJECTILE_FIRE) if(QDELETED(shot) || shot.deletion_queued) return if(time_scale > 0) - user_shots[shot] = TRUE - escape_the_negative_zone(shot) // in case it was tinted before it had a firer - if(!held_shots[shot]) - hold_shot(shot) - GLOB.cyberware_time_dilated[shot] = src - frozen_things[shot] = TRUE - RegisterSignal(shot, COMSIG_QDELETING, PROC_REF(held_gone), override = TRUE) + if(GLOB.cyberware_time_dilated[shot] == src) + unfreeze_atom(shot) // caught before it had a firer: its speed and colour back return // A hitscan beam takes its first step itself once paused (process_hitscan) and stops there. if(!shot.hitscan) @@ -703,6 +805,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) boss_holds -= thing surging -= thing slowed_tags -= thing + own_scales -= thing frozen_structures -= thing frozen_things -= thing GLOB.cyberware_time_dilated -= thing @@ -713,6 +816,8 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) /datum/proximity_monitor/advanced/timestop/sandevistan/proc/set_time_scale(new_scale) time_scale = new_scale for(var/mob/living/victim as anything in held_mobs) + if(own_scales[victim]) // a surger keeps its own rate + continue unfreeze_mob(victim) if(time_scale <= 0) unslow_mob(victim) @@ -720,6 +825,8 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) else slow_mob(victim) for(var/obj/projectile/shot as anything in held_shots) + if(own_scales[shot]) + continue shot.paused = time_scale <= 0 if(time_scale > 0) shot.speed = held_shots[shot] * time_scale @@ -743,10 +850,12 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) return var/elapsed = world.time - last_tick last_tick = world.time + for(var/mob/living/victim as anything in held_mobs) + var/lost = elapsed * (1 - scale_for(victim)) // a faster surger's is negative: its deadlines come sooner + if(lost) + push_deadlines(victim, lost) var/push = elapsed * (1 - time_scale) if(push > 0) - for(var/mob/living/victim as anything in held_mobs) - push_deadlines(victim, push) for(var/atom/movable/thing as anything in held_throws) var/datum/thrownthing/throw_datum = thing.throwing if(throw_datum && !throw_datum.paused) @@ -760,6 +869,8 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) released[boss] = TRUE if(thaw_at && world.time >= thaw_at) thaw_at = 0 + for(var/obj/projectile/shot as anything in user_shots.Copy()) // the user's hung shots fly on at full speed + unfreeze_atom(shot) set_time_scale(slow_scale) /datum/proximity_monitor/advanced/timestop/sandevistan/into_the_negative_zone(atom/A) @@ -776,6 +887,8 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/dilation_time = 3 SECONDS /// How fast the world runs in slow-mo. var/dilation_scale = 0.2 + /// How much faster than normal the bearer runs: sets surging mobs' relative rates in each other's fields. + var/time_multiplier = 5 /// The running dilation, if any. var/obj/effect/timestop/sandevistan/time_stop /// Whether this window slowed the world. Its trail stays green after the dilation ends. @@ -783,7 +896,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) /// Slows the world if the bearer is a player and nobody else is near. Called as the window opens. /datum/status_effect/cyberware_cascade_window/proc/try_time_stop() - cyberware_surge_frees(owner) // anyone's surge breaks them out of someone else's field + cyberware_surge_frees(owner, time_multiplier) // anyone's surge runs at its own rate in someone else's field if(dilation_freeze + dilation_time <= 0 || !owner.ckey || time_stop) return FALSE if(cyberware_time_stop_witnessed(owner)) @@ -791,7 +904,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/turf/here = get_turf(owner) if(!here) return FALSE - time_stop = new(here, SANDY_STOP_FIELD_RANGE, dilation_freeze + dilation_time, list(owner), TRUE, TRUE, dilation_freeze, dilation_scale) + time_stop = new(here, SANDY_STOP_FIELD_RANGE, dilation_freeze + dilation_time, list(owner), TRUE, TRUE, dilation_freeze, dilation_scale, time_multiplier) if(QDELETED(time_stop)) time_stop = null return FALSE @@ -892,5 +1005,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) #undef SANDY_STOP_FIELD_RANGE #undef SANDY_STOP_BOSS_HOLD #undef SANDY_STOP_SHOT_HANG +#undef SANDY_STOP_MAX_RELATIVE +#undef SANDY_STOP_SAME_RATE #undef SANDY_STOP_TRAIT #undef SANDY_STOP_OUTLINE diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm index 16c7b40edf5..559000aab03 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -11,11 +11,11 @@ /datum/unit_test/voidcrew_cyberware_timestop abstract_type = /datum/unit_test/voidcrew_cyberware_timestop -/// A player body (a key, no client) at `where` with a Cascade lattice in it -/datum/unit_test/voidcrew_cyberware_timestop/proc/make_user(turf/where, key) +/// A player body (a key, no client) at `where` with a Cascade lattice (or `lattice_type`) in it +/datum/unit_test/voidcrew_cyberware_timestop/proc/make_user(turf/where, key, lattice_type = /obj/item/organ/cyberimp/cyberware/cascade) var/mob/living/carbon/human/user = allocate(/mob/living/carbon/human/consistent, where) user.key = key - var/obj/item/organ/cyberimp/cyberware/cascade/lattice = allocate(/obj/item/organ/cyberimp/cyberware/cascade) + var/obj/item/organ/cyberimp/cyberware/cascade/lattice = allocate(lattice_type) TEST_ASSERT(lattice.Insert(user, special = TRUE), "Test fixture: the lattice wouldn't go in") return user @@ -184,10 +184,10 @@ /datum/unit_test/voidcrew_cyberware_timestop/proc/tinted(atom/thing) return length(thing.atom_colours) >= TEMPORARY_COLOUR_PRIORITY && !isnull(thing.atom_colours[TEMPORARY_COLOUR_PRIORITY]) -/// A raider NPC at `where` with its own Cascade lattice -/datum/unit_test/voidcrew_cyberware_timestop/proc/make_surger(turf/where) +/// A raider NPC at `where` with its own Cascade lattice (or `lattice_type`) +/datum/unit_test/voidcrew_cyberware_timestop/proc/make_surger(turf/where, lattice_type = /obj/item/organ/cyberimp/cyberware/cascade) var/mob/living/carbon/human/npc/raider = allocate(/mob/living/carbon/human/npc, where) - var/obj/item/organ/cyberimp/cyberware/cascade/lattice = allocate(/obj/item/organ/cyberimp/cyberware/cascade) + var/obj/item/organ/cyberimp/cyberware/cascade/lattice = allocate(lattice_type) TEST_ASSERT(lattice.Insert(raider, special = TRUE), "Test fixture: the lattice wouldn't go in the NPC") return raider @@ -281,3 +281,142 @@ user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) raider.remove_status_effect(/datum/status_effect/cyberware_cascade_window) raider.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + +/// Ends `body`'s window and crash, for the end of a test +/datum/unit_test/voidcrew_cyberware_timestop/proc/end_surge(mob/living/body) + body.remove_status_effect(/datum/status_effect/cyberware_cascade_window) + body.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + +/// A surging mob runs at its own multiplier over the user's, at most twice normal speed +/datum/unit_test/voidcrew_cyberware_timestop/relative_rates + +/datum/unit_test/voidcrew_cyberware_timestop/relative_rates/Run() + var/obj/item/organ/cyberimp/cyberware/cascade/street/street = allocate(/obj/item/organ/cyberimp/cyberware/cascade/street) + var/obj/item/organ/cyberimp/cyberware/cascade/milspec/service = allocate(/obj/item/organ/cyberimp/cyberware/cascade/milspec) + var/obj/item/organ/cyberimp/cyberware/cascade/cascade = allocate(/obj/item/organ/cyberimp/cyberware/cascade) + TEST_ASSERT_EQUAL(street.time_multiplier, 2, "The street rig isn't 2x") + TEST_ASSERT(abs(service.time_multiplier - 2.857) < 0.01, "The service lattice isn't about 2.86x ([service.time_multiplier])") + TEST_ASSERT_EQUAL(cascade.time_multiplier, 5, "The Cascade isn't 5x") + + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + var/datum/status_effect/cyberware_cascade_window/window = surge(user) + sleep(0.1 SECONDS) + TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield + TEST_ASSERT_EQUAL(field.user_multiplier, 5, "A Cascade user's field doesn't know they run at 5x") + TEST_ASSERT(abs(field.relative_rate(2) - 0.4) < 0.001, "A street surger in a Cascade's field isn't at 40%") + TEST_ASSERT_EQUAL(field.relative_rate(5), 1, "An equal surger isn't at full speed") + field.user_multiplier = 2 // as a street user's field + TEST_ASSERT_EQUAL(field.relative_rate(5), 2, "A Cascade surger in a street user's field isn't capped at 200%") + TEST_ASSERT(abs(field.relative_rate(1 / 0.35) - 1.4286) < 0.001, "A service surger in a street user's field isn't at about 143%") + field.user_multiplier = 5 + end_surge(user) + +/// A slower surger in the user's field is held at its relative rate through the freeze, never frozen or tinted +/datum/unit_test/voidcrew_cyberware_timestop/slower_surger + +/datum/unit_test/voidcrew_cyberware_timestop/slower_surger/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + var/mob/living/carbon/human/npc/raider = make_surger(spot(2, 0), /obj/item/organ/cyberimp/cyberware/cascade/street) + TEST_ASSERT_NOTNULL(surge(raider), "The NPC's window didn't open") + var/datum/status_effect/cyberware_cascade_window/window = surge(user) + sleep(0.1 SECONDS) + TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield + TEST_ASSERT(field.surging[raider], "The field didn't notice the NPC's surge") + TEST_ASSERT(abs(cyberware_time_scale(raider) - 0.4) < 0.001, "A street surger in a Cascade's field isn't at 40% ([cyberware_time_scale(raider)])") + TEST_ASSERT(!HAS_TRAIT(raider, TRAIT_IMMOBILIZED) && !HAS_TRAIT(raider, TRAIT_AI_PAUSED), "A surging NPC was frozen by the user's freeze") + TEST_ASSERT(!tinted(raider), "A surging NPC was tinted") + TEST_ASSERT(raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "A slower surger isn't slowed") + var/lost = cyberware_time_lost(raider, 10) + TEST_ASSERT(lost > 5.9 && lost < 6.1, "A slower surger's clock didn't run at 40% (lost [lost] of 10)") + sleep(1.2 SECONDS) // into the user's slow-mo: the surger keeps its own rate + TEST_ASSERT(abs(cyberware_time_scale(raider) - 0.4) < 0.001, "The thaw changed a surger's rate") + end_surge(user) + end_surge(raider) + +/// A faster surger runs faster than normal: deadlines sooner, a quicker walk, quicker shots and gore +/datum/unit_test/voidcrew_cyberware_timestop/faster_surger + +/datum/unit_test/voidcrew_cyberware_timestop/faster_surger/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser", /obj/item/organ/cyberimp/cyberware/cascade/street) + var/mob/living/carbon/human/npc/raider = make_surger(spot(2, 0)) + var/obj/projectile/bullet/shot = allocate(/obj/projectile/bullet, spot(1, 1)) + shot.firer = raider + var/shot_speed = shot.speed + var/datum/status_effect/cyberware_cascade_window/window = surge(user) + sleep(0.1 SECONDS) + TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield + TEST_ASSERT_EQUAL(field.user_multiplier, 2, "A street user's field doesn't know they run at 2x") + TEST_ASSERT(field.held_shots[shot], "Test fixture: the NPC's shot isn't held before it surges") + + TEST_ASSERT_NOTNULL(surge(raider), "The NPC's window didn't open") + TEST_ASSERT_EQUAL(cyberware_time_scale(raider), 2, "A Cascade surger in a street user's field isn't capped at 200%") + TEST_ASSERT(!tinted(raider), "A faster surger was tinted") + TEST_ASSERT_EQUAL(cyberware_time_lost(raider, 10), -10, "A faster surger's clock doesn't run at double speed") + TEST_ASSERT_EQUAL(cyberware_dilated_time(raider, 10), 5, "A faster surger's gore doesn't play at double speed") + var/datum/movespeed_modifier/pace = raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation) + TEST_ASSERT(pace && pace.multiplicative_slowdown <= 0, "A faster surger doesn't walk faster") + TEST_ASSERT(field.held_shots[shot] && !shot.paused && shot.speed == shot_speed * 2, "A faster surger's shot doesn't fly at double speed") + + var/next_move_was = world.time + 5 SECONDS + raider.next_move = next_move_was + sleep(0.5 SECONDS) // a couple of field ticks + TEST_ASSERT(raider.next_move < next_move_was - 0.3 SECONDS, "A faster surger's deadlines didn't come sooner") + end_surge(user) + end_surge(raider) + +/// The user's shots in their own slow-mo fly at full speed; shots hung in the freeze fly on at full speed when it ends +/datum/unit_test/voidcrew_cyberware_timestop/user_shots_free + +/datum/unit_test/voidcrew_cyberware_timestop/user_shots_free/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + var/datum/status_effect/cyberware_cascade_window/window = surge(user) + sleep(0.1 SECONDS) + TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield + + // One hanging in the freeze, as hang_shot leaves it. + var/obj/projectile/bullet/hung = allocate(/obj/projectile/bullet, spot(1, 0)) + hung.firer = user + var/hung_speed = hung.speed + field.user_shots[hung] = TRUE + field.hold_shot(hung) + GLOB.cyberware_time_dilated[hung] = field + field.frozen_things[hung] = TRUE + TEST_ASSERT(hung.paused, "Test fixture: the hung shot isn't paused") + + sleep(1.2 SECONDS) // into the slow-mo + TEST_ASSERT(field.time_scale > 0, "Test fixture: still frozen") + TEST_ASSERT(!hung.paused && hung.speed == hung_speed && !field.held_shots[hung], "A shot hung in the freeze didn't fly on at full speed") + + var/obj/projectile/bullet/shot = allocate(/obj/projectile/bullet, spot(1, 1)) + shot.firer = user + var/shot_speed = shot.speed + TEST_ASSERT(!field.freeze_atom(shot), "The field took the user's own slow-mo shot") + field.hang_shot(shot) + TEST_ASSERT(!field.held_shots[shot] && !shot.paused && shot.speed == shot_speed, "The user's slow-mo shot was slowed") + TEST_ASSERT(!tinted(shot), "The user's slow-mo shot was tinted") + end_surge(user) + +/// A surging NPC that dies is let go for good: its corpse isn't taken back and tinted +/datum/unit_test/voidcrew_cyberware_timestop/no_corpse_retake + +/datum/unit_test/voidcrew_cyberware_timestop/no_corpse_retake/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + var/mob/living/carbon/human/npc/raider = make_surger(spot(2, 0), /obj/item/organ/cyberimp/cyberware/cascade/street) + TEST_ASSERT_NOTNULL(surge(raider), "The NPC's window didn't open") + var/datum/status_effect/cyberware_cascade_window/window = surge(user) + sleep(0.1 SECONDS) + TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield + TEST_ASSERT(field.surging[raider], "Test fixture: the field didn't notice the NPC's surge") + raider.death() + sleep(0.5 SECONDS) // a couple of field ticks + TEST_ASSERT(!field.surging[raider], "The field kept a dead surger as surging") + TEST_ASSERT_NULL(GLOB.cyberware_time_dilated[raider], "The field took a dead surger back") + TEST_ASSERT(!tinted(raider), "A dead surger's corpse was tinted") + TEST_ASSERT(field.released[raider], "A dead surger wasn't let go for good") + end_surge(user) + end_surge(raider) From f52637c22dbf90bebde0f202ed91d161b6c7a916 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:45:50 -0800 Subject: [PATCH 12/33] Fly every bullet in the dilation field at the field's pace The user's shots crawl in their own slow-mo again and hang in the freeze, keeping their colour. A surging mob's shots fly at the field's pace too, untinted; the surger still fires on its own relative clock. --- .../modules/cyberware/ware_sandy_timestop.dm | 44 ++++++++------- .../unit_tests/voidcrew_cyberware_timestop.dm | 55 ++++++++++--------- 2 files changed, 53 insertions(+), 46 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index 4053fc844ec..2e3c53b3d26 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -41,7 +41,7 @@ * at most SANDY_STOP_MAX_RELATIVE: a street NPC in a Cascade's field at 40%, an equal tier at 100% (left alone), * a Cascade NPC in a street user's field at 200% (2.5x, capped). The user's freeze doesn't change that: a surging * mob keeps its relative rate through it, so the freeze only stops those with no surge. Faster than the user runs - * the same machinery the other way (its deadlines, cooldowns, walk, shots and gore come sooner). A surging mob is + * the same machinery the other way (its deadlines, cooldowns, walk and gore come sooner). A surging mob is * never tinted, at any rate. If it surges while already held, the field lets it go at once, drops every slow it * was carrying (surge_free()) and takes it back at its relative rate. When its window closes the field takes it at * the field's own pace, unless it is dead: a corpse freed by a surge is left as it fell. @@ -50,9 +50,10 @@ * job. It comes back for the rest of the window. * * Look: affected things get a grey-green tint instead of tg's inversion. Floor items, blood decals and the - * user are never touched, so they keep their colour, and so do the user's own shots. Shots the user fires - * while the world is frozen fly a tile and hang there until the freeze ends, then fly at full speed. Shots the - * user fires in the slow-mo are never held: they fly at full speed. + * user are never touched, so they keep their colour, and so do the user's own shots. Every bullet in the field + * flies at the field's pace, whoever fired it: the user's and a surging mob's crawl in the slow-mo like the rest + * (a faster surger fires sooner, but its bullets still crawl), untinted. Shots the user fires while the world is + * frozen fly a tile, hang there, and go on when it starts moving again. A shot leaving the field gets its speed back. */ /// Another player within this many tiles of the user means no dilation. @@ -373,7 +374,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/list/held_shots = list() /// Thrown things in hand. var/list/held_throws = list() - /// The user's own shots: never tinted. + /// The user's own shots: never tinted. A surging mob's held shots aren't tinted either. var/list/user_shots = list() /// Bosses in hand, to the world.time they shrug the field off. var/list/boss_holds = list() @@ -393,7 +394,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/list/slowed_tags = list() /// The user's time multiplier. A surging mob runs at its own over this. var/user_multiplier = 1 - /// Surging mobs held at their relative rate, and their shots, to that rate. Never 0, never tinted. + /// Surging mobs held at their relative rate, to that rate. Never 0, never tinted. Their shots fly at the field's pace. var/list/own_scales = list() /datum/proximity_monitor/advanced/timestop/sandevistan/New(atom/_host, range, _ignore_if_not_on_turf = TRUE, list/immune, antimagic_flags, channelled, start_scale = 0, _user_multiplier = 1) @@ -481,13 +482,13 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) hold_mob(A) else if(isprojectile(A)) var/obj/projectile/shot = A - if(shot.firer && immune[shot.firer]) // the user's own: hang_shot takes these in the freeze; in the slow-mo they fly free - return FALSE - if(shot.firer && (surging[shot.firer] || cyberware_own_surge(shot.firer))) // a surging mob's shots fly at its rate - var/rate = surging[shot.firer] ? scale_for(shot.firer) : relative_rate(cyberware_surge_multiplier(shot.firer)) - if(abs(rate - 1) < SANDY_STOP_SAME_RATE) + if(shot.firer && immune[shot.firer]) + if(time_scale <= 0) // hang_shot takes these return FALSE - own_scales[shot] = rate + user_shots[shot] = TRUE + tint = FALSE + escape_the_negative_zone(shot) // in case it was tinted before it had a firer + else if(shot.firer && (surging[shot.firer] || cyberware_own_surge(shot.firer))) // at the field's pace, like every bullet, but in colour tint = FALSE hold_shot(shot) else if(ismecha(A)) @@ -652,7 +653,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) /** * `surger` opened its own surge at `multiplier`: if the field holds it, let it go now and drop every slow it * carried (its walk, its pauses, its last bit of lost time, its slowed animations and the shots it fired), then - * take it back at its relative rate, shots and all. + * take it back at its relative rate. Its shots go back in at the field's pace, untinted. */ /datum/proximity_monitor/advanced/timestop/sandevistan/proc/surge_free(mob/living/surger, multiplier) if(surging[surger]) @@ -764,16 +765,21 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) SIGNAL_HANDLER RegisterSignal(shot, COMSIG_PROJECTILE_FIRE, PROC_REF(hang_shot)) -/// Signal proc for [COMSIG_PROJECTILE_FIRE]: the user's shot keeps its colour. In a frozen world it flies a -/// tile, then hangs until the freeze ends (tick() lets it go at full speed); in the slow-mo it flies at full speed. +/// Signal proc for [COMSIG_PROJECTILE_FIRE]: the user's shot keeps its colour but flies at the field's pace like +/// every bullet in it. In a frozen world it flies a tile, then hangs; in the slow-mo it crawls with the rest. /datum/proximity_monitor/advanced/timestop/sandevistan/proc/hang_shot(obj/projectile/shot) SIGNAL_HANDLER UnregisterSignal(shot, COMSIG_PROJECTILE_FIRE) if(QDELETED(shot) || shot.deletion_queued) return if(time_scale > 0) - if(GLOB.cyberware_time_dilated[shot] == src) - unfreeze_atom(shot) // caught before it had a firer: its speed and colour back + user_shots[shot] = TRUE + escape_the_negative_zone(shot) // in case it was tinted before it had a firer + if(!held_shots[shot]) + hold_shot(shot) + GLOB.cyberware_time_dilated[shot] = src + frozen_things[shot] = TRUE + RegisterSignal(shot, COMSIG_QDELETING, PROC_REF(held_gone), override = TRUE) return // A hitscan beam takes its first step itself once paused (process_hitscan) and stops there. if(!shot.hitscan) @@ -825,8 +831,6 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) else slow_mob(victim) for(var/obj/projectile/shot as anything in held_shots) - if(own_scales[shot]) - continue shot.paused = time_scale <= 0 if(time_scale > 0) shot.speed = held_shots[shot] * time_scale @@ -869,8 +873,6 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) released[boss] = TRUE if(thaw_at && world.time >= thaw_at) thaw_at = 0 - for(var/obj/projectile/shot as anything in user_shots.Copy()) // the user's hung shots fly on at full speed - unfreeze_atom(shot) set_time_scale(slow_scale) /datum/proximity_monitor/advanced/timestop/sandevistan/into_the_negative_zone(atom/A) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm index 559000aab03..22c3b15f7fc 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -358,7 +358,8 @@ TEST_ASSERT_EQUAL(cyberware_dilated_time(raider, 10), 5, "A faster surger's gore doesn't play at double speed") var/datum/movespeed_modifier/pace = raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation) TEST_ASSERT(pace && pace.multiplicative_slowdown <= 0, "A faster surger doesn't walk faster") - TEST_ASSERT(field.held_shots[shot] && !shot.paused && shot.speed == shot_speed * 2, "A faster surger's shot doesn't fly at double speed") + TEST_ASSERT(field.held_shots[shot] && !shot.paused && shot.speed == shot_speed * field.time_scale, "A faster surger's shot doesn't fly at the field's pace") + TEST_ASSERT(!tinted(shot), "A surger's shot was tinted") var/next_move_was = world.time + 5 SECONDS raider.next_move = next_move_was @@ -367,38 +368,42 @@ end_surge(user) end_surge(raider) -/// The user's shots in their own slow-mo fly at full speed; shots hung in the freeze fly on at full speed when it ends -/datum/unit_test/voidcrew_cyberware_timestop/user_shots_free +/// Every bullet in the field flies at the field's pace, whoever fired it: the user's and a surging NPC's alike, untinted +/datum/unit_test/voidcrew_cyberware_timestop/shots_at_field_pace -/datum/unit_test/voidcrew_cyberware_timestop/user_shots_free/Run() +/datum/unit_test/voidcrew_cyberware_timestop/shots_at_field_pace/Run() var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + var/mob/living/carbon/human/npc/raider = make_surger(spot(3, 0)) var/datum/status_effect/cyberware_cascade_window/window = surge(user) sleep(0.1 SECONDS) TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield - - // One hanging in the freeze, as hang_shot leaves it. - var/obj/projectile/bullet/hung = allocate(/obj/projectile/bullet, spot(1, 0)) - hung.firer = user - var/hung_speed = hung.speed - field.user_shots[hung] = TRUE - field.hold_shot(hung) - GLOB.cyberware_time_dilated[hung] = field - field.frozen_things[hung] = TRUE - TEST_ASSERT(hung.paused, "Test fixture: the hung shot isn't paused") - + TEST_ASSERT_NOTNULL(surge(raider), "The NPC's window didn't open") sleep(1.2 SECONDS) // into the slow-mo - TEST_ASSERT(field.time_scale > 0, "Test fixture: still frozen") - TEST_ASSERT(!hung.paused && hung.speed == hung_speed && !field.held_shots[hung], "A shot hung in the freeze didn't fly on at full speed") - - var/obj/projectile/bullet/shot = allocate(/obj/projectile/bullet, spot(1, 1)) - shot.firer = user - var/shot_speed = shot.speed - TEST_ASSERT(!field.freeze_atom(shot), "The field took the user's own slow-mo shot") - field.hang_shot(shot) - TEST_ASSERT(!field.held_shots[shot] && !shot.paused && shot.speed == shot_speed, "The user's slow-mo shot was slowed") - TEST_ASSERT(!tinted(shot), "The user's slow-mo shot was tinted") + TEST_ASSERT(field.time_scale > 0 && field.time_scale < 1, "Test fixture: not in the slow-mo") + + var/obj/projectile/bullet/user_shot = allocate(/obj/projectile/bullet, spot(1, 0)) + user_shot.firer = user + var/user_speed = user_shot.speed + field.hang_shot(user_shot) + TEST_ASSERT(field.held_shots[user_shot] && !user_shot.paused, "The user's slow-mo shot wasn't held") + TEST_ASSERT_EQUAL(user_shot.speed, user_speed * field.time_scale, "The user's slow-mo shot doesn't crawl at the field's pace") + TEST_ASSERT(!tinted(user_shot), "The user's slow-mo shot was tinted") + + var/obj/projectile/bullet/npc_shot = allocate(/obj/projectile/bullet, spot(2, 0)) + npc_shot.firer = raider + var/npc_speed = npc_shot.speed + field.freeze_atom(npc_shot) + TEST_ASSERT(field.held_shots[npc_shot] && !npc_shot.paused, "A surging NPC's shot wasn't held") + TEST_ASSERT_EQUAL(npc_shot.speed, npc_speed * field.time_scale, "A surging NPC's shot doesn't fly at the field's pace") + TEST_ASSERT(!tinted(npc_shot), "A surging NPC's shot was tinted") + + sleep(3 SECONDS) // past the end + TEST_ASSERT_NULL(window.time_stop, "The dilation outlived its 4 s") + TEST_ASSERT_EQUAL(user_shot.speed, user_speed, "The user's shot didn't get its speed back") + TEST_ASSERT_EQUAL(npc_shot.speed, npc_speed, "The NPC's shot didn't get its speed back") end_surge(user) + end_surge(raider) /// A surging NPC that dies is let go for good: its corpse isn't taken back and tinted /datum/unit_test/voidcrew_cyberware_timestop/no_corpse_retake From c0572b605bffa755a7c87be87142508896159067 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:10:45 -0800 Subject: [PATCH 13/33] Slow the weather and the world's sounds for the Sandevistan user during dilation --- code/game/sound/sound.dm | 8 + tgstation.dme | 1 + .../modules/cyberware/icons/sandy_weather.dmi | Bin 0 -> 85117 bytes .../modules/cyberware/ware_sandy_senses.dm | 317 ++++++++++++++++++ .../modules/cyberware/ware_sandy_timestop.dm | 7 + .../unit_tests/voidcrew_cyberware_timestop.dm | 91 +++++ 6 files changed, 424 insertions(+) create mode 100644 voidcrew/modules/cyberware/icons/sandy_weather.dmi create mode 100644 voidcrew/modules/cyberware/ware_sandy_senses.dm diff --git a/code/game/sound/sound.dm b/code/game/sound/sound.dm index 60452addb21..3e632619c9f 100644 --- a/code/game/sound/sound.dm +++ b/code/game/sound/sound.dm @@ -200,6 +200,14 @@ if(HAS_TRAIT(src, TRAIT_SOUND_DEBUGGED)) to_chat(src, span_admin("Max Range-[max_distance] Distance-[distance] Vol-[round(sound_to_use.volume, 0.01)] Sound-[sound_to_use.file]")) + // VOIDCREW EDIT CHANGE START - cyberware: a Sandevistan user hears the slowed world slowed (voidcrew/modules/cyberware/ware_sandy_senses.dm) + if(length(GLOB.cyberware_time_fields)) + var/sandy_frequency = sound_to_use.frequency + cyberware_dilate_heard(src, turf_source, sound_to_use, usr) + SEND_SOUND(src, sound_to_use) + sound_to_use.frequency = sandy_frequency // the datum can be shared with the next listener + return + // VOIDCREW EDIT CHANGE END SEND_SOUND(src, sound_to_use) /proc/sound_to_playing_players(soundin, volume = 100, vary = FALSE, frequency = 0, channel = 0, pressure_affected = FALSE, sound/S) diff --git a/tgstation.dme b/tgstation.dme index 2bf19a31c90..e628c7fdc90 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7225,6 +7225,7 @@ #include "voidcrew\modules\cyberware\ware_pro_combat.dm" #include "voidcrew\modules\cyberware\ware_pro_utility.dm" #include "voidcrew\modules\cyberware\ware_registry.dm" +#include "voidcrew\modules\cyberware\ware_sandy_senses.dm" #include "voidcrew\modules\cyberware\ware_sandy_timestop.dm" #include "voidcrew\modules\cyberware\ware_second_wind.dm" #include "voidcrew\modules\cyberware\ware_street.dm" diff --git a/voidcrew/modules/cyberware/icons/sandy_weather.dmi b/voidcrew/modules/cyberware/icons/sandy_weather.dmi new file mode 100644 index 0000000000000000000000000000000000000000..0b424049e700ba56c308e62fe91e8a61822b9c6e GIT binary patch literal 85117 zcmbrmc|6o_+di%+J_#u$dnoNx_WhGJ39V#Lwix@wV2nvZizTUKor=gh*_SahmLl1* zj&){a84PB`%rIlj_dVVBbN`RfxW7fW^CxZ~VB6Nm^GiGut~cq|360(_cL5Fz8bm>m1?xR_<;PWAC_ zWb_He!sE8B)`UIGHx*;QU*v($@Xnt|tH?-y?+!fswkYcqTX6#Egn2uD=jzNp=Z57q zzxDMwCA)E#yf^VNo{_bW5UlDc-qfO>`}0XN5Bbf4+3=FeE;U8Bho-WlU)&RqBt}YI z%8ub}dEjZkAThm*g{MqJ6^HZ8E+23od}Fz> z*U0?9%=MI08c&{|Jfn=?b*kW~^Pz8h-9(2fDt$NhJ~0pS7MDJ)A@D*$MetQse3O^A z$IyvG0T%+g<>TJe=(_UG$7vT`Xk7aqCG<`lqx>e&@Un~jx{SNA+e~rDPr5;M%s{Ky zr};1M9bY;|^_)sMxO>Z^RVXg0BqPac9`wHYy1H>rWm?bh-##CFf1{d0-+y@fl$MxR zzA%%@i+mb)(y#b(`oi|7dAa)`dDccIBp7J=uh>*!X8vHRwOVZQ6`h6fos&{;A2!+O zTwF9!T^P-eEg$gqSxG@H5#AUMC2K>)S5b02yLfmkOfKIJA75riGV`ySK%>OCtyreU5?aZ(TZy(Xpn%rvF1dhaL080xDuVXcqt<)VPQ~uNzDvxtwY8~Rw{GEh6H`(s%F1HMGLCidhwzmO zj>{S0pZB;=%Q>8J+P*ELB6?P4wh`v~nf*t4%V33c^f-FBO9l9m^U8<$mpJ;$c{`H9 z_Owf63cP`XoU-#bsA<6Up$hWGVZ(i&TmsNLOLIdQR~Dr{nv8TcMAj$!B8H;}7i5=^ zGVpj>pH6uflb{h9z1H`ZMPX`>7!F1{Ycf|t`m_w{HrK~1U>#p&44Y=)xY>4GV3hMW zob~qB$|%PDJ^=$mB$Bl-!oR|U(2!+_^c;p0zPArGK=G(X+w4ks7M-xjTQ8iNlhqgb(^8cY-mXr< z2T$z5m7iv=>X9@0m`~1Emt_p4SBut}2_US#qD6lN9tuXXAuGB5imPWo{7O2&m~%0( zHJ+SN(d^QGzWL%ZcfjO9jV1gk_KknV_0NI`@oe})n_OdQpJEdMJ--Bs9;65E(RtV* zon2_KQiQ08t5q+qSmgU93z5_iWrQd7gwU=73+j^J0lqck1PTTR&N>a020NCu_nPHQ zTn!CN%0>LQ(*ukG&!iI;c*?6+SG9M^HG3olZo+ z)tP(ii{LQATBC|9=b_Bn>j6T~jY!JIpbf{b;;tUg^>ar=#9n;kotc?Iz4-PkCN)Ws zBqshGm+U94?Hv%kDjsJvU2XIsXne6SXJ;8If@jH%T^o@b(+UxmH~f8e$A+h}VL5GA zc|COFho2Hze6JvBq};GFcsBcfMR>Ew_O%b!wS?YZKb1|-KWVU-Xe=V#SXqBt`ZLFR z#YJE%Rc>K5AGDAiQe1IeIBazSnf~4 zIB)$60~x^3+s`>^OgOUhT^536injJ$-vAzRrvofxmr?ZV9#6!z?_JY`b4C+UDgZNHTbI@5BB&Z$gqSB_jlotrpvzhDYItBCk3`{1Q%&tX9WVo=hLUg zoyar?s8@}5=(UtBqtjWP%rPf=XAP?yciI0?*bV?WVb{PM0Q~*@# zR_)#X?jUTf0_(Ko1b?FFvFAw9k!1;$!{t-m)9|w2>b7z{dI+yvcT}Hts=y}2b(@t6 zraO^Y!zSUk#Sgyjt>HarNXRd&bTthgoWJ zfMKQ{=5uVd=G0{;Yw5KVLtHg*uEAC%z5WgAwj6dLC%q8s@E@63z$QRAFp)D;PPB1$ z0dibDo3zsoPRX~QsdSL*%(p+`xT2U5mel2&__k!q9HkymGCcapd1hpPNm-I)-JI|8 zFR}*kV%T?ipd9R2)EiKfe(~S2hLgVnM_0TrjJWGep^dN5f30>HGcO*j4Ffqny?C{@ z)z=3AGaOFMC_b$`*s-YHOPiF=_sn8JqQBEIv!4)- zi@MVp-mv#lt2J*(HL?iTn{5%RfqIOm+=!3k_LE5=PaU^)LBOV~{c=ee)o6PFY>#VE zd%5#h7%o)~+i07BJ6BTX{}r8@JH9eA9w0ZSj%$EzcOZ9$>*|slI|g@oa{e_FF1PxR z!yV2oJYS%@LMv(Sc2oQ(>(?wse%9`N;Hv-1A*Fu!K+T&2bxcb2<9eK8GKF*RJcZn(k6&j*>fbe;_>Z|17L>zV)?I^QSDuF5#JTjPP7Et7PQ5KBB<8lM_Axgi zp~<~q_)LR2fc5rC!vh+>j+k!)%d9_WZ&{W2cIDV=ut{W@{_&Z<--lQM8Csmp!h+}Z znnv#&FHhRkvpSF%PH>t0>e=DZvA-v!*qB3~p-E%4qL-8YCs+6hg{y4PRzH1bMdn&c zGNS*B0Bf5JQc}O1_;zOOOo^vt>8r9z2c>VP#!drLtlzKFB0<%*9{(Ql!D@~OY|}WsEW!A0iU|=~z6wRl&7)^D<=`Qi>dIUK zfRlf#YjiB79=!ji6dC9fKYR&*U!RfsQXn@_9cJnQ9llexpMkTVbKY*IR=UJ+qPs1Mt~w;B-EsDgTlx8R$_tbm7oV+^N|4hM2E>XUM|vNzW7$gaAWKLoBA zutpx59XWa;-MBr?XmaNBV#s2skHYF&#EG6W6IbOr=YDd$SoO8QStv$TCe^p)ftGET z=a_4N0Q3pGdL-2K^qcGdj(mpxEDFwm`0xu7Vp#AFLT2kP@#LB%S+d*vmkTmHU|qg6 zbsrpeKL(%fS6ci7aSmm{p#1bX-^pKOg=lQaaQ!z>3-HXbC>fc-tPcZCN}{*Rs&fZF z|3ShA@&81^>Se&-LVc|dg;G!F4l_Ws-jNRxi*eXp^f5D zcVwW4I;@(G_eiD>Gp`ib69%ryb&g9976;pxrFfLZpDzP2Jo3s27rg;-mEwVdIi;5b zg9=jodK$ihCLLC08gC{!mYD@MNTSrplfGc5qI5s2^8Xn1Jd0CCVvp2CqDgdL@D&tY zN>IOlus+3hsc*twE@xSnqDS+wVJBK~LCzb1`znX_8$^Je=6(`w4=^I*N5i03!gdF{ zCMOO40TsLA%a$M8&l@1;n0-n~jhbD46Q|~E{v&`^ESnOcjHMSScRcxyCTN8`wM6AA z_r7tXiMPgF)dygfq=TT77efl5l-W(=mnT!S zMfFtXCe%$7C!k zgW*e99O;gS=7#hZ`kVCU5U`t!ylRk>r2NOE=)of;vpIFwhL-Fc+-jVXzaKyU71kgr z1;p8@Bk79zEAIZcD+8geF{oT4WoiJNSKcK*?DJujMu0Qn5zLbRz*7HltZ%bAGa@{1 zCNJ=TxPOVn9aY$I#KzZ?ITuy+*Wu*;-s1=Akh3Er<^YZYNKH|Z2`V-L z02t2ZP;g+ifuG`PlEdp!qe-cnXBA2RS3V!+&yFU8@Q1qRpP4qN818IVWt9c{#NP=~ z2l$9Yd*GV+bfTE1K1>(~VBskoVCbGdIv&WGHA$59u~phrI9m4A-;*imAvJq<++RcN z8=+ytpzlD<`QN~KqLSlf|I_i=1C2xWwG!$p?Wg>>65@$>E^XIDh@+c^x6Jx(HI_c9 z0lPM-So^cq>;WO38a;OBkA!oziJ&|mO|4m z&xR`jz5iA$&qF8}DWBJK$w3LgzkL8Y6a3{@Q;I4|jC=e6lKcwi${L_{`UfSOPJzhK zGPFn8{}>rC8dU6ft;Ol3F6T4H0%rA-GAUlj^!R{@r0v3B8!jN5tKJBAv={uJybK&! zdrLVBAf6$Wa)2PBynVKw0{1Njkn4%I|5w;2NnPN)4}HT^Wf60Tn3W6iEF+ z&6Z3_@I+A58SZ)R)%k`0%qTYfs_)_sS&ncpnG%d^^&#l=zTdH^`tjr61C8|Jc;^Ps zn>Vsw4hhjFdQ2*DH*r(vYZj+^>vM;+W-`H)&yx)jWr88Dxe8XgVZ7aaJ|RRfSfXmy9P z{}F4Sad??XxNhpGOUf@7AYcAb4_9U7{VJAsod^m*(`-u$ITOnD^hNR=pPtEyNG!(% zPjzUZL|Xvm<(v7BsnO8awg}g8f=w5kQJE990&-=oi(@yx!7K4NrUR)Wcb;&zuQ+Xe zSzP7^98}HP9%1g>qw$n~=r4ioV!>jqKs!rPV^e)rDn|xV%s9d%eI!;tvhQ%;fSb*_ z4H)*_@H=+nBPR@G^a`^Wptn%Qi2LFaiXG6eiL760-`aKDCx1%E+;Kh78}=|Z7dN;L znr|pmlhfwVX;7|=>bC+l4HHK1QfjvuI1IhUkWx!72(__Dv=y=I>iLu|i~vQrkb`%6 zy20lYC;={%DLQ_roU=Mtb^RdDg2gry4fW4VqKm{aihql4gb);)U>K%N9 zOJwwLmL|11EGL@;iWjM2qz!^JO|)AR#b(S0s?<{-Q|Nh=yc~i0mtdhX{z#0B5$efR z2l2(lX)ZL*gt}r}`WdoOtIwN2ry3hD9aW5`-*gIs)8Osn`~da?-bF#ubOP!=^7p*I zPHrflIP!zebrbqKPu()aVJ6D$d_0f0cx69i_eYxM4t_~d%VGFN26*)?&=xLd(%ham zArXv(vo7>k6F8{Jmy9+m%@GU)ywNb+#&95|&Tpp~Y?RYna1+z0476w2j_zNav1HE3 z7X}{MGmLAhI}2)Nedu`;zxR1Fu$%hk#x<16zl4OuVsI|!9^l}Bha^yXj#>dj>(T0kpvz2`;wBMDNXdhXfWJ$f`gDyQm*?E3dH4!7@J`< zr=Kpebj?ghl&Wlw6_)=(Dlw+7a$zUiefF(nkJ$f~{ubA%o#vJmcp zlFFm>LS5jx8~m4LmRQLv7|KKW63jM)CHgyGgy6H8xVgSGyY6TQYfk&ys8Pb|#zUSD zFC~{)<)L{PiX^$5?(8@7{J!r)I01rYlu>qOHm-+#vP4w$g|4?!=nGKI%CljP8|XJc zt?+CAbpiuVW?^y>X~0D2=!tDmp-60rsg%_4YsjAuK~K`qE|IZFPhm8kfgWV}++_WX z#4~V%(ze!qN3V9=PQ z1He%cA(Zq~<>=?(mI%0+h4?mGRnd;Hr62yLo(MGQ4XhuSR>rO=X_!qbygbzP8rab4 z!&@e*G)>h~bE}96wc~TDp%_&i`1tuli~@BWtadQv3_a2$1l?nsm8X{w0T(57Up{R#=y^?f5QleBhopw z2S44}a5gs1%VI_{W}w?43{qGGp#cJ^3@p71;*K<8RA>5jnBffLj*Gp79b%wWQ&C|e z>CZ6`gQf8Rj|sC4v?~(>$>Xff!-|L7AcaeaJ89$r`?vLhT@sSYlT^ql74NBf&4tj)^>dWW!IW#hIVUMi|4VE6QzM-dfCGJ4Q)QA^= z8A*Tg*p>4MX<(Uo#o$-bcU1g#-8@?M;Y@iI225F{EkLW*muIJ00Jf|yVk&@>1w)S- zXNFGsAdMZ#O)`$Rfn({JYx5CV&%_jpt2>dWb)P+*J*)!lTtQ2_1NxNfeKU~2FwQacB3d)#A_M`6cWrpxn}o$*GS5uo~K$}kile=9WD`4aZ4d*-)X7Fc*6 z=UVVoH1J~a`&y*cn)8Y-NIUB6@8pjZ=lkM4%XTYFO7v5VHv!zaJ7*Duzq9ipkogEA zrSr<`__Z4vKr~lS@edVYpi@7TWPmk}Lsm!w{*0-#iIi0A+-jE@LGzr+zO>?;TYsXy zq6<3&EWW^zd*)#J%ibk23L}<$6>mgvmm?NH(st zt2%76-K<1|=0_#S+izT6UE7fp5#xXP9?2dHU^fyBU!w@Q=ij@<)S0-K}d8?o%;)NQJ_CS z3u*UUDQ2n7E-#J&i~voIh%QT@9M5F{jC7NmU^e=%&~F=810uYahM>hjQNd$M6a{jB z9I|MqGyJz`xLDlq%aoUh&$1X~12dDbJnvoM0{T7`eIE}}iu~CliaO5hJ+J8}jTu~Z zoLI?vpR~Y<1oJKFBDz&ASq%(9Ig>)a)Bd&$mjzvdU_Yl?p=?DK)8aB4QPzJ&>PC0| z!=EalGw1;bCI=m@-vq9yQW?{WLf%14ZSK!(IBD9feqbHV>;>)I$Jh}f7VV5<4Lmd` zjPG^K-Y!6@{Di@pvo2eYn-;M0DN{1+13hH3sS-*Pvv)8KrigRQDFI%k^>fq|yOwkb zGB>bcis2zelx=XzA!-F_8N_koSN0dVy@F9{i zsvqmt9im=W;$U!E!rFtjHTHvmh@1xB)}B&n*VY`i~5{h@4$Cf)NHYTo0;;`v4K2q-Yh%q1L2C!u;Yn@9I3z1xLNrwXV`P8zZ z!y3Zhgn*qWYNVJk{1j(u0`LC(H_b4Iox_%y_#6fz^mCyAgP)p3IMH%Ifw|=V2xK9t z5JSmv6`VR^Z~G4pXYBmgzDZ#8nyilNJDp+Tw?2bVss2*^jOaxcK4}M)#VE2BA>Q1`u!(1=VNbm-TcbHg`6(QP9Rf zKO_)e75YmrXUI7D=ZH>V#}Pm!K{7~bJd|OEKKk>tg9yRGQ)Ffldj1S)~=^CiA>|-VjEu!o9 zkje$NnG{y;27VxklH0RMu^vg7_-Rkr*j3o~?i)AC?tsiQX7>O5$%_3Lnj)R}k>LbP z1t=!A)pS$<+2oNz?M}S9z_&XAyxuJ!jW4)m)?hC~Zvidhj4W)FB zTJ{|X2jrQvT9s=J8OxGq=EC|;2tF)8ZVevZWxf!RnR>x=Y>46fUn=ob*2DJ;C|^Pi z!m>^c@UpvLq(&OXLEGt5zvf%zl7Ei;|6d>Yx7(C<0d?iEoAM@iDKK=6^1z_6%EJ=? z)&S&}Cyp~d2tyx^pg8O6LHo`Ia(1>5w>ALjTjLtmKRIb#&2LYFks>Pt{Yrr9I@@&s z=Kf93=3{BA&#D9v46clT)SWLc#MDgPWm!Llp`}cBL(f01AAzoz&TrMoV$#C#efagO zfFPRHr(K~tEH&!tYYsmG0Vw~{Xz}m9Ykk0A>HyFK`I;0u6APWP;W~f+7TTsx1$^*-%(&Hyyhs8K^0BOO;WJr(n}Hn`~E6t}Z)oTs}2C$hV=6F0EU#SZIg% zv;x`4xu8*zqIP!3OB-;)=r}V7T=i6946rVl6a5~XFLW{P1BRaBeKm7LNRB696Lw_X_gG7auTM?eb2o? zZ8_D~F^BdN30}l3QgQ{SWH9QCNW=a~Rd39pT@(8bXkWncvAm`^IfmCx?&5)r40QC1iD&7ia#a~KKw+4{`N=i`6Wgv>}_X6PJO_asiNIMrp5y&d(P&TFQ-=tLEB zC6cDUp|Vr8faAWPNuETHdm{M{9)CQMG zfVt+(Ki};q_n1YJ+sb{Ry)Ema#=mkhxj9B^{;A%fDPbPDQ|9w8F-MJ*1feQw;-gUZ z{`xPluiG46Q?1)-1VQQW@uN*e`#q$M?sb@r>TG{!$ezk1_)9#wWlnUCPRBhG)KAB# zM?(Bh9G)6(yaQ|~WGsokM~HdKsB{6PKrLNgV0HBBt1=%5)Az@Ux3EpYM|Z|<9R1$x zdgR#W=JKhTWvtutx;N8LE^A#7l&VT`I!S4H?=AeBbiSD|XH~t`@=Dv!uGJiq1)iRM zrTrdz;!?sdkVf;+K`~f;oxqI!-HjITbesKX#E2FvF#8upqj`;&+GZi}(Y)OL#k-VQ z;&#O5#P00sT|2a=&lc7fj%mf_YA>Un!UB7~haLP5(=;UvUj48_%O$XX)@dYR)-7aK zhad(r6INvE)Y(KLIj$e&n#Ulou|ELLMeWD1DakEzJj?f?mfu`Z&4Ba}#05suttr&= z7qdp~+u2V1YbSJ{?3oth7Z+Z~r3n2^Y%A~MwVY>t5M(8{(p!d+GKQNYa*u3e)%X|c z&cz(c$Ub`%IK?C0 z-R5|fYAH26eW}}4R-4Z}RoesCJ6|Z=0yW$d74cX+I`m-Q#lIc08&EpN6XIYgT!0L~iI?amxO8x%)5YjBZKT@}j@>Vl$={4z#BA?++hdbt#VV;W1W9)et}vqOpkCa^91FYCp=;}Hwg$0}Cq!+(Y2PQ>3`ao0YyhZ7`hpm*=exYrLV z0z5x%n7daG{B!4l><;_Xq&7%+G0pOVUCc~~mjPV0-dd5kBz1Pf$=EnH`C`_eI-T6m z6#&#sT$$oJp*1r;C7SAfv`@M@X%oS@(31mx89cq#HcwnKzd+20CK%h--xChQJj_PB z<|yngmyUlXeeUga>z?o_z3rJX_n`g_#XSU9S#L_xEuQ7b{(3hzUGH%*_gi)^Wp=q{ z6Ej>hXCZYGMzzN-;Sg)naCYxeEn#TjyH@bDh$U6{+L`UY+w*HEI-vd8->a2*!*cXn zcBD>5K<_DY+(fqDpQ%iL2_Dj{EzEHLfR^lue#SLo15LEkauMg}BLIN}BS3>FK8ywi z6EK~xIO$bR0YTz+7i5^R?}cfwjxYkG@#P)tAr=QQsnIrw)e<7vtgXpiTeIU_>Pa&r z=F7iBHXH5H<3A%hEB%@8%WRu^Uh}J>k{#Chpzz)cuT@@82mYp}sK_SRSywTl(sTkM zBNCQ%K(C#>Qz|LU)A(Srnv>=}SIJnFZZ{^}Chxn-d=bQ|M3FCZrffpDovTJs{X@WBXX*SrG;X$&WX(Ggmga?hk zXpi{)qWx9UBSEwsAm59979z-FK(z;WG8=bRP^0bNa?ASJfWhV?G67!8=$BG_dWM?g ze_gqa{UUxUs$f4urJ3$_wEJ&-9F%00g%r{L0v3_37ckJ=ZFv}cuP>7NjFi-JwqOim z5JmAA{LvjuEPzfIfX(C|L+~1H5BpzM!PXB_{Fd7M!swPYx=u_ zwBfzczO$lHzct&5k5AKcMC$xBMVXXb9+l1Ci4E8T7)f zos`AKVO-f^zkC*9xKFl;+NG)&tf+Tg?ev;( z!D>|Pe*UhYVWMK{mb(F(Cr|cuj7avyT`CAZx#$@^Eq6g_Wy#+fhL=KT?hOnDcYBMx z8GrhSVM!QuF{{UQP2T1@1mu{AXnl{?YAJ3N*{A{ZIl1MUJFpUw2(tWc0^leAfSr`d z@PCMQd5VFs&}ADr)|)BVDIO1LgI_TsiSuqdv_1+UMbfGQz0^= z#EhL8OZmy@>7zIJ2>AVkNxpQ_^z$Zp+bl-Q3!(^CvlB1KBZ6W43XZ+i8cISwT zmhg@5x&b-UA6{uwmG`7<8@MKU1j;-z67J1;?2JgLbKNU`_`|_g)Q|ZM^a+uK^lrCF z5i5%=*LmVbRmT{o<~06b>*==nAic~uIh!{AsxRxM7x8OoAEkZNz_bIV@t@FP*X8=M zc4kREExjD~RSH|Y z)PAr0r^n8)n@VsypumRk@U)3>7apJGu9^Kt*Z$&FM&9tJp0Ip@bZKHZ9Yx4Sw9~58&!U{ zxA#cHRC$qTq{rQ#a4jLZ?&jXA_MT@GF>F;OA@tFA*s~v=eKEXyseeUZs)XIHxVinZ zfld%RMtR4Q>V1cO5o3`g*F9v~Jh(hjdZlTzWM3cH|0N|oYIAwLo!I1q4=yUoFsStKEpF2(s2w|F?{FkYFC5E@D!%am zqvmC_cu%>RA}e=EmfV_v&~0}X zRPv3&or|JZ4abHExCux(8Q0JAfQRYMFIp560-ZMB^P_rY`T2p{72z}I2bYnT3Mrg+ zH7M3VNZcBb57bY=o2{D_Z46*rTxApdwKs3?HCW=A6HqwX*?fO|G)AETr?2e@??5$dA)Yn_7kR;lrTBjf{F|9A;Zve3~#dvi4 zWMlU!U31cZf~6`P>c%@=C__tMd#w}FjYyf5r@G(sFKu5s^N39VXPclj-C@2T%uH2?!tfxZG&~+4q~iNTT4P?^qnwQ?ZOhtSU%Fcp zeSd$sRIEy3Ml9{*bh1zQ7lA_&;G-($*MqvzCdyG-u1PO-Mbk0D)^e))SZqb-g+hvi z2LEricndzzrh?;mYnesA&7tiw2fAWY13>AuaW=>I!6~3ung_7Y4FW?rF>9wIqkro~ z&^Mmtqwz(ihdje%j;;|;st!Nc$o;GS-a*Wz7QB?w813Ul9(pt^(;3zA{^0M|`sQP> z$8N@kL1Jg_v{sdZ6o!Qh;V>O&@)N=j*9C=MUUY+%BO!k!$kX-J9dV zF{O6wsuvBH<%@aN1h2jqEoG+8bMN_J!v50}G4^e5g_V+EQO(jjeF#ndkX8-*pU{Rt zzq7qnj-+@0i#uy=F5X%QHYQ`Uzt_7b#2!UE4^S>y0L%gXD}lo zJ`MLIB1GC+_K58&G-uVG`CW>s;9mNGT{bqRh@5i39d6^cdQ$MBFk=YF zIwZGA^5^1#c|T;?>@74V0wWI4kVE>^K+Pvd76zgd(&Tb0%8u3RAWV4Pl(zs)A2n5F z-8s9%!k7+Oc5YZii8r2g_Wv|*Af7EciM4K|y_dPmT)$GZ+z?<8K zFr|7A$F3W_IWBaK8Byy{#cs3EVnaGkGYd2s?fah?TvKi-YKvFv5$Opu(<4TKH}Q-f z3ryYN06gDB!oB;=`ELyUyN9AVQ|o38a|fhpR$_W`y3Q$HJu$iz$w$-pVFluI+hZG1 zGG?vS0YD7?(_%+~4Gm`+r$ki{G355}5%I#Y?1)){0zYQ$`CoJw!)K)CI?arQkbXcw zU-r0(>i}QKjG0Jz2mGyr%A;$tJI5cn;qoiJWq>jwq9Z?#uwhDk;od}L`s7ykOV7hS z4|TtFtMn88ZvOV(>Bm_vzBdV2yFEN!KlWY_P#h(;SnPSXF?|StbUeARbCx(2^K1b1 zj`i?@|BzW#P>^UqRHqh}{if)RBGKp5PrBv#)kyd98la=Ha>eat4fs#ThkH%pBgF^z z@K3dmOkn%f7=Mj?cB~eRATJ&pUe?1!ZUWVpDdE<5ADr1EL3b9Y7p()xOGu@N-emwu z6qzG7<4&$QZMGhmRCmAGWf}AAdO~W^VZ`Y+cy~*X?5JvQIjC7hsho! zVNLYI{TQ|Oi5;KJz@~?n*$dEboLt};))LH-B#l#Ww~4gV907b;%IA1p-@Yf ztCfm~LYhOg(_WXLDK%92Uie>l$E4O2FRLeT_mK-#fXQ`wqsi|%7QXh*w>fSZ(yu8X)|L0dkM!q~jei0#P*%$efD_ zo)=(kkX-d$Dp|8R>CM?9>9*Z9HUxcNqty?Ki&lH-(f;G#gNTB$LDe3@zh4YD{uUT~ z<<}h7;9JdV6i;m1y0wfxfX|A#`AvGD#bg+g)XIv6?UxRp@E=SuX@+L7wRaS-xp~*n zl(!rX!>}MkpMzQBVozkxS)VJl2T+saLg({DM zuaxtxeO^NSfEZzR`JEWN@n`RCRR_c9=` z>b&%*AYH8fOry9Nr7<-5*rUZt3$pBw=7MZDQ8;04{%;sjVX%<(uf~azq>NbshLxur zrN>{m(|amgmlu~DLUdGZ8csMVobiBh<;ag_SHnPV=jM7c@sPx(;w73)B zy@|YiGJB+RE4(vHI*2Lh#q3rYYRpX!g~?<=xUN8efUcoq&O3gLq)2PBycY6q4M-eQwde8kR5&e%8k=s_+>o}671+_@(cImqs&Po0 z+W&aV|7Z)~GB(WW-pV%cn1|mR8Cn2(E&CfKc9Z(ra@~$SA!Nlh$2hCVdIOdz&u9ew z=nWIiXz3Zjy;;||pLR5?ph#C#0A5D*jW}XaGgWj%aqhnA`W;zTM*J~8=BnEQRnQtyiI)-p`m)XEnC~wbFBM5JyFE-9$;<- z^@;_C3x(Tn%9$#5vG_lk;dbz7~Vc&xGQ-RmciPnzQx&>HkW z_gp2erQn?OIf;|@D*vnlE=ZrT4W-(Lx11$DNurt?G=+(_qs?{kJezm%-Lz^#`DzAG z7pgx9ZI2Q0zDE-5klz>4QKqVQ`x3oLY^ambtvaf6Z-m`&>kE0EO79|m{8)j7yy7vx$&^TlMc;7m3x>GVE(Er%$Xo`sDVAxNOcz z; zkP88arPs%dPvmhl-@1{$H0&5#rhJ#^%5o`^lY4Y>MOIa$)HjWX+pvNMKEmB5SQ^QN zt!S`j#5Ik?T6sU2NA-%B@g3akwX%M(>lo{?8 zrhlwZFle0QM%kji6)?z6ARuB1da?yS+C^4b<7%!)`A@2W!tV2E%umm=i0;~(caOY{ z8YQ;=%kj*@*Vl}eV{tY+Of9Di-|*|rJ%1ks@J?#1{tJby>ZHG4Zj&|+ zwHvas_<;Y=HcnR78n znvTB^M{1ROOO!ckHW!mv(eLb?6mzJ8!IV&>EdNC(Xmw8Tg11DV!;HN5jT{|nnqbnt zFlT2J;i3z5*e5#l&}-Pc6ymGO{u9IO9e>Mqq%>{%w0jUqI%<9LB0btuvM;9Yb~lRg zk6sNXbG@5k_%aTgOsqoe0m_S=K&#&s(oGFcv^yyCmf+3K^umbWvZEX*^=)$#$voBm zmRk)n_51eI3lpIQ*Ee<*y<6a7tmf{al=DjvhUC z*8O8T-xo5tpf;RSR~Y_61njwvSGyHmEho8g&vI9<<0EXBl#GGXR2!MVTUEX$C4T|! zG2iGtx;e>uF%Mj`H1Ba>c0G=eo2Y?ivo)`+)n3ZJ@Bm+?;br&M#sj}K^Bb(J=X8nm zsqVI`b@>?2Ez5a{DMvK}_kK<9QW5VSRP&Bh;U-EbX#0`dL6MKv0}Vr;BQcq9xRn%d1z*mn?6p?Ga8ztjSFw^#jftD_ioWU z%U`9EeSwFg;XcCI0s3r3db5{;Y=&LUnrk1A1kL#}clI~OS13EHxy7)O(etv`1c zeCZE_R}tM3GPcF8xz3kJC2c3xt-(uUdD|QD5<8zgHY$p!3!Om2Q1vImv-#Q1y?Ugm zz%HKMJbXL?JO_A=@%+v6hzIyzO#Z(I&3k!>Ch5ZFIsb>T?|`T3|Nke2vZc%jNtBVj zRYtawQAXF6y+Xz{%P10(?8;8|CN3#^glrjQ#Kjfv71zD@|Gu|=pHKDu{r+E%`*^rq z_nz|_&)0sPdz6VXcFHaO&-dX!7<`+IC=J6)hx9!5*HQjDtT_ph8_JnikLv&bwkLu_ zvuR#(aSlZP|I^+jCR#Gfqbnf&>ok9{qsB<&HjO>QHT-|^`s?G%hlu(<@E+)JD0ba! zmOte6A0(VMBWjLnx_Y3u-Bx5d5EG3FYU01?=s(VRiRe;2QSes%DdpG1|EpI2`NZH6 zqO?)DjDsA`bJqGGq6@#T{oiS8_LMl7d^5EEtKe*i%yHfap{#GZ=s@!L=6{g4^$?r> zH$Gnz1y|q>ABeOD-2+*}TcrMh4)@?-@D&`t{9nZj;4AG&aS$8i-3uEy5RlAe4|2Gr z7~Rtc@es;E!fbp;E)(4+A}6Ljbo%fm@?csbPNFNnzo5n#?Dk4Cf;;E*|Ka9-Gkt7A zuwAq)>951noKgS>5#sQAC}X+YFG9(3p+LRRTW!3w2a(C`hOmPi?zoxEf!rJ_ zQ#qUA@b4+yf2akYl8o1+tMQTlo9F$>`|ju*#K8LC-UrkF*1ZEAE+ir90M3<(Ug7}x zZU6WI<@?`V=2PNe%;Z5Rlk%^jKZrfNLL4MD6ee@~Z+sSjJRW=eKz8I9QeOjVAm+)PeMC zk2$0Rx@;Ih9WB}?#eJxDt$LEpWOMN830R=srg5Figr}3i?S>sIbTV>J&Z%E9#y77( za}t$hF*%<;f^knN2H~N@>BPEv?Ncyu-f?1)Et2noqdO*+rrT>Xn(T0D0cl#UQA1B*V#|3hh!z*jm%wb z+PPH562(MVM#rvDdlWtPs_J)bSd-vw8Z(L?@4>v;PPdgzshZZ^-z${PwOtzS@-i8) zqqh=IZ-cPK2e@lmX7o-&&Zl~0!7CdIN~#4%{V)>zA{vuXr;+LSdph=$Y1(KP z8$~TgcJ)rDkrAVWnu(T+o#Hd=#;#X0H*x_?O#-vAg}uH?lD1oop+PK}08Ci?b8wVppc?u4>T0VQ|Y8#c~~g z@@owgt!Iy&&3+BlA5+cdUsmfk<~Atm;f+x<(DN(8K}C1!PLh>9oAKeNPqIFoHJoo; zalP!!PQ4+Q?OKO};Z^%S-rf#PS&tEV3*&BMd)wZca`t)L!at9pO`HpD%JJ{I#hLS2 zM{fK;G;9nID@o8F89u&6*rz^+CVx3filBR(zNdDVwAO!GxkGZX$IiEZf6W|wfiyh2 zaERzqJRh4n^XtKx)ofUmgT8tmEN7QKcUv+CHXc~#dKD+5e-6`_Iym$1X%}!q+cEgI z15%V?F2`tQ<*pJ-!r+Y9Q*7>bjZQYqE7UsI>=0WHj1SXjvUVE_QL0bGC4mQ|7bgqS z_?0Dz!(iRINXPV6^E@wZj-<%!d9F|=3lY`QjU-DBD4xs1;81z&BHTe5z$Z`(7iu-RQ&G!w?`ly+!9 zyU%FZRU_!e9+26fT2Pkem{B5jBy$-du{h?WUGfhc0Wh_32xU-*M6$KsTO@LMkK2#1 zUr!5*rikT`ATv@7CGOuwvv<8a;#stQ-an^!c@r zy8Q_G4U*??7Y*&?Ij;kLvNh@r_E@25#f5lm?1Vz%@j8ut0)8SOIK*3j-T_IPmQ#?h zSXd`*j!j78)w!jsb4OdRMVTuEN4)`GG|+MB@18X=^bCm&qrxuyt6K|$f4?a7eJhc7 z4=m7ysSSG#3q?>v*aD#96;pBM1Mrz0@|DsrG-diEhO; z&^()6xjyE@d5<~&@Wz((R&4tTbKG@LB98v#i8UpaALDGM!bjg)9^<{w{U}$`hji<+ zF1C#cbj_Q#=ANx>?3m0p@HSI4$yrg4bDD}C66N`kFll>lk1=mcjI&m{Uc$K8&n{-?sWvLIN*SKFR)1|~h2Aqd5^3Hg88N5JI9u7@I=wX~Ko=lK z15+e}`O0Y}5(fEACE8mL+=ssUPe()%k3Ek_MTmM$adfAIZId!iGhNmXK{$nZFt0gaC1eoo>)5(aisDQ{ow1%mVEO*OQA zOd|Q2LNeXz)du3NbNDjX+bnVvOVbMEHP2Az0;Qq`x*H( zYc_G^F=7}PXx+UcjCTqu0`rYs*NDKiY_&wc20Rz++}_yw<6>^?Bd9h~c!q@Jwk<~2 zkAh)C=g%Pzk7rRDg$bE<<^IxEI5AW(xS#40FLChbex^yfix!FAKk#ueJ#G)%K9A9^ zih>X4b>JKuJAXxBNzthrIP^KNK=3rPpGEci-c3L0U}DDG(llgE61~%`xnn-93}_dz zAZ#^}l3+kFk!+ApsM#oyP{uqJ&xY{4y~@n?+-=HPMLUa@2l~BI?K8g^BuF*w%hL6F1_{utm{j;kb1j_@yus3-#5}~<;{?6 z0mE3&H743-j0!gnd*u1;P+m$9uH9}i-!@WL$T_ndk+WShtsUejsbbfd2NO@I&)Swm zFT1AOLJam&)3w{6<1b1^WvJbS!ia8Y@ zfh}a&HqgfR9I|Z{4n(9yIUG;!@NNm)rM)d`38oD# zF?82bY$%z~{wAgF6gkK%C@zvEcq^EMhuPFRY{Yd0I0w}Kugk#i1|CX(bnSRPV=XM- zk0A$Fc$t(mR>32w0r1rl2WJ=_z()XJEQ<~CiW^PAP8SuV{+k*!;`XQhbMLqB|GW2F z^4H$4PSwzJiXSi4Rd7{8{g=#{J|a>?$$~~hs4|unn;71$F+QeK*;wn{=+$=fWiRev zwMF`Jyd}qO_$RD35^c=-3$ZVeCAeiDy4s~W{mK>FW5~T}VZCVoDE~#Gno3^WYuka; zGjXrI=~}(x`1Q_e(OxDupKF`P)Vbj#t#6Ec{Q$c1l;Y2h@s_<1&M z^2aSK^YI6Qq>BiRJJe$|&wA#(``h>@?;XzM5FuNJ@m-p}=-d-7x`?ne=lUAP{7sC= z+;K>o8)FaPGZ0eQX=}}o(O!1Nq+mzvQ3ix4{&)D_z~LLzc@UEO8}UY0X5XRZj~h&b ziv`m}KiHInY2T8%8M<5^#XhP!{UD-yF(cS?in?rfD5jXBY$r|H%Ew; zZaw%)`{3=@5oUJ&DtU@QD1v}Iy8>* zLU^pgz0tN-8&7gYaFN=O#U1JfNi(v{-L;G0BC0-|&$^Ukny4-Ity?rJs#$WUxv%i_ z1(GH0%R~t3IbEe6YU~PW;BwLa*WCNOKX98iVCn&z z@EWVccErQk@bL6gvn3wOIU+o9uU_F}D<7svagV`=3*($4Hm~!>YjWAuUbQMEdDQsT zxyGm1>8?EuZj5|^^uJX@H?C#{XGUcDW5ct(*0CiUT%1giiMikgHYgUaHMWS~ccd0x zGJ?M*@%+(eSn~Zc4Y;t0n)>Zr)kd31?l{CoX9gEz4^}Z*1k=JD+vhaDZai?8p(3iR zCt~4i9Pc+ayYnkTJ;jbhfgG>2gdbA`8=R4)qJ8t>@$V^V!gCmyBfR>1nZx(Xu+k3- zArbqzREpfHq%idxv20sQf9-OJfBwMlxw}elT1p;A^U{iXgU-!wjW*yj*bGF8Ni(je zJxDEHxY_?5Y;)Wkq7h0?LEyJ_NB-H?&F+QGiO<^11u?|1k$FnZc!|69)^;k<%vMu0 zt=!ejyo47mz=cMT(rO`|p@!cuK?NYOv%Ee|?s5`b8g$GQ2|Bw1pIa!SQx^=0Z{8k*f@{3LGeM})VAd7A*p84EyLqYL_dq$KNB30bl{!vrGWJbYr39H}MN?X&W)rnr* zPz4ca{_d!bwK51hnn9Yzc}2Ofq7N^TmU||Wc&KPEBR)UJ3gU$Rd1Qv;d<5m9I(%lc zFAq7dUQKYD3btV6*gOqyY43j?ymfZ2{wONYlfFMp*S2@3LU8}?$)+lQu+(Fhk{8no z(@hE6(fe^{u<{m%&a`=P(A=T@&qS2F77hd1$qm%e_cjue9c1lE34v}4PL!HuN^Hs*YqdC{*m@T7~cdQKK{IS&;R16gcAoF znWMJTlK1-7I!~V_i@D5O4LR+Yj1THiAH2db_qnx=DWU{_1zhGr&^JV zl0~HWZTd6LVpV>;AW>qzcZUw^MSn8joRNPtf{mmYxRnyo2-p5F$pHV9ZkLQj(IepN z(o;xHGkn>cm_r+uv>IzT+Qc(&GiwamW+gh4d`4KeK zoTNREZZM&WnsDmO_Q7rETWIwkO2V{K-lGmW*^+6%y+mOUFZTM(opFa=6bcyPgn3 z-lZWFVgDwg5;@d^bjtb8%A!MY1nWfPx0bB%Ut!2Wp2YVzVdG5MiZnLyL&{ZruDk{fSn5_^b! z(6sxr!c&_nvxNrls-vYZge|i+ZM`{qZ~P46?cv3M+Dgk}=-&GEp=)wt@{OHfRZyFi znL5CN_VKfx%YkJqb&rJwI!R5RQ}px~`?pA?DSnuym}_J{XVAlWN^4@b`1-w>lGqRu z*9PYl#()(=I)S@S?!QW!-v)V+BAo!w0B>yqywG6Wo?cMgKUQL7)Nj&+#f-N|`prVU z{g~AZpQ8^ghA#Z#KI`#oZ<^oH&x>w^EImF%-9IMUJ8ZSlu1S03 zZ21~VP6wOYDMqX}?G&wdV6~qO&5zB)WG-p*u~0WI&r8@#q&kX@(W6lRl&@r9ZBpha z(c5j&8kxo~p9cMs>ClB^kPp}ij@1$t%b*kJhJPz~)%yNVm~D#i-2mcZzGit*H(Z)@ z>B(Rps1Iph@*<>()Z&OjJaDh93!sH?>Etx=TV4{x)~tYyo7t>p*5U?q?HrlQ^Z6nh zDK8SI-qzz0v#w;6789ufQg+lQul!gomDYD<70GtUd#oQd92&{-)-oD>a+dpuCk8Y< zZiRM=Oqtxz?0xr^Cg{t%4_j{CUc;e?B;9iOgX6T#ljk=cm+$(|NCH=SkzKI$j^M6X zByx=R+Rx?$t40v~T|FcRhIHBUkMHkkQ~1L@KgIcg8T79&9qGATeYVrf+;e*SP!R6K zFbzzu3tX7`!1$lszyEpcLHoh!6eMXHuO=vl~8 zqtkOA@Rg~w&3DH?d4bK#8-3gV?9LvyS48q;vHe(~)QnTVO2f_l`|7L}QLt<8hT#m` z{!#!_=N0B3@TFwmr`*r6`$dmwc~nG4lvbYLJ+tk3n>RQ5mGImlP~^}5RnmS?!xrI7 zWpiebFB8lJ9=_UcnAU}v2-({-iqK4ML0p%8WIlbn#)ESm#X$mh7Z^ts{IOe^K?}W- zGXbkOp}P6_^E97*BH^xY1IH1cni#>iefB$iHDGL7!4MglL5F2oJ8yQ^&c|yOU+6o* zQMD}f7 zZH1tmnE@3jy=+`yl0J)m!?L>u46cJR%9-iJ?Ihq_7mt81aC_+af$WRWvm_vn58aJv zx4n=Ch&Msf(|&^^&uFNs6p@>TvX!5xU-kAl*C};)jOOFsVr!5)2C__oI}M4{WJpgl zdoh{2(*aw(jFKt3+`iqNK#H2hPIqxdY#LuYi%)r{N>^5uv%`_4=r^B@Mu;+pRo89} zmt-Y6SymxG^&nWxtm?epPE|yO-RYKZ__0-Ziu@~F!NsI|*+L)!drH<@u8aB$TDfA> zbS;@(!Q-BZD0%?TdR^M>nvBZCRM)26FRI?KL2UJu91C>O`_(S^GC{wxT|Yf>XZ6uN zF|?@@lF!L5{mi&$dFt(1r)kX!dAfve33rBE22GRJs#P`01@vp8z1ND;l~fw))mikN z$i~#)^%)<@pU3%6;!5u3*Y9t33*8occoTPJMys-Tpv8RWRzr#Dnu>Sb^|bQ}*Mfo_ z7ll8$oDmItQlxlT%yUiklG45pk`9)r@Q7o|vEROqtxw`;tj~`B8G5^$@nqe6zQiD4Ubo7vK`|-udvJjjo64DVhOob zG-lnn&U$J(l{kv!l!pYl1^ zE>wr_*q$0rip{P%n+j#`9lKhw6L+`Za>c0U^*s}#Tv%1>i!qn?*Ow9AgEK=tJjE?t3>LA&}@{sg8q zW&WgYraL_4b*B8#{fXY}@1v@{CzCWWx9u|uJM%|}@FxA;0iGYoUTAat6U4DfAD!rw zFA=fOC9z%)FAhfy^lUkAk2{#(tk^LlIk!?$Gq@#nl9&b^C^yS??bX?=xZa}e9^iDo z#epM=S7l$5P1ZjpdMdxbjAB`%p(98S*(AYjP#u}WP+)A5-2sLR_OG(4R9{bY>Zv*E zmR`S6|BS?v-rsT%S?maPQTKEZD-1G8W3k`0-NhjhxP;gC)>Ls_wp=&L;(dNKSaGFzHz#F!$JA+N<)`b7K}u{()3 zTGp1fC0RY4P}i&;F=;I$G(ZyBFgVk|iAfiGxIXKZ5kMhTUpyKcT`MaQvg4>88)Z$rIA#lpouoJk-j>;b>A_BP zX)5Ds@I!UGOOc@4H4~15 zXlh&0$7!&x!X1k=*OjwHs8Gm-^?{RUzbtfhXVdhzS*HYOV`5K?*x0DL6nCxx3TZ5@ z!V6A%=@R&ve(s_E4b7m@#SsW>)bY&+tRTtby6J*Q{2aiV&S6e zx?Fm-BWgT=R1=ifC*Hj;padvE`krZNYD&X8mz98298~uW>DEC!Jo-(EDbp@CL1vC; zcsp5cutGzBiC{+(?VmR`x=fT8-uunF!t47c4zq?!h-N6}(HrMOA#GpXlRzt5B?Gqo z)hl%Gg7=!N-N-P^{1#{ffjl1T0UF>A3Zws;oQY%1tVD*<-dHk?#} zFL=*;qQF!$bMXv!qPizbALBBsxI9!$V}6^U~~-P zj6CjQ)L+eQfO&__uoap=>4Lo@djmG`VEuj(AIkib=}@-`|bnH5(dp z$ktnhM?%e3;g*n^o~CJ?ZH1#K9@fT6?+*>GNM4Vu9x6a(cs zxvujDd8Wc?P-^UKFE)dLBV-@9hr+1?@OMW>tDumd?be!rpfSK$AKrFCMggn6tr!7- z(;nmqDCjFNPlYy;2GnBGup`Ti_p9C#<)}Z+#zzif2(xiyDpp()6rV)xG$~rTsu7nP5|V>f>~` z)*+|5fv&cEBE(tROiZTvLVM|S4`&@d=^572@Mv%Z;oqZ9oC(5l{uEQHdV!0?ib=va zd8GXbhMPKe$TZU439rI_>!RwaDTD*KL55RQBC2VDs9g2Md!8iIK{t5_{ycY=Y0-`> z)EcIl8L|%OFNHde1fpp^EZirCT@LesUJ=&x<;}i(3cc`XmMx zJS#h!noRMi!~k0!44EcH?`dq6wlqzv=>mAMy7OinbW6u(@aDXO zH4j2qYdh7rso=dP4BWx9P<+;YCkkLN2pDLdkjW~%N@2i6cjDpL=!d3hK9m<2aDGn5 z+7{}%Ddxf^Bh9N#QVX_y{Nwm1ml2KyOY0xDcU;T~{y#_&9Yp`y-bqpqxCN{^1mZGg z2`PyM*h_z0T2ixz$sQjiB-paOO&L-Y^<$8=-iD;T!;dg#znJ#xVNjdQ1(f14pW#YW znvEVYRg}S z@{K8>RYbZq1bkt&M5lNrsgdd$VWtgYp;JV`xC96a`OkqBi0yna5qL<$3X&%g+R?9-_zsbM45>D*lY6)~{H_m4%z_@@|S5$A@L78tE#8^6XmFkjl3_~^R zEvMWX_Y~->q(?6#TbVNMf_Mcy)`*8*8flRS|Cv*lT@!C`{&Z;Z)kTJZOAj$#7yy-F z-g`M{9TC0ZSis=TneOqjv6;0Bm=9N&We)W1aS$Y*L({3Pt={moo+&a$S4~yy2f6Oy zvK?4P@gb%jib5t*b4iSi8u@uQ_BA!J^k1G>g~t?gqrA|oaL{FxNyUx`D69Vp@7MSI z8FOL0NBPgH=shFN2b$l->Cp33`vt4j>)x&4n^ND2a;ef4Jqn(xbv=!tuk0&IJO-h! zSdsQR6}sCR&=lwGD3`-Jp9$rG1_nZHZBAwFgWRDb<4eU_8thOXJ$63ZbF+#GBxp8d*h8Jq4hi(ZyyS&bryF%ny*9 zxeD+7c19b8Oc9jnY+?_(T|GW(GWS3&4BBWQ`;7c9tg_qK3R$IlopDO;qxQefbd0>^fVXYq6bJ+1^aJSjS-^zQ~KFkd4QCw%>xpiY;{p^T~ z_6Os*!E8rx zd4=B@Sx|X>rqZx|FgnnrXxTI>PAo>gII(I%N86^tZ>=gtkO%C3m)XA6KV?@9Whkd z9z5B?&eblHcbEm{nZr z!{9tX=UEx8F0Ye~V|gM4DWt4txG|ynxt-YhXLd;^fwv#US_e6gGM=F;Fnv7RLkf;4 zqmh8AO8jGL1@aO&?sK*y%fw~=gnauuWCdEUaygG?mw!$Pui{Dsc|h@z084 z*?_vXDpmeJ1-`b63#W5qRhx5Avzb^ebU&=KT4)KW>TH@=r?4skNa$^v(CSVV8#nqY zG2^;gs9uw(&ZRf5yIR=$&Ez*3L$HdcaEOOcgVjPzM^k-D-;gcD%+XAb_eq!7m{C&0 zRg=}inDxoCXnv|!Hzmf6OvYhD<`ABib2hTT-;|f!_UPm>0*TmfhlJtwVV+CrKB|_G z6X@4tMjx6cz-iSHUMt6Y>n$P9;(l?4+2zCH7f@cm0TN%;nU$3gD6xdBNuq|UoTeMu z@mWFrlmaLks**|cQdSGA)N;l~f0qTiA6Ie{cohTuhfkE1fYlWKeBHae<3^)MyO#`A!ooj zs|wYJrr&#vGaOjlbvn1Qk_j7#vyrR%>BRHH&N-b;#tkL!+$>V@jPw6IgTX5N4+PHQ zKI;sNiCxLui-CHHD@$mjhKD6uD#wi+cZStb!wZ2z|MR&*kQP*jP4k{Ac>2#&0Y6=+ zf#X<5f(v!C@n=a9MDsgFPG!gF0|v}oZdZ>P!Gn15hX|Did>}NTuYMPpe><)r-4ddG z2nL{EpMF(Z?KD5~tivuKAx33C$g8qgXYdgVjw|FhirYpjCA_2nrb z3yh+mc-f_|SJh8dtdOA$SY=$u3Y76c1d6ZPvzuHk{GP;IR`UDDcVclqvH%j_V4xfv z=`XmkT38t(mG?j&bsX&wFLV1mFuh@{HN;s(s2X2a7{Ql59z_lRaCNRIY2!|;1}{KZ zerIO^x3(9=`|wphI1-XPeavVLi0skfoCo?70BowG_P_>hIRK=bQNxKNmhP zS-WG_5Hovri7_MA zpk&(yP;#eyrLWe=U>hSz1 z0Kpl^yla|R*%^w|M~R~Ae~U3eFyNs3WD(IaK=dw+%Jgar3!q(B_6wx=a}PHGnz9A! zbMUtFr-cmzz*s|yW^T&1G#Q&v0`SCSX#q``6m>6)1BAgB2bFcE)OU75KZ>Kh3!x3$p-sas^tUz5L4&d4o@vOS7&Po?q@F zMLXodA34C_hk+Ix&-Ct<8IAxu8G58ISflg%1_2;zi$E6qI=(|te1CJ?98#1-@HXC| z&?*T)qA?+DtM0!+35u6MbL! z&cvF}-l?9OoxE4=<+G53yn4VZSU_?*Cu)HjlO7o(j|0g&3Ob{PQv|#K$*0<8#kdpy zWO8{AcjCrJe8;#e9%4MyHt;Q%73#gWoCoo0W42RH=MuH&HL$FkIhz=G9d>8rHNp__ zbA%x(Dmppgd);Z+H)oF0(2w}e$emQ@TOZZ>&o4JkU&4I{BiIEs>C;OxMA7qd<*ag~ z!Tz>NL=rLgXjOt+TgHik%ih!(UZZZ_dj94|+HBptr{JyGmmbQ}l+R;cFex9lh+Miv z&d?g!5`A^Vi;0(ONhUUqH7WV&TiT98(zDrV-5b~1UcXH#cHE-kYS{d;mo9Hy#xGPn z!%0>M{%VvYFY0;(-?_$9iht3Jwfv9URw9JZKJ2wSA^9BRkfa7ilhqRUZ2i>|3`j$E zFhO^_6C`WXB)$>5)zTBwkqqO=exU0*q?U-2tPHp>caT&l;GEwdQM`XTRM;AKr9&vj zx;ggrSGQ8Oi;WEBqKHeN&beGm)-DcvLyjVhx6$MkN=R6VD7`k)#^!v>39+y5i?C6!W8V(vh8`5q#Bflm$ zq{^3Gbcnd2e*(Hdp(8uz9(cIjF4`it&dsvVf-jh#sBerk3C(y#EnF_Fm_sn!X_GC0OG6>R?&3U7;0IggcXn<`K~X+m z8i6Bn49pv)Y%2RTA<+iD;%1|Z=pi1wrZD#7HF%{&9D0LS5ee@CK~>Vegnt%BbpNd? z#b=?Q`A`N!v7#MKm#W7h1hgh)25x@MRT_KJNmTlcjMCb)n#!7~IJZj9y?L%9v_ID= z-RAiBp&W9d%oduLrJPE2^oOXy7cAJ!T+h@nZ}PD&Fm(Ik_C+;zjg@U*uj2`c5yyox z&II4+srD`{Rrcoet0dGXptkQK%cy({Hy!GWdw1T%JjQFIua@vs&$?;2@Bzt6`V#*l zHNz@krbeHi8;!qp_^(@}!eFaGA6PaFv!>L+gy9iGHNCUeN{u=ZiMZ|i6rB|5Y@YNl zpD%2bo~(;0r(4PTs!oS_zx2@w1I45ncCmp5x_MO<(~|zVfO#5MPQ>LqY;YkBx*efp z9CheQQfRO&=1ELO`v_;))1*Ugry|H6(#6hv6Orfea2>aMA3~H*boD%O;Ne@0rlYN% zY^LU^OCFZ&5>$S2f)w`$tQOj=&+^bH&gXM{|MW(Er_Onxm|#xOJA9RR^@L~vgw_Vq z2#*GS37G5zVU!SE|LkMdm&hHskFx(i1# zb=_m5vass=dS~3!s*vnHC%an*&GQY@E9Mj5$7E&|R9#GM^)?@e)77$bmI^F+bh&5z zgqf@egy0E!OvmvIz+gXd8dKQKy$JJe@~BHnRG1bC0zIR+}97r1G-JD0Szk1KDiip1#DMGd+~w!~y~- z*0sU7rpV8iJmo8UrjU3ZnQK}QWNCcZ6A9&a%yjN=A*Xg}lbY4`W!zxeGIuIH7J@GA zje7ogmUAZHq>3=JDy3BJ*_nP@7f!#6npNwRJe@&8V-P^3(_=jrhja@jZnt=3 zXRWB}q6JtdO^b|s({1@->3v%F&9jaMx3x#Bc14T`yvy!?#B(X%UJQOh+X=4?P~!25 zE)2`~jqh@tLIutM9qjW;HwW~qhPe_!Kpf@J16>8!D%_}PA!u6UOS68W+lSZ}-;6qZ z1(k+J&V(6!87eAny|fll+}(1T>#Ls=yZ3jwh?P<1Z7t(u3(IJ7?tBLA*SgMfHy<;3 z%(={1@CUq^YVXieyL~aVB_NzTCo=4E#}(R}iAFKQ@an1mB}A8z(s$OMnSN{(CL$<_8u*dOA@mg92O#=zzfp{bPWKd^5vZf0#MRGi4<=2UKu%6P;< z8f_yoFL$r)N%tJlVi)InaCc8yf7ozO_4l;yqwKS$nP6TUo#4ZJ# zBkPg*W$3Ja1eX-%X@jS*H=2N=ox}ZjF2b&i;L(#gtH7m%68e|&prA2aIoXB|zHj!E z=$Wl*+>8K8vv)s3^t=eQPZIayyC(riS1CSec}uAFZ(f2WJs{` zX`R=rGtRV4g}!QN3=9;IaaM^f!Z+O!hC}NKi(tkowSh+XZwU-NA~uQ8ZXCTd&2#QZ z&oI>P48@HK;#xt^VTm)csv>M@R5!`F&yvb5YADNEahda1DBBJq^E}l?KM#%A7#Aux@=P${HPXy#8ap6^vzI_V+)R?D7Fn|W7 zQ9M&y)Of$+zfVf`hsVX({3>UQ7G~q`rh~TQT15%(<=kpR;D&;O!PL`n$AC%D2`s4Y z8Ms1Q1$h0aAG}>AudQT=FMJkgaM^tkLoR&BICt+B(LLtaxipf6CzeDx=e~RoW1rI_ zy2U8DwAsxnnpXbdL*_Ay8{rK!yHky{n_F25DvIyjxsL7j5HxhQ-|IP&X+1n)_~Dng*#W17HDQl^}_!EJ8L#4{tsnHucOt-l>ojn=!7k~odB65j5n4EnJDX_^Pzi`Fm40w#Q93q(FkxWOT!teZcGGDw!$%ug{Wo z_y6`t1~juP$3KrN41jsBGYZ)myFVa6_L%gVO0_#%IECPXWwV;Nz?u)~DQZELCtiF4 z%)0XW9UG5)6Q)HNEe8wu>1g&8a>%=`zgKPNg!@MOQ)@Oqql*WD>1*aLaKFeXM0yxn!X(=2*~ z`||r1{zD2J@AP=)KZepgCS$)qDs1fbeUmvum`G89#SLe9>FCXECH|(^uPKg;rc66* zf~+3R&^J@HJ=yclUZ>hf+4AqpaUWSleZui_ST4&iacMY5t5ujs>2wAbSV3;FTaC=WP zHedIYi^;>nQ(=p#j>_ij=@NZR!22UK&zbse{fxvUf_ND>jC&DKWhFZtXg_B*Oh>9qix*#_*4{%JN*gl?U$<2x;0LZ{IW9bWxn9uhy8bHPjPz?GkrHz-DLI$C_|7NdbBRiUGOTV_S}YRazw!sz^BqFsaMy~EI9&BC#)};r@RCqb zO_F&MfU{jj`Ns|A(cM^9_U2USb37*swBV&<8ojjy7nG;{qJ{;`K;Z+VXg}>y#r;{1 z&+qhzhEZJ1R$KQIpLpnF;{Hidne_MXkh6NSU+r#FcSQyZ%Z0xdB05ezn}U!Xex&Fg z<`~IKR8H-qU`nd!_h#xj0F1mejbgIsnVCvVfpwAgIh+QEHAkWm9u@bxOC_p3rj?v2_?n=}Wv0dyLCP z`Cv?0dL45=hp}rfdB&c8KW9jJsLA)^!cir5+)n=TMRHozQt7Xy;}3H9^{{E2NsSc> znl4pBpx8hXdy#Xzx4SJtZL}T*D9-WA50tFrB3^FW>gM{OeCWLOJBx zFGe%cQMz;(C)V&u%vg5fqjrs;Apv=iE`rHmEjtO7;?$Kpcp!$ryZ)!Jf`IUd5Jmf( zv|~g-G_!?!4I#i)(V&poTnmmi5LZe6mu5e=05mZk zBm6bq^JT5|wfYAh&0jsJBQzD1sP%&_-(ReB*}4=Sz}dQZIhI?&>Gc)Y9l4vv7ct+S z_uQYSyU%uO<0U=4vfGj+aH02PK9;aS2v+?djZj={(FhP!_W5Vh8nm2kf%gP44^R9Z zD`c-Ts5q)C>bxej{oq-X_?@P^r@+iyT{ORDZ}Lpk+(=hb#`c{P_Xr!J`>ksa+7mqc>>FbG(XP9v7fAD0hJ|QA! z@%~7^O#%h3i7A8xt4DPCe$vHXYVgVbCi+Fji5=A(Vi&vc=4XB)zXOJ8!2N!mBY3Zj zCtC!Sz^Io9W%|*tpd1E8{BppNzr(q9TxrfYZ+!yT>oAoqP#7RACIYoUP)G!Hqqk;C zoRO`Niq9Z`R6a>XarVcXkOhxsk`qdM*N(obhg`)cSoZ{ph2zEfoMty>uXJEEIekw@ zv|eElx$*sbA(A)j77bfOvsVj}ox7&22em*|&ra;tRoZ>Q&Ac9%I==jQwofP;%m_^& z==8HRyeCk2%Rq3s`gH_{pes}SckKAtq;*m`#;U@P2J1)Ztu|oBd7k(<7;aAsR3SXRz5hwGRZuz9>AjpbI!3>#*sl+`3=7iKgZ5Ep7Cos}Xx zmZ8KK+Q8qmEKY81M%M1W5!`Mk-*$|8OTlUKx%}CN+k^u4Z2)O#q=4_xv$?074Vx=o z@;9QS^vchJB(RXj0JA(CP(;^Wh3z|w z_DhLqtlExR2ea%o7EBjd25ON5^&OPMA1NNe$#~WT(3pGhfQX-N_X;Un1j!AMkrP@b9X$jPv77SaJ(}x9N<4-nQ+o*&IbXXk z0cmW4e>-LlFCxU5Du*SgzBq+XJczQWWYq)HBjbwpVPHKc}!0UO5GN{%rS5X~Zc=DEF zE9c1oaZ<*UQ+@jgorc!{n`LNvEn#(g^*Z5zmVdez4&80{!|q& z3L+bfUrGg8{M2QBkYO#{9KqKkUa6~an!xQ?7tXKAq%U%#?}xk{CwknU9E-K-l5ER# zPtGWL^(7H>PL=4M7b*9Uh07gcTc_k^&+HX`>XoltI)C+vyYI@AT^|u@-O1T<`}cE; zN+EHl63|S^M?13^t{aQ^%GC6cQbx}gb;;(!R{{{}NW4aBZXP2T`J4yHa7|&m0Y8f; z;cs}mDdAB0J5zC8A!?d>S!se+u3;%yJq)6y*GFI~aIbSHWM5ec0==d9)~1OezPG+z zq3}N4{p&?k=afhUcLeTVASTH$$p^57aw->=aUOgjxucp9pq zCivf=pB4CnkA!+W{`=p7bB2M!FAuaj%1b6sA9bwJy_>QMFA8P?3lF2zSWtsw6Kk60 zPMR`uS;e)V5Q&y4rFDCQFn8yB@hrv zoFg&Wbi1n%Ed0KuQlH8DlnA?%Q0lFt1pY5tZ2}ThLtq8iy3}w!zf3<9Mj0nY( z{L(zZTVc22@s%NUoj35pCg+|JylyGS-_>`}ILW=N*3e%392(xs_WFHO20MDsLj)vZ zN&9*$^8vk6MjI`PZyyNzojC&9V=oS;mS5xPILx@7Ng@4(?1#a8kLRg2khv01im)GV zWrwAZiBOaH#tsOu*RWH*Wb=3*!R<*r>sGpYrk*#<$T3J zSRRcOTW7uD!l@xIuO`Lk#dzVn4wMjd@L}?2^+eQ^5qh3Av9!*x<{(^${gqBGJd99;D@VOT^y=F~3 z*>&1D`CH8bfBfv__)i44hU`B1x<#G*ww-2?`W+m)>7O;)t<< zea-aTB!cayYfsOI+{(mBdOZ@pN+ur&zYoAKE)RwT zS*BpF16m>Rgd(-Qi@=IJn-o4&z>`P_$p>!n^6&i-*y>C!hKAa<+G}$^3zG-i;aDnQ zOic$%48l%RW7n#HE_ z<}M9#lbHYd)93rRnpw|)Hk0eN@tf?%$L^gIF;|m*_`{Wi89>JmObYTe@y3fDDtE=Z zQ<@_LgCNuCA|S_elkm?1?mv^lZmjbJ?$je}wN6G*C2o5M`&G{FTm@9-K2Yi-UQR=y%3nHaGQcU=4~;&i^ikAFuO42l2C=;Fmh|Jt1o!tZU&+{|eD-Q7Z*GrFUE z$HcyUuRT2$LUMjuDLo*#Na?o6W<~g3AP+EwRvPJ*t8(IwgN!aFmFG)xmnH%@jE~_7 z_BowE=&31D5RjB!co>f)*(aR@rPzG^Gx@N=8LlZ(R(oyZfdneqC73RyQ_u_gvMOW9 zMUkh(SE}dDJ^JR&<$SZc(>*ya9Qm|&+L~>MOW@7@r`*k6zQU1GOv;ota*xAHFD|)P zIu(}*D|kWvs<>I|1rk^mT;gs2FF9T zKS~Qcih?+k#zzLWVo{l3lQ2!Qn%XH-Ff3J)wIw%(x!Izpl_8yk zLvxX6CO56e{znpq*6W$&UNBs)BR%&2IC~RtsQUL0m=rCRo~MPfOj;1L`zhap0|0Y{`sck zuk>0RpM3WI>~Oh$1>a2UDCn+6aVs+S4pJpeG?9?EIXi<01}H7kdOJ3c6)CWaU19NM z!@uL7U3<`%b%oEON6G8z9ee445<#onDS-#CARD{z(6!39ht4WqdGlN}q%Fh^y)O+W8JnlX_DgS> z!fC751a3KuM9pHIvn7JEk^WA$49qdaiv?j1zb!^Msca;`1Q7kO6Iw?_`|HrKr|BY> zx`X`lKr zB%^3s?rVWE(=tW0MY*ZwpFd#WJ}1g$4hlrrob3QD16IF5+YNy=RYrc#omTg?{6${8 zR7dKt;+Cypkvh3Y1<%~8eMicp_VfWSzvqqnoBpF6A&Q?HPQ1Bnd%5?(4nZO3B!WnV zx7e1O_=CcS2c^FC@$G(0m6f5O{fSl1GF#}aOZRO0Qsb>1&v#N1@SR)f;8=ndN~tSL z(2<`!FcbS1T&w{i?3D)G5A^)Sc>c8w*t3Pt^Wp|DG%TkKRwJ)@>yF_mvQCK@_|=-Q z;z}rD_HW~t4okNo%HCZ6hUe0ax+QIbW5@SzxsdlL<1F-E-HvwxFRj znJ9-u%TPFyy~HguN%1yo6Osl&?K5qBzfdM({T~tkcm0IpF2DGl3FoUH7jyQLGor~nkI~kHw&h-Fr1kK1 z&;Os+! zvYf#J&2YO&redFU(~f>Vi@2Wud(**v<#3YVhC?sHHKrVfK}k|yA9;CeosqfJe$XFt zj2z@s{R*T#N+Na^xuYypQwFV`K@~sH%}^6XE32dz3KNxsJLXvc&aes%QdvAFh{~e2 zx?{a|DW<*Vl?d;-U|1riXl`7`OxqhCX84zroq^%WEa6o<*eAL#4h`%x(%8G{^InRo z@w>ZMA|iEK#(-ykGi3V)spE@>_pDlbh5GISv9}_XY%qCH0du=>rHl-e-G2E5!XpBL zD|;N294w@(Le&Yx{_j904}1$GXuBh6i?>Ek<&*_IMUC;MgPToVoy{QeVlD9&y83(0R*RWh2=oO89nPY0-J!{Td;7|{~N_WB9witw9@Xzl{03K?^!r%Fw z#8cE>{-WN}cmo+WoloaTqOp`jt~rqt*dn{|g`54(5I4=;k(jrSm3qB0SMFQ>*ZzY` z9m+rNl-yr)MUdyf?!=hD%c;wfU!0!Kthe?}J?I5MKxz}|esty+!tzV;29>otR>c2D zZvw3Q|EV_-zzzxjgGO7iMA*NFf1aj)iMDOg!K_wt(&^=g_SKq2-0$wIT=&^wxl+8+ zz4srMtMu=Vyjj1#>szu+uhgEEPkD^gLWY{%`GFDCNnQI{K(X+QSz&KON%SguhKY8? z!D>SzZkkA9Wz?Z~5HYd?J+j`Ho$28F=&2b5zc}0zsOvttA9NFeA7LGcW~>h?27($- zp}iK!+51l>@n8ZgK8FikR}n43)$Vz6YWeMY9eGo+Q6@ZPm^DdLO(m z+fFo0Z@Xlbey*nPjrl81LbO@y@xRoLdK@hOaPAYe!gitTfNhcIJ1@E!W>ji#h47s# zs2WZR&@y;r!2PD3lG z0n zx$nZ_VcUmbaHjdpgx+PyV2LxHJD!`d2wC$o;#ai5D5UKIe-zl3Rg!#cs=R;gP$Hi&`LaMs{i4#^_5rX-L?XL2L5qsobLAmLsiw_4DWG|O=c+$*0&dCtoj?U?eVByq@3tdxer}Ihv+wFTMcQQzurk^{tV4H^}%a zLyymY;uA>Mx`}ko0r{Nlq@zQP2uGW?0s9(I?FDP3qKzEc8#7IW9Pj_oz7FZX173X~ zQ>cS}YguGpX`-BVSbE(Zj8|Zb#F#he7g+2+w&afg+C59xB(B(<$RqalW&d7L(Ds!6 z!p(}_9bI)?dTDYlli3_NUHMQTvMNsXB>vrktz9~0fl}v0XmujCqP951JrVcOT0o6I zo_K^>;RBcf67$=q(DRn1=!pf^i|r`=5AxfB*38_qD1U&ao-g>L%&XAgFA;Od^ZB7( zggmFl200z6w_JZJRTBL{pH#lmD$dN1P6=CsgWT%xN+oq*`O_20dzL@hgxfr1 zy5{ND{A1*u30F3t5nnbPik5S&hI%yQjn1QIOA5@;bS9m%oo`q@g+Ru}&k>AuQrH)1 z*D{O+J{|U&IWALl`i@Zp-dbqz(4sB7|r+xx=bBAB%$O#a#G06c4t`a+S&XPbQ_`?vlDbkf1B zo*ao-{PFUYJ2a@H?^DU&W64fpPu_Z~uYKvh9Q^yojd90MtmBjaF5#=5MIF7q^zlkB zig}@9#>%Ik5u8FYc#?DF2Jzavg?(MBbe)!W;Cnlw3uYXLf-qksd!1(-Lrp0;w9TQj z7hQOwX(wu~>vZ4k`@@0D@qoeUiVltQ+z;F)3M*qZu;%hp;e^FE*>84j$LxSjs(W0; zZB-)F;v@ON|AQBr37~$dzhj}XjEXmQdjZOB{wMyXU@U&fy`n;|`~C|@e{l4o`v`ha zyt7gKlBKS)K4GG)6C~FTm|%| zl4yF8x380JI9lz2W2MK{_c9tjyCW`s(rXiSA^A8fM7R1}lKoOz?Sm%oO*i~DUqK0f z!wkn9v=KnLCbkaEgMFtE4wpmxkJ8h?i~n6bgS|g>L=Nq9+9+ZDM)6z4K89Y{$Y*me z!5}%yor)%sK4SGmcR4?H(}$3-oxEYqJ{JTA1Pyk@hi*Twrh(Zb_SuryEcNG5OaswE8Da3uk`;}u0WYF<6A$?p%nQMISr z(dT+io`BBgaEXzgH?}-#65CiSc_pHx$vq><4mC{z5-8c{364)?BaMKNhwFe~OyQTf zvxyKPwL7SyN3Z3X96c%h!5F6!six0^uLq+Sh3=VN44)qg!y=Oj$et8a`TxiPn`dx_ z*PJ={)Lf=v2}_d2-AC!_BlUvtp4eOHXD z^JcRbSJz(qV$1AqP$y!#)$dku(!D1GL0p}F|{!!35|DU zBi%c;>gRW+8Z=uHi!oI1lntwT_hEN5I`-zpv;>8VL_h(1lh%?nqK_rNYl(98dO>KH zAFof@8RYZez2E6f$fw|}Mt5NPKr8FINU3DLHVJ2BnA6)lFgckoseybA4X1BMpGavH zNsY87P@ul?!w;br>=5AxdD?|jz&!%Xx5721ky4{?u`i&}V0fje*5jZvI*lCy3Bo5W z?D8|aT(bt5nytR2-~PNSmHVOIyj3{6ecQ6ME(zD(FM1uZ<=#Qr ztcFO>oO3JvY15XIox>3-T1f_XW4hH-zxJ5rTIcM{>GklqdGtlMsz{DW%>yzWqgiss zMNq${I>yI+n|>Mcl&Uo{W(1++4bWSn+b(5N}`k1ftwF}RU$dV(YMm4H09n-uwKFl@3KC#jhuy8H-i0Mvs!^zPka~X~O zdf}{{T|sV{*x;uhqX=sU^QsKYvuGB&|NKpYdcWSIe)m1^E`}U1@F0*rP^`B>PNo)u-QqoN)Ucv;4DqkFZESxGSX#T+Q<=&@>G|^?UH-uR|fgk z(foc)aq>x!*UUCXMx7%nAI~58HpYUwZXHH;a>}Ks2AvVM6R(L(mErIinOM12!eMwe z90`^2f`hNksjqbh=MFlN(-CqfsEu`Q)2%x>%+Z z=UA?3IbFpuxSRJxX6N9mw@-wHU2Yk>nAF5A-F}Qdv7{K}4<1LSHi*SWMr_zFZit4Y zwS7l{e~|IySbCfdIumH<(Mu4VQ$hLL(8L=3a&zuf;I@2rLskZ(t53kEmBhylg{T;& z=_>AZdo|5+wPrJ@U{WMXqFu!xcbo3oRhJC(MIsEeo^I;GyKOW+=?|F?ntFDcw6?F+ zFEF6AKNz3pe&|Qgo9%QO%Xy452MoNROiqntjTf+khA|R_s1RR}=j-NJ?js8G? zp3W0c6wpnD7o0*&$u3}gQNNT5VZb42>>`8ww3;%&?BZ&RT+#2^TlasqE zcb9zR(ad^b7ma%x6SB%x^TyNMzWBUo%}!XIKl!fb6|%~bxEsS@8lTj_o@~mUMAp4n z3@s89xn@W=+6Dv?qsWW`AD}E;d7Q%h1NH~NNzfUaH^>}s{!I2n)u$5 zrnFH~T(|MblZ`*5KrR=UxuV;^H|eP;$DYaG$~47LIi8-f+I zVOC<`%b!{Ch+)qjby=$2Ju&#;->r`_fFgM&U>Xonhe^PW7SS^0Zt zG_~!UuKfPh!54q_UcFgvlXC`O1rxSMPTA{Y-e+tQf6{p0A!^9I2yf3T)rT}4AJ;6z~$lGDN8yN%~X zkRhqdloxm5lNeR=)dG*QeJZ`z-NefU3-Nx5v)tDEFJU8f+X>*9@(&i9K+MOX)Hw7S1Zh znPj?U=bhP-`##8}rWCI;9V0Y2q#C7ibrNUd!|}RvdS`Czc-^Fjnj}5OEcf-0Wc^8W zUoai=AhTWHs)4zDgph@w<_bM%ZS}#z4K@PkT(Bb*m(ipy8}dZ9vV$&pWiEc$bL+ue zmOZ!SdVzT$6-q>idmUo2-(DAS*K2)59zFdtyS68tSjl-6c0Z zV4|myI!NeD0ub2+KEx%fRBv##HMlh1axBZ+MOQba%{^Py)7acBK7H3GS?gXz^^ zL(yGaJ$FY;p+21b4o=niHzLlnJ7P;E`F;N0^4h!4Z0H)@ID6V*$A<$)zJ-qHr8wt$ z<0E`}_Gi!!HR3(r`8>wP$?Vim@U+zBm&S;%YL^h5p4giuUXWb0u`Alg`{-cUpi$0& ztdj|a_WF+cvej8q-C{%i4;wX+c0eZApqLBy8bb6~59T8{&zQgUv{)DjSvTzxmDsCJ*rx%kHMVW&Ics!?t+-M1vx-~+#p>xCCvJnQB--1l}l(B3Vl z+?DdJnkJ$R{&XI7+EUK2E6LLT`}f27c7$&&4;#xmHgtVTIdwO>Tx)AuN~ zt2aoo<$Qhn8AZQ>pM;E)_3)T|6UXuhpdrCnN>@G#8F-mXXmq7j&4&zoZW@K?bYFhQ zekKYT*mo{UcqXN-ZOVNFqg@zurU;v$VvvxM#rFE?-jqcu@1NZIoheiN^D8N-i`08jMcZosfVRLvSvL0tP3mg$KVEB_` zU2oqZv(`Fu7A0Hh6uEi00gV^K)Y$D7cZwLx5I%xs5H_$y;cvPowt)j)GW93@9W1|Q ztk@9bg1;kRa%23BZw7t%kol${2)Zw@RR6w@_o2b`&-X<7g6^8r$(chrkIAXC0!!858>Gep^K7l0W~u=WU5AH}{b{7mf@@6-E5_Q^O7{LO<0$J>ONrVw8i zOKAR$&PIeCE)2gW6XX?!ts3`Q4L-W_zWP*V)QzPyLx0A1y@F&*Pl|SIUM9rZpV8`@ zQ%ZkznDRpDgDQn!a4GZ7`{Wf_!oE2g0Vgvdnq@}o#8%h4AG^6mIi@#$`j~tASnb2 z4zn6fn`QLx^SD15#>1?vOJISC`PF<2dydn$48irI% zM=BN9Wik@fW=RYJ_nBOTbt$CDqbU?uSEKgCH|ae2OyP?wu)Ms1!2I933&AGYa=yJ+paRLK@YyC+^}o z7Qv-1;+tV0$eGoXBDzD|_-RQg;s!Bfg)e7LaGku9z7qMOy=8S(6hL2sV<+QjL& zj>hgvBgil4ez@sM^CND6U|r2@63-eI*KZYenZ7%;qJCq9=ValFu5xpXmS&4x@4nWF zlZ}Oqm@W+;jqbu&um_GgDrhk_?cNEn-V+yn9gQy(A+>Gm(M@=Pa&{7dQmTFiNi2H# z=_Vu~%K{4KVLgF0MDorBfBR?IOd$gdUJ~GA@Av9|taf(A`rxEA0>X^FzX7Qb_kesnxMAU@bTz1=D*f=<%2TB1^Mb{sup0ZF!nDe0J z5j9C%@VDKh?qzr!N&zR+H`8yNVPS*(BqT92&=~N8C4g$gNSuTkQj1C4O0;@$Oq5Zk zNtQ5~SG~wwRg(8x(Lizy&ukz8)+^iKe(VKt+jwZ^q%6R`VYy{#+cL!ye1Z!;mJqA` ze{ilADmPYs38yJpT9x9q(S}WaR1BJ9{rxi`CfAHqdt_6Z1g2SZm~Q;T#WZmmeq>sR zC3e~0-F(i2)L1+rYf=-y@yZ6sm{{C}q%SCohzOa(-ph0onuku85^|~$Vm3L3p_d>6 z_pXRAx_P45=y_tQ*$|qHeTyl1QZY+^NjRp0Ha`1>Uum@PCfcD=?^0I9#9ou@3`gTXHOnye=>nPZnq(=d#%3QIISzWJH;UGeq&v$i~nip&QRP{VHZH2 z<9#|p_BVoU1*NDPA&Hz1QQ&jL0P-7S%qZExPlsduqrh%__%!@Oxr=PtRWOKfk{9FC zsKnx=6;GhB9CJ7siHwCeca))A%l_(|D#!LvyYADMgx;)DB#{q*Zg`bD=bV0MYNY3*;xU2=teURY?Db{ z<2Pg)xblFn$TT`rjL^_|8V&Ynj5UDioR`WFjn1yjvZab6j2)t4H!>B1M}}$FWe;4M zkQ+>>dTW-ir!VfDacA?c*l}jH-el@#AMf=MMb%(G!fbRsG|z%Wf*eKL$v^T832B`^o5tmWjASr*2H6 z3ibyEY>i0Qvl$^(V@gC)IiH5+fPIi71uaG-iR*L8I!uK)WrjkoH;?TVot=agn8c{l z`H{+z!>w|I8TIBki9;I2cHh1aKFLg$>WsJ4hqxI}$|w3Qi#?6AstiDW=!$>rF}of~ zG>Mw= z6mi!l*Ct?R5Zf~M%$A^l^@9#F5t%mOv9?WH8v@ZG^`q`m?I{ zw@w0~uHmiGtyy3I!s^QgSu|QcGr}G9468rHK0(b9{El2S)jPX=G{ICnQ_mt=5Ys7- zzBsYFd(~Xwf#tNq!<+0GWjr!~71AZRLC`>F54Nu=yog~RIQ(ej^@9R~DBO*6{&(LW z-$XN&bG7|unl2wHce(mf@HeMiR>SJXV_9XKyDxI;T61*8Up|kPa}5|e<<0z>J^ool zMpyjp^N&Q;oW>r@Nq6vD4L+R5jrD^uZEsszfUL{h2j+1%=Ku=EnfHO02sp0m+ry}Y zVi9pBc?f0i)Fz_JBk;#emUnffpx8peCe5=FIrc;S7t)so&Qe0wW%#oK{K`(0l4O7@ z$Hy-`H<|pTyIecQGayL9pdFj4*K9Ny-#96fA$)HKSqQmq;v znjbO9DK-q6Y%yZR7U_Z-=fk-T_Gf^;c8D9384<&M9hw>`tAD*U;4~_QxK~HM=s13? ziMe@W6Y7sd833Fc2_7Z`0!Sp_Ot+$C;J{49#h&-HnOkw8si_GW_v~*N)`hFCii>%S zo?eEF9C?9xcpum#jgBtzKsr5-de^P ztWCHyyagn?rAUNs>Et^-EOAqi#fF?-SKCt~{xBCmiJ}2E;Os;d;{SX_FySF`}mWj7;H#SI+~G)?gOhZmHzj5X4~+;96XSKMyo`+FGhN=9>GD zzcNd?X3)4*HOE}Jd6*{3Evb*Wb|zzfqXX-YyKgN6)C5K{q47X29wf7$?boQZZR2lp zH;hH$USON1X#1@3Tg%FA0H|EO@0N|llW#VSXz!2$wTpC~gO(!BU_kM28xbwS&7o+* zvvpOG0HY5mXn#eNdAZVdH03EI-_3IsIy8l2cW>IX668B#PCu!D&P5Kj`f@D1B)sT)XeA!9JrhLaPLXF`qR8)%dBIlFw}%vTG2`A^>lH_O`Q^<5iGw zm+-#Ja9Ua#Ie2k}zUC~)n>BOhTr?S^U5mOFO@+6xme0f!YF*822dtKud0LU8#IJE*AcsIU;1^%CFo;vuWD z>oG=)ax&PX65pZK`jHt#xunv)rHD#VX3KY1CG|jUlyi31?P~CO?3By&>`B%^CVk9g z*&1%g?qx*~@s?l{$=f1?UOv|761ZZ_+_pS0ITbcafIpfrPFQe_qoPKmMhS-fytPng zEon)!R3B#cVZRxUk%O}oMvJ$w@Mb06hqxZwBa6sJ!?h$=CjHa!_kzvvlpI{%gXzQK z`bgT^wOH^V+4Pa>v?IZr;*L(yHG>Umq2#;Yft2QjRjPk4;LQ3GIW{2h@r&S~8q+0Q zL&6r33aFbw-%0zm2a^JDHsw+Uq{=MT(w=F1U>T(8BZb|Ux3wNU-pRc;MSkraatm_f z=1%4GW3z8Tt@PDy+_6EGJ@r^+*p9nh8J=ok7ccO#NV!BaUViF-M1syG_yCXTYnsSR z5D?A@&R7~~rv=NvbTic}p~0;KBx7x@MetySsxthMZ82tAPr3*0OSlE$lwqBgrOI&C zX|7!rxbsJL4L>aJ#1|)n#oJ={N&(t;a#XfrRaY-Y_@ARSB?k?_KuXM@=qU z#4OUoMe56KwrVBGxQ{e+^q(o+sp1Sa#jN7VG}&+}@otgGW;nP;7d$=la0!fu{dR~P zE$u3*>Fh4XQ%o2wMwv*Ft7itJLAm zu!OeLJ$8p})n>Q{_Ojl0&Dp&c`kE}T8GLKs6rP)gyt&;L*cgns1HFeCH>eW#E?Hp9 zX4s>7s||xd9K|i=8s}c$H)So%hyc+UHA=UiqW9=Jw72s0VIBKOu7N4F&*+oI#Lhvn*${;oL1jV$-e)exl-}{)Wq^s`HSm&k}VO$mGOLn6{JjSh6%iA=JoS zgMQR!(bc%&Je}tn;P)OYV=3O+D8Rbv)5ysisWOS(@ST6ZZMM_r%rD zJ4ZexkyGL5hg|W-7kSy7$<(OKMy_!sM6kT0@^j&^uHb(yBP$3Ub%kXpc3Kv(1tu(e zmGTFLa1ouqkxR?rBz~EflfUi(%0DTHsbE~b$9NT6MC<(5(Yvp|^i-7&>9=`2Ucx1o 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 I7@vszU&Sj4@Bjb+ literal 0 HcmV?d00001 diff --git a/voidcrew/modules/cyberware/ware_sandy_senses.dm b/voidcrew/modules/cyberware/ware_sandy_senses.dm new file mode 100644 index 00000000000..f3875b85562 --- /dev/null +++ b/voidcrew/modules/cyberware/ware_sandy_senses.dm @@ -0,0 +1,317 @@ +/** + * # What the Sandevistan user sees and hears of the slowed world + * + * Only the user's own client changes; nobody else's view or speakers do. + * + * Sound: a sound from a tile inside the field reaches the user pitched down and slowed by the field's scale + * (the `frequency` of the sound datum, set in playsound_local). The user's own sounds stay normal: anything + * from their own tile, and anything their own action set off (`usr` is the user: their gun, their swings, + * their callbacks). During the freeze the world is a low, quiet rumble. The weather and ship-hum loops already + * playing on the user's channels are slowed in place with SOUND_UPDATE, and the loop's next segments go out + * slowed too. + * + * Weather: area overlays are shared by everyone on a planet, so they are left alone. Each weathered area in + * the user's view gets one override image, shown to the user's client only: a copy of the area's appearance + * with every weather overlay swapped for a pre-baked slowed (or, in the freeze, still) copy from + * sandy_weather.dmi. There is no particle weather to slow. + */ + +#define SANDY_WEATHER_ICON 'voidcrew/modules/cyberware/icons/sandy_weather.dmi' +/// How fast world sounds play for the user while the world is frozen. +#define SANDY_FREEZE_SOUND_SPEED 0.15 +/// How loud world sounds are for the user while the world is frozen, as a share of normal. +#define SANDY_FREEZE_SOUND_VOLUME 0.25 +/// Tiles past the edge of the user's view that still get slowed weather, so a step never shows a fast edge. +#define SANDY_WEATHER_VIEW_MARGIN 1 + +/// The field `listener` is the user of, if any. +/proc/cyberware_field_of_user(mob/listener) + for(var/datum/proximity_monitor/advanced/timestop/sandevistan/field as anything in GLOB.cyberware_time_fields) + if(field.host == listener) + return field + +/** + * How fast `listener` hears a sound from `source`: 1 normally, the field's pace for a sound from inside the + * user's field, SANDY_FREEZE_SOUND_SPEED in the freeze. `actor` is whoever set the sound off (`usr`). + */ +/proc/cyberware_heard_speed(mob/listener, turf/source, mob/actor) + if(!isturf(source) || actor == listener) + return 1 + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = cyberware_field_of_user(listener) + if(!field) + return 1 + var/turf/here = get_turf(listener) + if(!here || source == here || source.z != here.z || get_dist(source, here) > field.current_range) + return 1 + return field.time_scale > 0 ? field.time_scale : SANDY_FREEZE_SOUND_SPEED + +/// Slows `heard` for `listener` if it comes from the slowed world. Called from playsound_local. +/proc/cyberware_dilate_heard(mob/listener, turf/source, sound/heard, mob/actor) + var/speed = cyberware_heard_speed(listener, source, actor) + if(speed == 1) + return + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = cyberware_field_of_user(listener) + field?.senses?.note_one_shot(heard) + heard.frequency = cyberware_scaled_frequency(heard.frequency, speed) + if(speed == SANDY_FREEZE_SOUND_SPEED) + heard.volume *= SANDY_FREEZE_SOUND_VOLUME + +/// `frequency` (0 for as-is, a multiplier within -100..100, or a rate in Hz) played `speed` times as fast. +/proc/cyberware_scaled_frequency(frequency, speed) + if(!frequency) + frequency = 1 + return frequency * speed + +// ----- looping sounds sent straight to a mob (weather) ----- + +/datum/looping_sound + /// The last file this loop sent straight to its mob, to slow it in place. + var/sandy_last_file + /// The volume it went at. + var/sandy_last_volume + /// The channel it went on (ash storm loops take a random one). + var/sandy_last_channel + +/// The direct branch of the base play(), done here so the channel a loop took is known and the sound can +/// go out slowed to a dilation user. +/datum/looping_sound/play(soundfile, volume_override) + if(!direct) + return ..() + var/sound/sound_to_play = sound(soundfile) + sound_to_play.channel = sound_channel || SSsounds.random_available_channel() + sound_to_play.volume = volume_override || volume //Use volume as fallback if theres no override + sandy_last_file = soundfile + sandy_last_volume = sound_to_play.volume + sandy_last_channel = sound_to_play.channel + if(length(GLOB.cyberware_time_fields) && ismob(parent)) + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = cyberware_field_of_user(parent) + if(field?.senses) + sound_to_play.volume *= field.senses.loop_volume + sound_to_play.frequency = field.senses.loop_speed + SEND_SOUND(parent, sound_to_play) + +// ========================================================================= +// THE USER'S SENSES +// ========================================================================= + +/// Lives with a dilation field and keeps its user's weather and sound loops in step with the field's pace. +/datum/sandy_senses + /// The field whose pace this follows. + var/datum/proximity_monitor/advanced/timestop/sandevistan/field + /// The user. + var/mob/living/user + /// The client the images are shown to. + var/client/viewer + /// Weathered areas in view, to the override image shown for each. + var/list/area_images = list() + /// Areas, to what their image was built from, to rebuild it only when that changes. + var/list/area_keys = list() + /// How fast the user's loops play now. + var/loop_speed = 1 + /// How loud the user's loops are now, as a share of normal. + var/loop_volume = 1 + /// Channels of world sounds sent slowed and maybe still playing, to an unslowed copy of each, to retune them. + var/list/one_shots = list() + /// The same channels, to when each went out. + var/list/one_shot_times = list() + +/datum/sandy_senses/New(datum/proximity_monitor/advanced/timestop/sandevistan/field) + src.field = field + user = field.host + RegisterSignal(user, COMSIG_MOB_LOGOUT, PROC_REF(on_logout)) + update() + +/datum/sandy_senses/Destroy() + if(user) + UnregisterSignal(user, COMSIG_MOB_LOGOUT) + set_loops(1, 1) + clear_images() + field = null + user = null + return ..() + +/// The field changed pace, or ticked: bring the loops and the weather in line. +/datum/sandy_senses/proc/update() + if(field.time_scale > 0) + set_loops(field.time_scale, 1) + else + set_loops(SANDY_FREEZE_SOUND_SPEED, SANDY_FREEZE_SOUND_VOLUME) + refresh_weather() + +/// Signal proc for [COMSIG_MOB_LOGOUT]: the client keeps its images when it leaves, so take them back. +/datum/sandy_senses/proc/on_logout(datum/source) + SIGNAL_HANDLER + clear_images() + +// ----- sound loops ----- + +/// Plays the user's weather and ship-hum loops at `speed` and `volume` times normal, in place. +/datum/sandy_senses/proc/set_loops(speed, volume) + if(loop_speed == speed && loop_volume == volume) + return + loop_speed = speed + loop_volume = volume + if(!user.client) + return + retune_one_shots(speed, volume) + for(var/datum/component/area_sound_manager/manager as anything in user.GetComponents(/datum/component/area_sound_manager)) + var/datum/looping_sound/loop = manager.our_loop + if(!loop?.direct || !loop.sandy_last_channel || !loop.sandy_last_file) + continue + update_channel(loop.sandy_last_file, loop.sandy_last_channel, loop.sandy_last_volume * volume, speed) + var/buzz = user.client.current_ambient_sound + var/area/here = get_area(user) + if(buzz && here?.ambient_buzz == buzz) + var/buzz_pref = user.client.prefs.read_preference(/datum/preference/numeric/volume/sound_ship_ambience_volume) + update_channel(buzz, CHANNEL_BUZZ, here.ambient_buzz_vol * (buzz_pref / 100) * volume, speed, TRUE) + +/// Notes a world sound about to go out slowed (`heard` still unslowed), to speed it back up later. +/datum/sandy_senses/proc/note_one_shot(sound/heard) + if(!heard.channel || heard.repeat) + return + var/sound/copy = sound(heard.file, 0, 0, heard.channel, heard.volume) + copy.frequency = heard.frequency + copy.x = heard.x + copy.y = heard.y + copy.z = heard.z + copy.falloff = heard.falloff + copy.environment = heard.environment + copy.echo = heard.echo + one_shots["[heard.channel]"] = copy + one_shot_times["[heard.channel]"] = world.time + +/// World sounds still playing from earlier in the effect take the new pace: full speed when it ends. +/datum/sandy_senses/proc/retune_one_shots(speed, volume) + for(var/channel in one_shots.Copy()) + if(world.time - one_shot_times[channel] > 15 SECONDS) // long finished + one_shots -= channel + one_shot_times -= channel + continue + var/sound/copy = one_shots[channel] + var/sound/update = sound(copy.file, 0, 0, copy.channel, copy.volume * volume) + update.frequency = cyberware_scaled_frequency(copy.frequency, speed) // never 0: an update with 0 keeps the old pace + update.x = copy.x + update.y = copy.y + update.z = copy.z + update.falloff = copy.falloff + update.environment = copy.environment + update.echo = copy.echo + update.status = SOUND_UPDATE + SEND_SOUND(user, update) + if(speed == 1) + one_shots.Cut() + one_shot_times.Cut() + +/// Changes the pace and volume of what is playing on `channel` for the user, without restarting it. +/datum/sandy_senses/proc/update_channel(file, channel, volume, speed, repeat = FALSE) + var/sound/update = sound(file, repeat, 0, channel, volume) + update.frequency = speed + update.status = SOUND_UPDATE + SEND_SOUND(user, update) + +// ----- weather ----- + +/// Shows slowed weather over every weathered area in the user's view, and drops images for the rest. +/datum/sandy_senses/proc/refresh_weather(force = FALSE) + var/client/now_viewer = user.client + if(viewer && viewer != now_viewer) + clear_images() + viewer = now_viewer + if(!viewer && !force) + return + var/tag = cyberware_weather_tag(field.time_scale) + var/list/wanted = list() + var/turf/centre = get_turf(user) + if(centre) + var/list/weathers = list() + for(var/datum/weather/weather as anything in SSweather.processing) + if(weather.stage != END_STAGE && length(weather.overlay_cache) && (centre.z in weather.impacted_z_levels)) + weathers += weather + if(length(weathers)) + var/list/size = getviewsize(viewer?.view || world.view) + var/reach_x = round(size[1] / 2) + SANDY_WEATHER_VIEW_MARGIN + var/reach_y = round(size[2] / 2) + SANDY_WEATHER_VIEW_MARGIN + var/list/seen = list() + for(var/turf/spot as anything in block(max(centre.x - reach_x, 1), max(centre.y - reach_y, 1), centre.z, min(centre.x + reach_x, world.maxx), min(centre.y + reach_y, world.maxy), centre.z)) + seen[spot.loc] = TRUE + for(var/area/place as anything in seen) + var/list/caches = list() + for(var/datum/weather/weather as anything in weathers) + if(weather.impacted_areas_lookup[place]) + caches += list(weather.overlay_cache) + if(length(caches)) + wanted[place] = caches + for(var/area/place as anything in area_images.Copy()) + if(!wanted[place]) + drop_image(place) + for(var/area/place as anything in wanted) + var/list/caches = wanted[place] + var/key = "[tag]|[length(place.overlays)]" + for(var/list/cache as anything in caches) + key += "|[REF(cache)]" + if(area_keys[place] == key) + continue + drop_image(place) + var/image/slowed = cyberware_slowed_weather_image(place, tag) + area_images[place] = slowed + area_keys[place] = key + viewer?.images += slowed + +/// Takes `place`'s image off the user's screen. +/datum/sandy_senses/proc/drop_image(area/place) + var/image/slowed = area_images[place] + if(slowed) + viewer?.images -= slowed + area_images -= place + area_keys -= place + +/// Takes every image off the user's screen: the real weather shows again. +/datum/sandy_senses/proc/clear_images() + if(viewer) + for(var/area/place as anything in area_images) + viewer.images -= area_images[place] + area_images.Cut() + area_keys.Cut() + viewer = null + +/// The sandy_weather.dmi suffix for a field running at `scale`: a still frame in the freeze, else the nearest baked pace. +/proc/cyberware_weather_tag(scale) + if(scale <= 0) + return "still" + if(scale <= 0.27) + return "s20" + if(scale <= 0.42) + return "s35" + return "s50" + +/// An override image of `place` as it looks now, with its weather overlays swapped for slowed (`tag`) copies. +/proc/cyberware_slowed_weather_image(area/place, tag) + var/static/list/baked + if(!baked) + baked = list() + for(var/state in icon_states(SANDY_WEATHER_ICON)) + baked[state] = TRUE + var/image/slowed = image(loc = place) + slowed.appearance = place.appearance + slowed.override = TRUE + var/list/swapped = list() + for(var/overlay in slowed.overlays) + var/state + if(overlay:icon == 'icons/effects/weather_effects.dmi') + state = "[overlay:icon_state]_[tag]" + else if(overlay:icon == 'icons/effects/glow_weather.dmi') + state = "glow_[overlay:icon_state]_[tag]" + if(!state || !baked[state]) + swapped += overlay + continue + var/mutable_appearance/copy = new(overlay) + copy.icon = SANDY_WEATHER_ICON + copy.icon_state = state + swapped += copy + slowed.overlays = swapped + return slowed + +#undef SANDY_WEATHER_ICON +#undef SANDY_FREEZE_SOUND_SPEED +#undef SANDY_FREEZE_SOUND_VOLUME +#undef SANDY_WEATHER_VIEW_MARGIN diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index 6d7c10d1040..f35ad6fc60d 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -345,6 +345,8 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/list/surging = list() /// Held mobs, to the tags of their slowed hv_vis_play() animations, to drop if they break free. var/list/slowed_tags = list() + /// The user's slowed weather and sound (ware_sandy_senses.dm). + var/datum/sandy_senses/senses /datum/proximity_monitor/advanced/timestop/sandevistan/New(atom/_host, range, _ignore_if_not_on_turf = TRUE, list/immune, antimagic_flags, channelled, start_scale = 0) time_scale = start_scale @@ -354,8 +356,11 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) STOP_PROCESSING(SSfastprocess, src) // tick() runs on tick_timer instead tick_timer = addtimer(CALLBACK(src, PROC_REF(tick)), 0.2 SECONDS, TIMER_LOOP | TIMER_STOPPABLE | TIMER_DELETE_ME) RegisterSignal(host, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE, PROC_REF(on_user_fire)) + if(ismob(host)) + senses = new(src) /datum/proximity_monitor/advanced/timestop/sandevistan/Destroy() + QDEL_NULL(senses) deltimer(tick_timer) tick_timer = null if(host) @@ -731,6 +736,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) stop_mech(mech) else start_mech(mech) + senses?.update() /datum/proximity_monitor/advanced/timestop/sandevistan/process() return PROCESS_KILL @@ -761,6 +767,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) if(thaw_at && world.time >= thaw_at) thaw_at = 0 set_time_scale(slow_scale) + senses?.refresh_weather() /datum/proximity_monitor/advanced/timestop/sandevistan/into_the_negative_zone(atom/A) A.add_atom_colour(cyberware_time_stop_tint(), TEMPORARY_COLOUR_PRIORITY) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm index 16c7b40edf5..c0a86c93561 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -281,3 +281,94 @@ user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) raider.remove_status_effect(/datum/status_effect/cyberware_cascade_window) raider.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + +/// The user hears the slowed world slowed (ware_sandy_senses.dm): world sounds in the field take its pace and +/// a quiet low rumble in the freeze; their own sounds, sounds from outside the field and other listeners don't. +/datum/unit_test/voidcrew_cyberware_timestop/heard_slowed + +/datum/unit_test/voidcrew_cyberware_timestop/heard_slowed/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + var/mob/living/carbon/human/npc/raider = allocate(/mob/living/carbon/human/npc, spot(3, 0)) + var/turf/far = locate(user.x + 40, user.y, user.z) || locate(1, user.y, user.z) + TEST_ASSERT_EQUAL(cyberware_heard_speed(user, get_turf(raider), raider), 1, "A sound was slowed with no dilation running") + var/datum/status_effect/cyberware_cascade_window/window = surge(user) + sleep(0.1 SECONDS) + TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield + TEST_ASSERT_NOTNULL(field.senses, "The field has no senses for its user") + + // The freeze: a quiet, low rumble. + var/frozen_speed = cyberware_heard_speed(user, get_turf(raider), raider) + TEST_ASSERT(frozen_speed > 0 && frozen_speed < 0.5, "A world sound in the freeze played at [frozen_speed]") + var/sound/shot = sound('sound/items/weapons/gun/pistol/shot.ogg') + shot.volume = 100 + cyberware_dilate_heard(user, get_turf(raider), shot, raider) + TEST_ASSERT(shot.frequency == frozen_speed, "A world sound in the freeze went out at frequency [shot.frequency]") + TEST_ASSERT(shot.volume < 50, "A world sound in the freeze stayed loud ([shot.volume])") + TEST_ASSERT_EQUAL(field.senses.loop_speed, frozen_speed, "The user's loops didn't slow in the freeze") + + // The user's own sounds, other listeners and far sounds stay as they are. + TEST_ASSERT_EQUAL(cyberware_heard_speed(user, get_turf(user), raider), 1, "A sound on the user's own tile was slowed") + TEST_ASSERT_EQUAL(cyberware_heard_speed(user, get_turf(raider), user), 1, "A sound the user set off was slowed") + TEST_ASSERT_EQUAL(cyberware_heard_speed(raider, get_turf(raider), null), 1, "Someone other than the user heard it slowed") + if(far && get_dist(far, user) > field.current_range) + TEST_ASSERT_EQUAL(cyberware_heard_speed(user, far, null), 1, "A sound from outside the field was slowed") + TEST_ASSERT_EQUAL(cyberware_scaled_frequency(44100, 0.5), 22050, "A rate in Hz didn't scale") + TEST_ASSERT_EQUAL(cyberware_scaled_frequency(0, 0.2), 0.2, "An as-is sound didn't scale") + + sleep(1.2 SECONDS) // into the 20% slow-mo + TEST_ASSERT_EQUAL(cyberware_heard_speed(user, get_turf(raider), raider), field.time_scale, "A world sound didn't take the slow-mo pace") + TEST_ASSERT_EQUAL(field.senses.loop_speed, field.time_scale, "The user's loops didn't take the slow-mo pace") + + user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) + user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + TEST_ASSERT_NULL(window.time_stop, "Test fixture: the dilation outlived its window") + TEST_ASSERT_EQUAL(cyberware_heard_speed(user, get_turf(raider), raider), 1, "Sounds stayed slowed after the dilation") + +/// The user sees the weather slowed through a private override image per area; the area itself never changes, +/// and the images go when the dilation ends. +/datum/unit_test/voidcrew_cyberware_timestop/weather_slowed + +/datum/unit_test/voidcrew_cyberware_timestop/weather_slowed/Run() + var/mob/living/carbon/human/user = make_user(spot(1, 1), "sandytimestopuser") + var/area/place = get_area(user) + var/datum/weather/rain_storm/rain = new(list(user.z), list("areas" = list(place), "weather_flags" = WEATHER_INDOORS)) + TEST_ASSERT(rain.impacted_areas_lookup[place], "Test fixture: the rain didn't take the test room") + rain.stage = MAIN_STAGE + rain.update_areas() + SSweather.processing |= rain + var/list/area_overlays = place.overlays.Copy() + + var/datum/status_effect/cyberware_cascade_window/window = surge(user) + sleep(0.1 SECONDS) + TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield + field.senses.refresh_weather(force = TRUE) // no client in a unit test + var/image/frozen = field.senses.area_images[place] + TEST_ASSERT_NOTNULL(frozen, "The rained-on area got no image") + TEST_ASSERT(frozen.override, "The weather image doesn't replace the area's look") + TEST_ASSERT(weather_states(frozen).Find("rain_high_still"), "The freeze didn't show still rain: [jointext(weather_states(frozen), ",")]") + TEST_ASSERT(!weather_states(frozen).Find("rain_high"), "The fast rain is still in the freeze image") + TEST_ASSERT_EQUAL(length(place.overlays), length(area_overlays), "The area's own overlays changed") + + sleep(1.2 SECONDS) // into the 20% slow-mo + field.senses.refresh_weather(force = TRUE) + var/image/slowed = field.senses.area_images[place] + TEST_ASSERT(weather_states(slowed).Find("rain_high_s20"), "The slow-mo didn't show slowed rain: [jointext(weather_states(slowed), ",")]") + + user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) + user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + TEST_ASSERT_NULL(window.time_stop, "Test fixture: the dilation outlived its window") + TEST_ASSERT_NULL(field.senses, "The senses outlived the dilation") + TEST_ASSERT_EQUAL(length(place.overlays), length(area_overlays), "The area's own overlays changed after the dilation") + + SSweather.processing -= rain + rain.stage = END_STAGE + rain.update_areas() + qdel(rain) + +/// The icon states of `slowed`'s overlays. +/datum/unit_test/voidcrew_cyberware_timestop/weather_slowed/proc/weather_states(image/slowed) + . = list() + for(var/overlay in slowed.overlays) + . += overlay:icon_state From 1b2b68b6ec09db26a2bf2954656e9548a81fa03b Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 01:47:55 -0800 Subject: [PATCH 14/33] Run the Sandevistan slow-mo for the whole surge with no freeze, and let a second press end it The lone surge now slows the world from the moment it opens to the end of the window (street 6 s at 50%, service 7 s at 35%, Cascade 8 s at 20%). The freeze is gone on every model: no frozen-AI traits, paused or hung shots, still weather frames, quiet freeze sound, mech shutdown, turret lock or animation hold. Pressing the surge again ends a player's window early and starts the crash; NPC lattices never turn themselves off. --- .../modules/cyberware/icons/sandy_weather.dmi | Bin 85117 -> 77562 bytes voidcrew/modules/cyberware/ware_legend.dm | 30 +- voidcrew/modules/cyberware/ware_line_sandy.dm | 4 - .../modules/cyberware/ware_sandy_senses.dm | 50 +-- .../modules/cyberware/ware_sandy_timestop.dm | 316 +++++------------- voidcrew/modules/hyperviolence/_core.dm | 14 +- .../hyperviolence/visuals/_motion_core.dm | 35 +- .../hyperviolence/visuals/blood_motion.dm | 4 +- .../hyperviolence/visuals/death_falls.dm | 2 +- .../hyperviolence/visuals/execution_motion.dm | 8 +- .../hyperviolence/visuals/flying_gore.dm | 14 +- .../unit_tests/voidcrew_cyberware_timestop.dm | 284 ++++++++++------ 12 files changed, 334 insertions(+), 427 deletions(-) diff --git a/voidcrew/modules/cyberware/icons/sandy_weather.dmi b/voidcrew/modules/cyberware/icons/sandy_weather.dmi index 0b424049e700ba56c308e62fe91e8a61822b9c6e..4d3f4fae89a385669442378cbae905aaf2f179cd 100644 GIT binary patch literal 77562 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

(`N)j-AHYEsW> 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 literal 85117 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%0DTHsbE~b$9NT6MC<(5(Yvp|^i-7&>9=`2Ucx1o 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 I7@vszU&Sj4@Bjb+ diff --git a/voidcrew/modules/cyberware/ware_legend.dm b/voidcrew/modules/cyberware/ware_legend.dm index bed5d4a5298..3b5acf17a59 100644 --- a/voidcrew/modules/cyberware/ware_legend.dm +++ b/voidcrew/modules/cyberware/ware_legend.dm @@ -90,12 +90,7 @@ var/window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade /// What the bearer reads on activation. var/online_message = "CASCADE ONLINE." - // When no other player is near, the world slows (ware_sandy_timestop.dm): - /// how long it freezes first, - var/dilation_freeze = 1 SECONDS - /// how long slow-mo runs after that, - var/dilation_time = 3 SECONDS - /// and how fast the world runs in slow-mo. + /// How fast the world runs through the window when no other player is near (ware_sandy_timestop.dm). var/dilation_scale = 0.2 /// How much faster than normal the bearer runs in the window. A surging mob in someone else's field runs at its /// multiplier over theirs. @@ -135,10 +130,29 @@ cooldown_time = 60 SECONDS click_to_activate = FALSE +/// Whether a press now would end a running window: a player's own press only. An NPC's AI never turns its surge off. +/datum/action/cooldown/cyberware/cascade_surge/proc/can_cut_short() + if(!isliving(owner)) + return FALSE + var/mob/living/surger = owner + return !!surger.ckey && !!surger.has_status_effect(/datum/status_effect/cyberware_cascade_window) + +/// The cooldown starts as the window opens, so pressing again to end the window early looks past it. +/datum/action/cooldown/cyberware/cascade_surge/IsAvailable(feedback = FALSE) + if(!can_cut_short()) + return ..() + var/cooldown_end = next_use_time + next_use_time = 0 + . = ..() + next_use_time = cooldown_end + /datum/action/cooldown/cyberware/cascade_surge/Activate(atom/target) if(!isliving(owner)) return FALSE var/mob/living/surger = owner + if(can_cut_short()) // the second press: back to real time, and the crash + surger.remove_status_effect(/datum/status_effect/cyberware_cascade_window) + return TRUE if(surger.has_status_effect(/datum/status_effect/cyberware_cascade_window) || surger.has_status_effect(/datum/status_effect/cyberware_cascade_crash)) surger.balloon_alert(surger, "lattice still cycling!") return FALSE @@ -198,8 +212,6 @@ online_message = lattice.online_message crash_duration = lattice.crash_duration crash_stamina = lattice.crash_stamina - dilation_freeze = lattice.dilation_freeze - dilation_time = lattice.dilation_time dilation_scale = lattice.dilation_scale time_multiplier = lattice.time_multiplier return ..() @@ -256,7 +268,7 @@ steps_taken++ if(steps_taken % afterimage_interval) // cheaper chrome leaves a sparser trail return - if(time_stop || dilated) // a surge that slowed the world keeps its green trail to the end of the window + if(time_stop) // green while the world is slowed stopped_afterimage(old_loc) return trail_hue = cyberware_time_trail_hue(owner, trail_hue) // ware_sandy_timestop.dm diff --git a/voidcrew/modules/cyberware/ware_line_sandy.dm b/voidcrew/modules/cyberware/ware_line_sandy.dm index ac92f7b4efb..6065c983024 100644 --- a/voidcrew/modules/cyberware/ware_line_sandy.dm +++ b/voidcrew/modules/cyberware/ware_line_sandy.dm @@ -52,8 +52,6 @@ afterimage_alpha = 120 window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade/aftermarket online_message = "REFLEX RIG ONLINE." - dilation_freeze = 0 - dilation_time = 2 SECONDS dilation_scale = 0.5 time_multiplier = 2 @@ -93,8 +91,6 @@ afterimage_alpha = 160 window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade/aftermarket online_message = "LATTICE ONLINE." - dilation_freeze = 0 - dilation_time = 3 SECONDS dilation_scale = 0.35 time_multiplier = 1 / 0.35 diff --git a/voidcrew/modules/cyberware/ware_sandy_senses.dm b/voidcrew/modules/cyberware/ware_sandy_senses.dm index f3875b85562..0c2cc49b8f8 100644 --- a/voidcrew/modules/cyberware/ware_sandy_senses.dm +++ b/voidcrew/modules/cyberware/ware_sandy_senses.dm @@ -6,21 +6,16 @@ * Sound: a sound from a tile inside the field reaches the user pitched down and slowed by the field's scale * (the `frequency` of the sound datum, set in playsound_local). The user's own sounds stay normal: anything * from their own tile, and anything their own action set off (`usr` is the user: their gun, their swings, - * their callbacks). During the freeze the world is a low, quiet rumble. The weather and ship-hum loops already - * playing on the user's channels are slowed in place with SOUND_UPDATE, and the loop's next segments go out + * their callbacks). The weather and ship-hum loops already playing on the user's channels are slowed in place with SOUND_UPDATE, and the loop's next segments go out * slowed too. * * Weather: area overlays are shared by everyone on a planet, so they are left alone. Each weathered area in * the user's view gets one override image, shown to the user's client only: a copy of the area's appearance - * with every weather overlay swapped for a pre-baked slowed (or, in the freeze, still) copy from + * with every weather overlay swapped for a pre-baked slowed copy from * sandy_weather.dmi. There is no particle weather to slow. */ #define SANDY_WEATHER_ICON 'voidcrew/modules/cyberware/icons/sandy_weather.dmi' -/// How fast world sounds play for the user while the world is frozen. -#define SANDY_FREEZE_SOUND_SPEED 0.15 -/// How loud world sounds are for the user while the world is frozen, as a share of normal. -#define SANDY_FREEZE_SOUND_VOLUME 0.25 /// Tiles past the edge of the user's view that still get slowed weather, so a step never shows a fast edge. #define SANDY_WEATHER_VIEW_MARGIN 1 @@ -32,7 +27,7 @@ /** * How fast `listener` hears a sound from `source`: 1 normally, the field's pace for a sound from inside the - * user's field, SANDY_FREEZE_SOUND_SPEED in the freeze. `actor` is whoever set the sound off (`usr`). + * user's field. `actor` is whoever set the sound off (`usr`). */ /proc/cyberware_heard_speed(mob/listener, turf/source, mob/actor) if(!isturf(source) || actor == listener) @@ -43,7 +38,7 @@ var/turf/here = get_turf(listener) if(!here || source == here || source.z != here.z || get_dist(source, here) > field.current_range) return 1 - return field.time_scale > 0 ? field.time_scale : SANDY_FREEZE_SOUND_SPEED + return field.time_scale /// Slows `heard` for `listener` if it comes from the slowed world. Called from playsound_local. /proc/cyberware_dilate_heard(mob/listener, turf/source, sound/heard, mob/actor) @@ -53,8 +48,6 @@ var/datum/proximity_monitor/advanced/timestop/sandevistan/field = cyberware_field_of_user(listener) field?.senses?.note_one_shot(heard) heard.frequency = cyberware_scaled_frequency(heard.frequency, speed) - if(speed == SANDY_FREEZE_SOUND_SPEED) - heard.volume *= SANDY_FREEZE_SOUND_VOLUME /// `frequency` (0 for as-is, a multiplier within -100..100, or a rate in Hz) played `speed` times as fast. /proc/cyberware_scaled_frequency(frequency, speed) @@ -86,7 +79,6 @@ if(length(GLOB.cyberware_time_fields) && ismob(parent)) var/datum/proximity_monitor/advanced/timestop/sandevistan/field = cyberware_field_of_user(parent) if(field?.senses) - sound_to_play.volume *= field.senses.loop_volume sound_to_play.frequency = field.senses.loop_speed SEND_SOUND(parent, sound_to_play) @@ -108,8 +100,6 @@ var/list/area_keys = list() /// How fast the user's loops play now. var/loop_speed = 1 - /// How loud the user's loops are now, as a share of normal. - var/loop_volume = 1 /// Channels of world sounds sent slowed and maybe still playing, to an unslowed copy of each, to retune them. var/list/one_shots = list() /// The same channels, to when each went out. @@ -124,18 +114,15 @@ /datum/sandy_senses/Destroy() if(user) UnregisterSignal(user, COMSIG_MOB_LOGOUT) - set_loops(1, 1) + set_loops(1) clear_images() field = null user = null return ..() -/// The field changed pace, or ticked: bring the loops and the weather in line. +/// The field started: bring the loops and the weather in line with its pace. /datum/sandy_senses/proc/update() - if(field.time_scale > 0) - set_loops(field.time_scale, 1) - else - set_loops(SANDY_FREEZE_SOUND_SPEED, SANDY_FREEZE_SOUND_VOLUME) + set_loops(field.time_scale) refresh_weather() /// Signal proc for [COMSIG_MOB_LOGOUT]: the client keeps its images when it leaves, so take them back. @@ -145,25 +132,24 @@ // ----- sound loops ----- -/// Plays the user's weather and ship-hum loops at `speed` and `volume` times normal, in place. -/datum/sandy_senses/proc/set_loops(speed, volume) - if(loop_speed == speed && loop_volume == volume) +/// Plays the user's weather and ship-hum loops at `speed` times normal, in place. +/datum/sandy_senses/proc/set_loops(speed) + if(loop_speed == speed) return loop_speed = speed - loop_volume = volume if(!user.client) return - retune_one_shots(speed, volume) + retune_one_shots(speed) for(var/datum/component/area_sound_manager/manager as anything in user.GetComponents(/datum/component/area_sound_manager)) var/datum/looping_sound/loop = manager.our_loop if(!loop?.direct || !loop.sandy_last_channel || !loop.sandy_last_file) continue - update_channel(loop.sandy_last_file, loop.sandy_last_channel, loop.sandy_last_volume * volume, speed) + update_channel(loop.sandy_last_file, loop.sandy_last_channel, loop.sandy_last_volume, speed) var/buzz = user.client.current_ambient_sound var/area/here = get_area(user) if(buzz && here?.ambient_buzz == buzz) var/buzz_pref = user.client.prefs.read_preference(/datum/preference/numeric/volume/sound_ship_ambience_volume) - update_channel(buzz, CHANNEL_BUZZ, here.ambient_buzz_vol * (buzz_pref / 100) * volume, speed, TRUE) + update_channel(buzz, CHANNEL_BUZZ, here.ambient_buzz_vol * (buzz_pref / 100), speed, TRUE) /// Notes a world sound about to go out slowed (`heard` still unslowed), to speed it back up later. /datum/sandy_senses/proc/note_one_shot(sound/heard) @@ -181,14 +167,14 @@ one_shot_times["[heard.channel]"] = world.time /// World sounds still playing from earlier in the effect take the new pace: full speed when it ends. -/datum/sandy_senses/proc/retune_one_shots(speed, volume) +/datum/sandy_senses/proc/retune_one_shots(speed) for(var/channel in one_shots.Copy()) if(world.time - one_shot_times[channel] > 15 SECONDS) // long finished one_shots -= channel one_shot_times -= channel continue var/sound/copy = one_shots[channel] - var/sound/update = sound(copy.file, 0, 0, copy.channel, copy.volume * volume) + var/sound/update = sound(copy.file, 0, 0, copy.channel, copy.volume) update.frequency = cyberware_scaled_frequency(copy.frequency, speed) // never 0: an update with 0 keeps the old pace update.x = copy.x update.y = copy.y @@ -274,10 +260,8 @@ area_keys.Cut() viewer = null -/// The sandy_weather.dmi suffix for a field running at `scale`: a still frame in the freeze, else the nearest baked pace. +/// The sandy_weather.dmi suffix for a field running at `scale`: the nearest baked pace. /proc/cyberware_weather_tag(scale) - if(scale <= 0) - return "still" if(scale <= 0.27) return "s20" if(scale <= 0.42) @@ -312,6 +296,4 @@ return slowed #undef SANDY_WEATHER_ICON -#undef SANDY_FREEZE_SOUND_SPEED -#undef SANDY_FREEZE_SOUND_VOLUME #undef SANDY_WEATHER_VIEW_MARGIN diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index e7c2c901b39..0ba88177385 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -2,81 +2,75 @@ * # The Cascade line's time dilation * * When a Cascade-line surge (ware_legend.dm, ware_line_sandy.dm) opens and no other player is within - * SANDY_STOP_WITNESS_RANGE tiles, the world around the user slows down: + * SANDY_STOP_WITNESS_RANGE tiles, the world around the user runs in slow motion for the whole window: * - * model freeze then slow-mo - * street - 50% for 2 s - * service - 35% for 3 s - * Cascade T4 1 s 20% for 3 s + * model window world runs at + * street 6 s 50% + * service 7 s 35% + * Cascade T4 8 s 20% * - * The rest of the window then runs as normal, then the crash. With another player near, the surge is the - * plain one. Only a player's surge does this (the user needs a ckey): an NPC criminal's lattice never does. + * Pressing the surge again ends the window early, slow-mo and all, and the crash follows as usual. With another + * player near, the surge is the plain one; if one comes in range mid-surge, the slow-mo ends there and the surge + * carries on without it. Only a player's surge does this (the user needs a ckey): an NPC criminal's lattice never does. * * Built on tg's time stop field (code/datums/proximity_monitor/fields/timestop.dm), hosted on the user so - * it follows them. Anything it touches stays slowed (or frozen) until the effect ends, even once the user - * walks off. The field re-checks for players every tick and ends at once if one comes in. + * it follows them. Anything it touches stays slowed until the effect ends, even once the user walks off. The + * field re-checks for players every tick and ends at once if one comes in. * - * No shortcuts on people. A frozen mob gets tg's own TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED and - * TRAIT_AI_PAUSED, never a fake state or a Stun. A slowed mob moves under a movespeed modifier, and every - * tick the field pushes its timing deadlines back by the time it lost (next_move, ability cooldowns, AI - * behaviour cooldowns and the blackboard deadlines listed in dilated_blackboard_keys(), ranged cooldowns). - * Frozen and slowed bodies take damage, wounds and bleeding through tg as normal. + * No shortcuts on people. A slowed mob moves under a movespeed modifier, and every tick the field pushes its + * timing deadlines back by the time it lost (next_move, ability cooldowns, AI behaviour cooldowns and the + * blackboard deadlines listed in dilated_blackboard_keys(), ranged cooldowns). Slowed bodies take damage, + * wounds and bleeding through tg as normal. * * Everything else in the field runs on the same slowed clock, through a few helpers below: * - cyberware_dilated_time() and cyberware_dilated_steps() turn a length of local time into real time, exact - * through the freeze, the slow-mo and the end. Hyperviolence gore uses them (flinches, death throes, blood - * paint, flying gibs), and so do a mech's weapon and melee cooldowns and a gun's fire delay. + * through the slow-mo and its end. Hyperviolence gore uses them (flinches, death throes, blood paint, flying + * gibs), and so do a mech's weapon and melee cooldowns and a gun's fire delay. * - cyberware_time_lost() stretches do_after for a slowed body (VOIDCREW EDIT in do_after). * - Burst shots wait on a burst clock the field pushes back (cyberware_burst_wait()), so a burst queued before * the slow-mo slows with it: guns (gun.dm) and basic mob ranged attacks (ranged_attacks.dm). - * - The field pushes back mech move cooldowns, turret fire cooldowns and lit grenade fuses (_grenade.dm), and - * shuts a mech down for the freeze. + * - The field pushes back mech move cooldowns, turret fire cooldowns and lit grenade fuses (_grenade.dm). * Things in hand stay held until the effect ends; gore and blood that merely stand in the field are slowed * while they are within its range. The user and anything the user carries are never slowed. + * Nothing is ever frozen: the field never sends tg's time stop signals, so a supermatter keeps running. * - * Bosses (a finisher user, or a boss-rank human NPC) are held for SANDY_STOP_BOSS_HOLD at most. + * Bosses (a finisher user, or a boss-rank human NPC) are slowed for SANDY_STOP_BOSS_HOLD at most. * * Each surge model speeds its bearer up by a time multiplier (the lattice's time_multiplier): street 2x, service * about 2.86x, Cascade 5x. A mob on its own surge (a bounty NPC's lattice) runs at its multiplier over the user's, - * at most SANDY_STOP_MAX_RELATIVE: a street NPC in a Cascade's field at 40%, an equal tier at 100% (left alone), - * a Cascade NPC in a street user's field at 200% (2.5x, capped). The user's freeze doesn't change that: a surging - * mob keeps its relative rate through it, so the freeze only stops those with no surge. Faster than the user runs - * the same machinery the other way (its deadlines, cooldowns, walk and gore come sooner). A surging mob is - * never tinted, at any rate. If it surges while already held, the field lets it go at once, drops every slow it - * was carrying (surge_free()) and takes it back at its relative rate. When its window closes the field takes it at - * the field's own pace, unless it is dead: a corpse freed by a surge is left as it fell. + * at most SANDY_STOP_MAX_RELATIVE, for as long as both windows are open: a street NPC in a Cascade's field at 40%, + * an equal tier at 100% (left alone), a Cascade NPC in a street user's field at 200% (2.5x, capped). Faster than + * the user runs the same machinery the other way (its deadlines, cooldowns, walk and gore come sooner). A surging + * mob is never tinted, at any rate. If it surges while already held, the field lets it go at once, drops every + * slow it was carrying (surge_free()) and takes it back at its relative rate. When its window closes the field + * takes it at the field's own pace, unless it is dead: a corpse freed by a surge is left as it fell. * * The user's reflex dodge (ware_pro_combat.dm) is off while the world is slowed: the speed gap already does that - * job. It comes back for the rest of the window. + * job. It comes back if the slow-mo ends before the window does. * * Look: affected things get a grey-green tint instead of tg's inversion. Floor items, blood decals and the * user are never touched, so they keep their colour, and so do the user's own shots. Every bullet in the field * flies at the field's pace, whoever fired it: the user's and a surging mob's crawl in the slow-mo like the rest - * (a faster surger fires sooner, but its bullets still crawl), untinted. Shots the user fires while the world is - * frozen fly a tile, hang there, and go on when it starts moving again. A shot leaving the field gets its speed back. + * (a faster surger fires sooner, but its bullets still crawl), untinted. A shot leaving the field gets its speed back. */ /// Another player within this many tiles of the user means no dilation. #define SANDY_STOP_WITNESS_RANGE 11 /// Radius of the field around the user (a widescreen view is 9 tiles to each side). #define SANDY_STOP_FIELD_RANGE 9 -/// How long a boss stays frozen or slowed before it shrugs the field off. +/// How long a boss stays slowed before it shrugs the field off. #define SANDY_STOP_BOSS_HOLD (1 SECONDS) -/// How far, in pixels, the user's own shots fly before they hang in a frozen world. -#define SANDY_STOP_SHOT_HANG 32 /// The fastest a surging mob runs in someone else's field, relative to normal speed. #define SANDY_STOP_MAX_RELATIVE 2 /// Relative rates this close to 1 count as the same speed: the mob is left alone. #define SANDY_STOP_SAME_RATE 0.01 -/// Trait source for everything the dilation applies. -#define SANDY_STOP_TRAIT "sandevistan_time_stop" /// Filter name of the user's edge glow. #define SANDY_STOP_OUTLINE "sandy_time_stop_outline" /// Every atom the dilation has hold of, to its field. GLOBAL_LIST_EMPTY(cyberware_time_dilated) -/// How fast time runs for `thing`: 1 normally, 0 frozen, in between in slow-mo, above 1 for a faster surger. +/// How fast time runs for `thing`: 1 normally, below 1 in slow-mo, above 1 for a faster surger. /proc/cyberware_time_scale(atom/thing) if(isnull(thing)) return 1 @@ -110,7 +104,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) return field return null -/// Whether `bearer` is slowing the world around them right now (the freeze or the slow-mo). +/// Whether `bearer` is slowing the world around them right now. /proc/cyberware_time_stopping(mob/living/bearer) for(var/datum/proximity_monitor/advanced/timestop/sandevistan/field as anything in GLOB.cyberware_time_fields) if(field.host == bearer) @@ -158,8 +152,8 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) return 170 return hue -/// How long, in real time, `time` deciseconds of `thing`'s own time take from now. Exact through the freeze, -/// the slow-mo and the end of the effect, as the field stands now. +/// How long, in real time, `time` deciseconds of `thing`'s own time take from now. Exact through the slow-mo +/// and the end of the effect, as the field stands now. /proc/cyberware_dilated_time(atom/thing, time) var/datum/proximity_monitor/advanced/timestop/sandevistan/field = cyberware_time_field(thing) if(!field) @@ -167,9 +161,9 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) return field.real_time_for(thing, time) /** - * Real-time lengths for a chain of animation steps `times` on `thing`: list(hold, step 1, step 2, ...), or null - * when it isn't slowed. `hold` is how long it sits still first (the freeze). A looping chain just plays at the - * field's pace. `tag` names the animation, so a held mob that breaks free (surge_free()) can drop it. + * Real-time lengths for a chain of animation steps `times` on `thing`: list(step 1, step 2, ...), or null when it + * isn't slowed. A looping chain just plays at the field's pace. `tag` names the animation, so a held mob that + * breaks free (surge_free()) can drop it. */ /proc/cyberware_dilated_steps(atom/thing, list/times, loop = 1, tag) var/datum/proximity_monitor/advanced/timestop/sandevistan/field = cyberware_time_field(thing) @@ -180,16 +174,11 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/list/real = list() if(loop != 1) var/scale = field.scale_for(thing) - if(scale <= 0) - scale = field.slow_scale - real += 0 for(var/time in times) - real += time / max(scale, 0.05) + real += time / scale return real - var/hold = field.real_time_for(thing, 0) - real += hold var/local = 0 - var/reached = hold + var/reached = 0 for(var/time in times) local += time var/at = field.real_time_for(thing, local) @@ -197,8 +186,8 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) reached = at return real -/// Time `thing` lost in the last `elapsed` deciseconds: all of it frozen, none of it outside a field. Negative for a -/// surger running faster than normal: it gained time. +/// Time `thing` lost in the last `elapsed` deciseconds: none of it outside a field. Negative for a surger running +/// faster than normal: it gained time. /proc/cyberware_time_lost(atom/thing, elapsed) var/datum/proximity_monitor/advanced/timestop/sandevistan/field = GLOB.cyberware_time_dilated[thing] if(!field || elapsed <= 0) @@ -220,8 +209,6 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) /proc/cyberware_burst_wait(atom/shooter, start, gap, index) if(!GLOB.cyberware_time_dilated[shooter]) return max(start + gap * index - world.time, 0) - if(!cyberware_time_scale(shooter)) - return world.tick_lag var/due = start + cyberware_time_lag(shooter) + gap * index return due - world.time >= world.tick_lag ? due - world.time : 0 @@ -317,19 +304,16 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) freezerange = SANDY_STOP_FIELD_RANGE /// Whose dilation this is. The field is hosted on them. var/mob/living/user - /// How long the world is frozen first. - var/freeze_time = 0 - /// How fast the world runs after that. + /// How fast the world runs. var/slow_scale = 1 /// The user's own time multiplier (their surge model's), for surging mobs' relative rates. var/user_multiplier = 1 -/// `immune_atoms` must be list(user). `time` is the whole effect; `freeze` of it is frozen, the rest at `scale`. -/// `multiplier` is the user's surge model's time multiplier. -/obj/effect/timestop/sandevistan/Initialize(mapload, radius, time, list/immune_atoms, start = TRUE, silent = TRUE, freeze = 0, scale = 1, multiplier = 1) +/// `immune_atoms` must be list(user). `time` is the whole effect, all of it at `scale`. `multiplier` is the user's +/// surge model's time multiplier. +/obj/effect/timestop/sandevistan/Initialize(mapload, radius, time, list/immune_atoms, start = TRUE, silent = TRUE, scale = 1, multiplier = 1) if(length(immune_atoms)) user = immune_atoms[1] - freeze_time = freeze slow_scale = scale user_multiplier = multiplier return ..(mapload, radius, time, immune_atoms, start, TRUE) @@ -343,11 +327,8 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) QDEL_IN(src, duration) if(QDELETED(user)) return - var/datum/proximity_monitor/advanced/timestop/sandevistan/field = new(user, freezerange, TRUE, immune, antimagic_flags, FALSE, freeze_time > 0 ? 0 : slow_scale, user_multiplier) + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = new(user, freezerange, TRUE, immune, antimagic_flags, FALSE, slow_scale, user_multiplier) field.stopper = src - field.slow_scale = slow_scale - if(freeze_time > 0) - field.thaw_at = world.time + freeze_time field.ends_at = world.time + duration chronofield = field @@ -358,12 +339,8 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) /datum/proximity_monitor/advanced/timestop/sandevistan /// The effect that owns this field. Deleting it ends the dilation. var/obj/effect/timestop/sandevistan/stopper - /// How fast time runs in here right now. 0 is frozen. - var/time_scale = 0 - /// The slow-mo scale the field drops to once the freeze ends. - var/slow_scale = 1 - /// world.time the freeze ends and slow-mo starts, or 0. - var/thaw_at = 0 + /// How fast time runs in here, below 1. + var/time_scale = 1 /// world.time of the last tick, for how much time each tick dilated. var/last_tick = 0 /// The field's own clock. A timer, not SSfastprocess, which sleeps outside RUNLEVEL_GAME. @@ -382,7 +359,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/list/released = list() /// world.time the effect ends, when time runs normally again. var/ends_at = INFINITY - /// Mechs held, to whether the field shut them down for the freeze. + /// Mechs held. Their move cooldown runs slow. var/list/held_mechs = list() /// Turrets held. Their fire cooldown runs slow. var/list/held_turrets = list() @@ -399,8 +376,8 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) /// Surging mobs held at their relative rate, to that rate. Never 0, never tinted. Their shots fly at the field's pace. var/list/own_scales = list() -/datum/proximity_monitor/advanced/timestop/sandevistan/New(atom/_host, range, _ignore_if_not_on_turf = TRUE, list/immune, antimagic_flags, channelled, start_scale = 0, _user_multiplier = 1) - time_scale = start_scale +/datum/proximity_monitor/advanced/timestop/sandevistan/New(atom/_host, range, _ignore_if_not_on_turf = TRUE, list/immune, antimagic_flags, channelled, _time_scale = 1, _user_multiplier = 1) + time_scale = _time_scale > 0 ? _time_scale : 1 user_multiplier = _user_multiplier > 0 ? _user_multiplier : 1 last_tick = world.time GLOB.cyberware_time_fields += src @@ -426,35 +403,20 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) own_scales.Cut() released.Cut() -/// How long, in real time from now, `local` deciseconds of `thing`'s own time take: the rest of the freeze, -/// then the slow-mo until the effect (or a boss's hold) ends, then real time. +/// How long, in real time from now, `local` deciseconds of `thing`'s own time take: its rate in the field until +/// the effect (or a boss's hold) ends, then real time. /datum/proximity_monitor/advanced/timestop/sandevistan/proc/real_time_for(atom/thing, local) var/now = world.time var/ends = ends_at if(boss_holds[thing]) ends = min(ends, boss_holds[thing]) - if(ends <= now) - return local - var/own = own_scales[thing] - if(own) // a surger at its own steady rate, freeze or not - var/own_span = (ends - now) * own - if(local <= own_span) - return local / own - return ends - now + local - own_span - if(surging[thing]) + var/rate = scale_for(thing) + if(ends <= now || rate == 1) return local - var/at = now - var/scale = time_scale - if(scale <= 0) - at = thaw_at ? min(thaw_at, ends) : ends - scale = slow_scale - if(at < ends && scale > 0) - var/span = (ends - at) * scale - if(local <= span) - return at - now + local / scale - local -= span - at = ends - return at - now + local + var/span = (ends - now) * rate + if(local <= span) + return local / rate + return ends - now + local - span /// How fast time runs for `thing` in this field: a surger's own relative rate, 1 for a surger left alone, else the field's pace. /datum/proximity_monitor/advanced/timestop/sandevistan/proc/scale_for(atom/thing) @@ -488,8 +450,6 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) else if(isprojectile(A)) var/obj/projectile/shot = A if(shot.firer && immune[shot.firer]) - if(time_scale <= 0) // hang_shot takes these - return FALSE user_shots[shot] = TRUE tint = FALSE escape_the_negative_zone(shot) // in case it was tinted before it had a firer @@ -501,8 +461,6 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) else if(istype(A, /obj/machinery/porta_turret)) frozen_structures += A held_turrets[A] = TRUE - if(time_scale <= 0) - hold_turret_fire(A) else if((ismachinery(A) && !istype(A, /obj/machinery/light)) || isstructure(A)) frozen_structures += A // looks only else if(cyberware_lit_grenade(A)) @@ -512,14 +470,11 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) return FALSE if(A.throwing) held_throws[A] = TRUE - if(time_scale <= 0) - A.throwing.paused = TRUE GLOB.cyberware_time_dilated[A] = src frozen_things[A] = TRUE RegisterSignal(A, COMSIG_QDELETING, PROC_REF(held_gone), override = TRUE) if(tint) into_the_negative_zone(A) - SEND_SIGNAL(A, COMSIG_ATOM_TIMESTOP_FREEZE, src) return TRUE /datum/proximity_monitor/advanced/timestop/sandevistan/unfreeze_all() @@ -534,14 +489,8 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) let_go_mob(A) if(held_shots[A]) let_go_shot(A) - if(held_throws[A]) - var/datum/thrownthing/throw_datum = A.throwing - if(throw_datum) - throw_datum.paused = FALSE - held_throws -= A - if(!isnull(held_mechs[A])) - start_mech(A) - held_mechs -= A + held_throws -= A + held_mechs -= A held_turrets -= A held_grenades -= A frozen_structures -= A @@ -552,44 +501,21 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) escape_the_negative_zone(A) frozen_things -= A GLOB.cyberware_time_dilated -= A - SEND_SIGNAL(A, COMSIG_ATOM_TIMESTOP_UNFREEZE, src) // ----- mobs ----- -/// Takes hold of a mob: frozen or slowed (or sped up, for a faster surger), whichever its rate is. +/// Takes hold of a mob: slowed (or sped up, for a faster surger), whichever its rate is. /datum/proximity_monitor/advanced/timestop/sandevistan/proc/hold_mob(mob/living/victim) held_mobs[victim] = TRUE - var/rate = scale_for(victim) - if(rate <= 0) - freeze_mob(victim) - else - slow_mob(victim) - if(rate < 1 && cyberware_time_stop_boss(victim)) + slow_mob(victim) + if(scale_for(victim) < 1 && cyberware_time_stop_boss(victim)) boss_holds[victim] = world.time + SANDY_STOP_BOSS_HOLD /datum/proximity_monitor/advanced/timestop/sandevistan/proc/let_go_mob(mob/living/victim) - unfreeze_mob(victim) unslow_mob(victim) held_mobs -= victim boss_holds -= victim -/datum/proximity_monitor/advanced/timestop/sandevistan/freeze_mob(mob/living/victim) - frozen_mobs[victim] = TRUE - victim.add_traits(list(TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED, TRAIT_AI_PAUSED, TRAIT_MUTE, TRAIT_EMOTEMUTE), SANDY_STOP_TRAIT) - GLOB.move_manager.stop_looping(victim) - if(isanimal(victim)) // the old simple mob AI doesn't read TRAIT_AI_PAUSED - var/mob/living/simple_animal/animal_victim = victim - animal_victim.toggle_ai(AI_OFF) - -/datum/proximity_monitor/advanced/timestop/sandevistan/unfreeze_mob(mob/living/victim) - if(!frozen_mobs[victim]) - return - frozen_mobs -= victim - victim.remove_traits(list(TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED, TRAIT_AI_PAUSED, TRAIT_MUTE, TRAIT_EMOTEMUTE), SANDY_STOP_TRAIT) - if(isanimal(victim)) - var/mob/living/simple_animal/animal_victim = victim - animal_victim.toggle_ai(initial(animal_victim.AIStatus)) - /// Paces a mob's walk to its rate in the field: slower, or faster for a faster surger. Its timers move with it every tick(). /datum/proximity_monitor/advanced/timestop/sandevistan/proc/slow_mob(mob/living/victim) var/rate = scale_for(victim) @@ -641,7 +567,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) /** * `surger` runs its own surge at `multiplier`: at the same rate as the user it is left alone; otherwise the field - * holds it at its relative rate, slower or faster, untinted. The user's freeze doesn't touch it. + * holds it at its relative rate, slower or faster, untinted. */ /datum/proximity_monitor/advanced/timestop/sandevistan/proc/take_surger(mob/living/surger, multiplier) surging[surger] = TRUE @@ -653,7 +579,6 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) hold_mob(surger) GLOB.cyberware_time_dilated[surger] = src frozen_things[surger] = TRUE - SEND_SIGNAL(surger, COMSIG_ATOM_TIMESTOP_FREEZE, src) /** * `surger` opened its own surge at `multiplier`: if the field holds it, let it go now and drop every slow it @@ -712,29 +637,10 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) /// Takes hold of a mech and whoever is driving it. /datum/proximity_monitor/advanced/timestop/sandevistan/proc/hold_mech(obj/vehicle/sealed/mecha/mech) frozen_structures += mech - held_mechs[mech] = FALSE - if(time_scale <= 0) - stop_mech(mech) + held_mechs[mech] = TRUE for(var/mob/living/pilot in mech.occupants) freeze_atom(pilot) -/// The freeze shuts a mech down, as tg's own time stop does. -/datum/proximity_monitor/advanced/timestop/sandevistan/proc/stop_mech(obj/vehicle/sealed/mecha/mech) - if(held_mechs[mech] || mech.completely_disabled) - return - mech.completely_disabled = TRUE - held_mechs[mech] = TRUE - -/datum/proximity_monitor/advanced/timestop/sandevistan/proc/start_mech(obj/vehicle/sealed/mecha/mech) - if(!held_mechs[mech]) - return - mech.completely_disabled = FALSE - held_mechs[mech] = FALSE - -/// A frozen turret's cooldown stays a little ahead of the clock, so it can't fire until time moves. -/datum/proximity_monitor/advanced/timestop/sandevistan/proc/hold_turret_fire(obj/machinery/porta_turret/turret) - turret.last_fired = max(turret.last_fired, world.time + 0.3 SECONDS - turret.shot_delay) - /// Pushes back the clocks of held machines and grenades by `push` deciseconds. /datum/proximity_monitor/advanced/timestop/sandevistan/proc/push_machine_deadlines(push) var/now = world.time @@ -744,8 +650,6 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) for(var/obj/machinery/porta_turret/turret as anything in held_turrets) if(turret.last_fired + turret.shot_delay > now) turret.last_fired += push - if(time_scale <= 0) - hold_turret_fire(turret) for(var/obj/item/grenade/bomb as anything in held_grenades) if(bomb.cyberware_fuse_at > now) bomb.cyberware_fuse_at += push @@ -754,50 +658,29 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) /datum/proximity_monitor/advanced/timestop/sandevistan/proc/hold_shot(obj/projectile/shot) held_shots[shot] = shot.speed - var/rate = scale_for(shot) - if(rate <= 0) - shot.paused = TRUE - else - shot.speed = held_shots[shot] * rate + shot.speed = held_shots[shot] * scale_for(shot) /datum/proximity_monitor/advanced/timestop/sandevistan/proc/let_go_shot(obj/projectile/shot) shot.speed = held_shots[shot] - shot.paused = FALSE held_shots -= shot /// Signal proc for [COMSIG_PROJECTILE_FIRER_BEFORE_FIRE] on the user: watch the shot leave the barrel. /datum/proximity_monitor/advanced/timestop/sandevistan/proc/on_user_fire(datum/source, obj/projectile/shot, atom/fired_from, atom/original) SIGNAL_HANDLER - RegisterSignal(shot, COMSIG_PROJECTILE_FIRE, PROC_REF(hang_shot)) + RegisterSignal(shot, COMSIG_PROJECTILE_FIRE, PROC_REF(take_user_shot)) -/// Signal proc for [COMSIG_PROJECTILE_FIRE]: the user's shot keeps its colour but flies at the field's pace like -/// every bullet in it. In a frozen world it flies a tile, then hangs; in the slow-mo it crawls with the rest. -/datum/proximity_monitor/advanced/timestop/sandevistan/proc/hang_shot(obj/projectile/shot) +/// Signal proc for [COMSIG_PROJECTILE_FIRE]: the user's shot keeps its colour but crawls at the field's pace like +/// every bullet in it. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/take_user_shot(obj/projectile/shot) SIGNAL_HANDLER UnregisterSignal(shot, COMSIG_PROJECTILE_FIRE) if(QDELETED(shot) || shot.deletion_queued) return - if(time_scale > 0) - user_shots[shot] = TRUE - escape_the_negative_zone(shot) // in case it was tinted before it had a firer - if(!held_shots[shot]) - hold_shot(shot) - GLOB.cyberware_time_dilated[shot] = src - frozen_things[shot] = TRUE - RegisterSignal(shot, COMSIG_QDELETING, PROC_REF(held_gone), override = TRUE) - return - // A hitscan beam takes its first step itself once paused (process_hitscan) and stops there. - if(!shot.hitscan) - shot.paused = FALSE // it may have been caught before it had a firer - shot.process_movement(SANDY_STOP_SHOT_HANG) - if(QDELETED(shot) || shot.deletion_queued) - return user_shots[shot] = TRUE escape_the_negative_zone(shot) // in case it was tinted before it had a firer if(held_shots[shot]) - shot.paused = TRUE - else - hold_shot(shot) + return + hold_shot(shot) GLOB.cyberware_time_dilated[shot] = src frozen_things[shot] = TRUE RegisterSignal(shot, COMSIG_QDELETING, PROC_REF(held_gone), override = TRUE) @@ -808,7 +691,6 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) held_shots -= thing user_shots -= thing held_mobs -= thing - frozen_mobs -= thing held_throws -= thing held_mechs -= thing held_turrets -= thing @@ -823,36 +705,10 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) // ----- the clock ----- -/// Moves the whole field to a new pace: frozen things start moving, slowed things change speed. -/datum/proximity_monitor/advanced/timestop/sandevistan/proc/set_time_scale(new_scale) - time_scale = new_scale - for(var/mob/living/victim as anything in held_mobs) - if(own_scales[victim]) // a surger keeps its own rate - continue - unfreeze_mob(victim) - if(time_scale <= 0) - unslow_mob(victim) - freeze_mob(victim) - else - slow_mob(victim) - for(var/obj/projectile/shot as anything in held_shots) - shot.paused = time_scale <= 0 - if(time_scale > 0) - shot.speed = held_shots[shot] * time_scale - for(var/atom/movable/thing as anything in held_throws) - if(thing.throwing) - thing.throwing.paused = time_scale <= 0 - for(var/obj/vehicle/sealed/mecha/mech as anything in held_mechs) - if(time_scale <= 0) - stop_mech(mech) - else - start_mech(mech) - senses?.update() - /datum/proximity_monitor/advanced/timestop/sandevistan/process() return PROCESS_KILL -/// Every 0.2 s: end if a player came in, push back the timers of everything held, let bosses go, end the freeze. +/// Every 0.2 s: end if a player came in, push back the timers of everything held, let bosses go. /datum/proximity_monitor/advanced/timestop/sandevistan/proc/tick() if(QDELETED(host) || cyberware_time_stop_witnessed(host)) if(!QDELETED(stopper)) @@ -868,7 +724,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) if(push > 0) for(var/atom/movable/thing as anything in held_throws) var/datum/thrownthing/throw_datum = thing.throwing - if(throw_datum && !throw_datum.paused) + if(throw_datum) throw_datum.start_time += push push_machine_deadlines(push) check_surgers() @@ -877,9 +733,6 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) continue unfreeze_atom(boss) released[boss] = TRUE - if(thaw_at && world.time >= thaw_at) - thaw_at = 0 - set_time_scale(slow_scale) senses?.refresh_weather() /datum/proximity_monitor/advanced/timestop/sandevistan/into_the_negative_zone(atom/A) @@ -890,40 +743,35 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) // ========================================================================= /datum/status_effect/cyberware_cascade_window - /// How long the world is frozen if nobody else is near when the window opens. Copied from the lattice. - var/dilation_freeze = 1 SECONDS - /// How long slow-mo runs after the freeze. - var/dilation_time = 3 SECONDS - /// How fast the world runs in slow-mo. + /// How fast the world runs in slow-mo, if nobody else is near when the window opens. Copied from the lattice. var/dilation_scale = 0.2 /// How much faster than normal the bearer runs: sets surging mobs' relative rates in each other's fields. var/time_multiplier = 5 /// The running dilation, if any. var/obj/effect/timestop/sandevistan/time_stop - /// Whether this window slowed the world. Its trail stays green after the dilation ends. - var/dilated = FALSE -/// Slows the world if the bearer is a player and nobody else is near. Called as the window opens. +/// Slows the world for the whole window if the bearer is a player and nobody else is near. Called as the window +/// opens, while `duration` is still its length. /datum/status_effect/cyberware_cascade_window/proc/try_time_stop() cyberware_surge_frees(owner, time_multiplier) // anyone's surge runs at its own rate in someone else's field - if(dilation_freeze + dilation_time <= 0 || !owner.ckey || time_stop) + if(dilation_scale <= 0 || dilation_scale >= 1 || duration <= 0 || !owner.ckey || time_stop) return FALSE if(cyberware_time_stop_witnessed(owner)) return FALSE var/turf/here = get_turf(owner) if(!here) return FALSE - time_stop = new(here, SANDY_STOP_FIELD_RANGE, dilation_freeze + dilation_time, list(owner), TRUE, TRUE, dilation_freeze, dilation_scale, time_multiplier) + time_stop = new(here, SANDY_STOP_FIELD_RANGE, duration, list(owner), TRUE, TRUE, dilation_scale, time_multiplier) if(QDELETED(time_stop)) time_stop = null return FALSE RegisterSignal(time_stop, COMSIG_QDELETING, PROC_REF(time_stop_over)) - dilated = TRUE owner.remove_client_colour(id) // the user sees the slowed world as it is owner.add_filter(SANDY_STOP_OUTLINE, 2, outline_filter(1, "#5AFF96D2")) return TRUE -/// Signal proc for [COMSIG_QDELETING] on the dilation: time runs normally and the rest of the window carries on. +/// Signal proc for [COMSIG_QDELETING] on the dilation (a player came near): time runs normally and the rest of the +/// window carries on. /datum/status_effect/cyberware_cascade_window/proc/time_stop_over(datum/source) SIGNAL_HANDLER time_stop = null @@ -1004,7 +852,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) if(!cyberware_fuse_at) return var/wait = cyberware_fuse_at + cyberware_time_lag(src) - world.time - if(wait >= world.tick_lag || (GLOB.cyberware_time_dilated[src] && !cyberware_time_scale(src))) + if(wait >= world.tick_lag) addtimer(CALLBACK(src, PROC_REF(cyberware_fuse_done)), max(wait, world.tick_lag), TIMER_DELETE_ME) return cyberware_fuse_at = 0 @@ -1013,8 +861,6 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) #undef SANDY_STOP_WITNESS_RANGE #undef SANDY_STOP_FIELD_RANGE #undef SANDY_STOP_BOSS_HOLD -#undef SANDY_STOP_SHOT_HANG #undef SANDY_STOP_MAX_RELATIVE #undef SANDY_STOP_SAME_RATE -#undef SANDY_STOP_TRAIT #undef SANDY_STOP_OUTLINE diff --git a/voidcrew/modules/hyperviolence/_core.dm b/voidcrew/modules/hyperviolence/_core.dm index f28b6a2c84a..02245a4fddf 100644 --- a/voidcrew/modules/hyperviolence/_core.dm +++ b/voidcrew/modules/hyperviolence/_core.dm @@ -37,11 +37,11 @@ var/matrix/fallen = matrix(transform) var/matrix/upright = matrix(transform) upright.Turn(-angle) - // In a Sandevistan field (cyberware/ware_sandy_timestop.dm) it holds through the freeze and runs slow. - var/list/real = cyberware_dilated_steps(src, list(HV_DEATH_THROES_TIME, UPDATE_TRANSFORM_ANIMATION_TIME)) || list(0, HV_DEATH_THROES_TIME, UPDATE_TRANSFORM_ANIMATION_TIME) + // In a Sandevistan field (cyberware/ware_sandy_timestop.dm) it runs slow. + var/list/real = cyberware_dilated_steps(src, list(HV_DEATH_THROES_TIME, UPDATE_TRANSFORM_ANIMATION_TIME)) || list(HV_DEATH_THROES_TIME, UPDATE_TRANSFORM_ANIMATION_TIME) animate(src, transform = upright, time = 0) - animate(transform = upright, time = real[1] + real[2]) - animate(transform = fallen, time = real[3], easing = EASE_IN|EASE_OUT) + animate(transform = upright, time = real[1]) + animate(transform = fallen, time = real[2], easing = EASE_IN|EASE_OUT) // The shudder: sideways offsets that die down, each step relative to the last, ending where it began. var/static/list/shudder = list(3, -3, 2, -2, 2, -1, 1, 0) var/step_time = HV_DEATH_THROES_TIME / length(shudder) @@ -49,14 +49,12 @@ for(var/i in 1 to length(shudder)) shudder_times += step_time var/list/shudder_real = cyberware_dilated_steps(src, shudder_times) - if(shudder_real && shudder_real[1] > 0) - animate(src, pixel_w = 0, time = shudder_real[1], flags = ANIMATION_RELATIVE|ANIMATION_PARALLEL) var/offset = 0 for(var/i in 1 to length(shudder)) var/shift = shudder[i] - offset offset = shudder[i] - var/time = shudder_real ? shudder_real[i + 1] : step_time - if(i == 1 && !(shudder_real && shudder_real[1] > 0)) + var/time = shudder_real ? shudder_real[i] : step_time + if(i == 1) animate(src, pixel_w = shift, time = time, flags = ANIMATION_RELATIVE|ANIMATION_PARALLEL) else animate(pixel_w = shift, time = time, flags = ANIMATION_RELATIVE) diff --git a/voidcrew/modules/hyperviolence/visuals/_motion_core.dm b/voidcrew/modules/hyperviolence/visuals/_motion_core.dm index 1acccda933b..a597b647805 100644 --- a/voidcrew/modules/hyperviolence/visuals/_motion_core.dm +++ b/voidcrew/modules/hyperviolence/visuals/_motion_core.dm @@ -202,25 +202,15 @@ /atom/proc/hv_vis_play(tag, list/steps, loop = 1) if(!length(steps)) return - // In a Sandevistan field (cyberware/ware_sandy_timestop.dm) it plays at the field's pace, and holds still - // through the freeze once any jump to its first pose is done. + // In a Sandevistan field (cyberware/ware_sandy_timestop.dm) it plays at the field's pace. var/list/times = list() for(var/list/step as anything in steps) times += step[4] var/list/real = cyberware_dilated_steps(src, times, loop, tag) - var/hold = real ? real[1] : 0 var/started = FALSE for(var/i in 1 to length(steps)) var/list/step = steps[i] - if(hold > 0 && step[4] > 0) - var/list/still = i > 1 ? steps[i - 1] : list(pixel_w, pixel_z, transform) - if(!started) - animate(src, pixel_w = still[1], pixel_z = still[2], transform = still[3], time = hold, loop = loop, tag = tag) - started = TRUE - else - animate(pixel_w = still[1], pixel_z = still[2], transform = still[3], time = hold) - hold = 0 - var/time = real ? real[i + 1] : step[4] + var/time = real ? real[i] : step[4] if(!started) animate(src, pixel_w = step[1], pixel_z = step[2], transform = step[3], time = time, easing = step[5], loop = loop, tag = tag) started = TRUE @@ -236,24 +226,15 @@ /atom/proc/hv_vis_play_over(list/steps) if(!length(steps)) return - // Slowed and held like hv_vis_play() in a Sandevistan field. + // Slowed like hv_vis_play() in a Sandevistan field. var/list/times = list() for(var/list/step as anything in steps) times += step[3] var/list/real = cyberware_dilated_steps(src, times) - var/hold = real ? real[1] : 0 var/started = FALSE for(var/i in 1 to length(steps)) var/list/step = steps[i] - if(hold > 0 && step[3] > 0) - var/list/still = i > 1 ? steps[i - 1] : list(pixel_y, transform) - if(!started) - animate(src, pixel_y = still[1], transform = still[2], time = hold) - started = TRUE - else - animate(pixel_y = still[1], transform = still[2], time = hold) - hold = 0 - var/time = real ? real[i + 1] : step[3] + var/time = real ? real[i] : step[3] if(!started) animate(src, pixel_y = step[1], transform = step[2], time = time, easing = step[4]) started = TRUE @@ -324,10 +305,10 @@ var/flash = get_filter(HV_VIS_FLASH_FILTER) if(!flash) return - var/list/real = cyberware_dilated_steps(src, list(0.5, HV_VIS_HITSTOP_TIME, 1)) || list(0, 0.5, HV_VIS_HITSTOP_TIME, 1) - animate(flash, color = list(0.6,0,0, 0,0.6,0, 0,0,0.6, 0.4,0.4,0.4), time = real[1] + real[2], easing = LINEAR_EASING) - animate(color = list(0.6,0,0, 0,0.6,0, 0,0,0.6, 0.4,0.4,0.4), time = real[3], easing = LINEAR_EASING) - animate(color = list(1,0,0, 0,1,0, 0,0,1, 0,0,0), time = real[4], easing = LINEAR_EASING) + var/list/real = cyberware_dilated_steps(src, list(0.5, HV_VIS_HITSTOP_TIME, 1)) || list(0.5, HV_VIS_HITSTOP_TIME, 1) + animate(flash, color = list(0.6,0,0, 0,0.6,0, 0,0,0.6, 0.4,0.4,0.4), time = real[1], easing = LINEAR_EASING) + animate(color = list(0.6,0,0, 0,0.6,0, 0,0,0.6, 0.4,0.4,0.4), time = real[2], easing = LINEAR_EASING) + animate(color = list(1,0,0, 0,1,0, 0,0,1, 0,0,0), time = real[3], easing = LINEAR_EASING) addtimer(CALLBACK(src, TYPE_PROC_REF(/datum, remove_filter), HV_VIS_FLASH_FILTER), cyberware_dilated_time(src, 0.5 SECONDS), TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_DELETE_ME) /// The attacker's side of a hit-stop: the blow sticks for a few frames. A melee attacker stays leaned diff --git a/voidcrew/modules/hyperviolence/visuals/blood_motion.dm b/voidcrew/modules/hyperviolence/visuals/blood_motion.dm index 22aa43f7bdd..9d07adf8be9 100644 --- a/voidcrew/modules/hyperviolence/visuals/blood_motion.dm +++ b/voidcrew/modules/hyperviolence/visuals/blood_motion.dm @@ -28,14 +28,14 @@ GLOBAL_VAR(hv_vis_spray_origin) /obj/effect/decal/cleanable/blood/proc/hv_vis_paint(list/keys) var/reached = world.time var/list/timed = list() - // In a Sandevistan field (cyberware/ware_sandy_timestop.dm) the paint waits out the freeze and runs slow. + // In a Sandevistan field (cyberware/ware_sandy_timestop.dm) the paint runs slow. var/list/times = list() for(var/list/key as anything in keys) times += key[3] var/list/real = cyberware_dilated_steps(src, times) for(var/i in 1 to length(keys)) var/list/key = keys[i] - reached += real ? real[i + 1] + (i == 1 ? real[1] : 0) : key[3] + reached += real ? real[i] : key[3] timed += list(list(key[1], key[2], reached, key[4])) hv_vis_painting = timed hv_vis_paint_on() diff --git a/voidcrew/modules/hyperviolence/visuals/death_falls.dm b/voidcrew/modules/hyperviolence/visuals/death_falls.dm index c531b78d7ed..082a1cc1a02 100644 --- a/voidcrew/modules/hyperviolence/visuals/death_falls.dm +++ b/voidcrew/modules/hyperviolence/visuals/death_falls.dm @@ -125,7 +125,7 @@ hv_vis_play_over(hv_vis_hold_then_fall_steps(HV_VIS_HITSTOP_TIME, 2)) if(hv_vis_hit_damage >= HV_VIS_FULL_SLIDE_DAMAGE && hv_vis_can_slide_tile(hit_dir)) // Next tick: the blow that killed it may still be resolving (a shotgun's other pellets). - addtimer(CALLBACK(src, PROC_REF(hv_vis_slide_tile), hit_dir), cyberware_dilated_time(src, 1), TIMER_DELETE_ME) // waits out a Sandevistan freeze + addtimer(CALLBACK(src, PROC_REF(hv_vis_slide_tile), hit_dir), cyberware_dilated_time(src, 1), TIMER_DELETE_ME) // slower in a Sandevistan field return hv_vis_slide_in_tile(hit_dir, hv_vis_hit_damage, big ? HV_VIS_HITSTOP_TIME : 0) hv_vis_twitches(rand(3, 5), big ? 6 : 4) diff --git a/voidcrew/modules/hyperviolence/visuals/execution_motion.dm b/voidcrew/modules/hyperviolence/visuals/execution_motion.dm index fe3f5474b4a..664754db19f 100644 --- a/voidcrew/modules/hyperviolence/visuals/execution_motion.dm +++ b/voidcrew/modules/hyperviolence/visuals/execution_motion.dm @@ -69,10 +69,10 @@ var/base_w = pixel_w var/base_z = pixel_z // Slower in a Sandevistan field (cyberware/ware_sandy_timestop.dm). - var/list/real = cyberware_dilated_steps(src, list(0.5, 1, 0.5), loops) || list(0, 0.5, 1, 0.5) - animate(src, pixel_w = base_w + strength, pixel_z = base_z, transform = base, time = real[1] + real[2], loop = loops, tag = HV_VIS_TAG_TREMBLE) - animate(pixel_w = base_w - strength, pixel_z = base_z, transform = base, time = real[3]) - animate(pixel_w = base_w, pixel_z = base_z, transform = base, time = real[4]) + var/list/real = cyberware_dilated_steps(src, list(0.5, 1, 0.5), loops) || list(0.5, 1, 0.5) + animate(src, pixel_w = base_w + strength, pixel_z = base_z, transform = base, time = real[1], loop = loops, tag = HV_VIS_TAG_TREMBLE) + animate(pixel_w = base_w - strength, pixel_z = base_z, transform = base, time = real[2]) + animate(pixel_w = base_w, pixel_z = base_z, transform = base, time = real[3]) /** * A muzzle flash much bigger than a normal shot's, with a flash of light. It is where the barrel's end diff --git a/voidcrew/modules/hyperviolence/visuals/flying_gore.dm b/voidcrew/modules/hyperviolence/visuals/flying_gore.dm index b103ab527c4..21a513d8242 100644 --- a/voidcrew/modules/hyperviolence/visuals/flying_gore.dm +++ b/voidcrew/modules/hyperviolence/visuals/flying_gore.dm @@ -230,7 +230,7 @@ GLOBAL_LIST(hv_vis_arcing_gibs) /// where it is once it has dropped. /datum/component/hv_tumble/proc/launch() start_flight() - // In a Sandevistan field (cyberware/ware_sandy_timestop.dm) it waits out the freeze and flies slow. + // In a Sandevistan field (cyberware/ware_sandy_timestop.dm) it flies slow. step_timer = addtimer(CALLBACK(src, PROC_REF(take_step)), cyberware_dilated_time(parent, length(path) ? world.tick_lag : flight_time()), TIMER_STOPPABLE|TIMER_DELETE_ME) /datum/component/hv_tumble/proc/start_flight() @@ -432,12 +432,12 @@ GLOBAL_LIST(hv_vis_arcing_gibs) var/list/pivot = part.hv_vis_spin_pivot() var/start_x = part.pixel_x var/start_y = part.pixel_y - // Slower in a Sandevistan field (cyberware/ware_sandy_timestop.dm); the opening pause takes the freeze. - var/list/real = cyberware_dilated_steps(part, list(2.5, 1.5, 2, 3)) || list(0, 2.5, 1.5, 2, 3) - animate(part, pixel_x = start_x, pixel_y = start_y, transform = base, time = real[1] + real[2], tag = HV_VIS_TAG_SPIN) - animate(pixel_x = start_x + (end_x - start_x) / 3, pixel_y = start_y + (end_y - start_y) / 3, transform = hv_vis_turned(base, spin * 120, pivot[2], pivot[1]), time = real[3], easing = LINEAR_EASING) - animate(pixel_x = start_x + (end_x - start_x) * 2 / 3, pixel_y = start_y + (end_y - start_y) * 2 / 3, transform = hv_vis_turned(base, spin * 240, pivot[2], pivot[1]), time = real[4], easing = LINEAR_EASING) - animate(pixel_x = end_x, pixel_y = end_y, transform = base, time = real[5], easing = QUAD_EASING|EASE_OUT) + // Slower in a Sandevistan field (cyberware/ware_sandy_timestop.dm). + var/list/real = cyberware_dilated_steps(part, list(2.5, 1.5, 2, 3)) || list(2.5, 1.5, 2, 3) + animate(part, pixel_x = start_x, pixel_y = start_y, transform = base, time = real[1], tag = HV_VIS_TAG_SPIN) + animate(pixel_x = start_x + (end_x - start_x) / 3, pixel_y = start_y + (end_y - start_y) / 3, transform = hv_vis_turned(base, spin * 120, pivot[2], pivot[1]), time = real[2], easing = LINEAR_EASING) + animate(pixel_x = start_x + (end_x - start_x) * 2 / 3, pixel_y = start_y + (end_y - start_y) * 2 / 3, transform = hv_vis_turned(base, spin * 240, pivot[2], pivot[1]), time = real[3], easing = LINEAR_EASING) + animate(pixel_x = end_x, pixel_y = end_y, transform = base, time = real[4], easing = QUAD_EASING|EASE_OUT) // ---- Limbs and heads coming off ---- diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm index 65449aa6e2c..7875c3c55db 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -2,9 +2,10 @@ * # The Cascade line's time dilation * * voidcrew/modules/cyberware/ware_sandy_timestop.dm. A player's surge with no other player near slows the - * world: the Cascade lattice freezes it for 1 s (a human NPC is held by tg's immobilise, hand-block and - * AI-pause traits, and its timers stop), then runs it at 20% for 3 s (the NPC walks under a movespeed - * modifier), then lets go. The user can act the whole time. Another player near means no dilation at all. + * world for the whole window: street 6 s at 50%, service 7 s at 35%, Cascade 8 s at 20%. Nothing is ever + * frozen: a human NPC walks under a movespeed modifier and its timers run slow, and it never gets tg's + * immobilise, hand-block or AI-pause traits. The user can act the whole time, and pressing the surge again ends + * the window early, slow-mo and all. Another player near means no dilation at all. * * Unit-test files compile before voidcrew/_DEFINES/, so literals stand in for module defines. */ @@ -33,11 +34,27 @@ body.key = null return ..() -/// Nobody else near: the world freezes, then slows, then lets go on time; the user can act throughout +/// Whether `body` carries any of the traits tg's own time stop freezes with +/datum/unit_test/voidcrew_cyberware_timestop/proc/frozen(mob/living/body) + return HAS_TRAIT(body, TRAIT_IMMOBILIZED) || HAS_TRAIT(body, TRAIT_HANDS_BLOCKED) || HAS_TRAIT(body, TRAIT_AI_PAUSED) || HAS_TRAIT(body, TRAIT_MUTE) || HAS_TRAIT(body, TRAIT_EMOTEMUTE) + +/// Sets the length of `user`'s next window, to keep a test short +/datum/unit_test/voidcrew_cyberware_timestop/proc/set_window(mob/living/carbon/human/user, length) + var/obj/item/organ/cyberimp/cyberware/cascade/lattice = locate() in user.organs + lattice.window_duration = length + +/// `user`'s surge button +/datum/unit_test/voidcrew_cyberware_timestop/proc/surge_button(mob/living/carbon/human/user) + var/obj/item/organ/cyberimp/cyberware/cascade/lattice = locate() in user.organs + return locate(/datum/action/cooldown/cyberware/cascade_surge) in lattice.actions + +/// Nobody else near: the world runs slow from the first moment to the end of the window, nothing is ever frozen, +/// and the user can act throughout /datum/unit_test/voidcrew_cyberware_timestop/alone /datum/unit_test/voidcrew_cyberware_timestop/alone/Run() var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + set_window(user, 2 SECONDS) var/mob/living/carbon/human/npc/raider = allocate(/mob/living/carbon/human/npc, spot(2, 0)) var/next_move_was = world.time + 5 SECONDS raider.next_move = next_move_was @@ -45,38 +62,125 @@ TEST_ASSERT_NOTNULL(window, "The lattice's window didn't open") sleep(0.1 SECONDS) // the field starts async TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") - TEST_ASSERT(istype(window.time_stop.chronofield, /datum/proximity_monitor/advanced/timestop/sandevistan), "The dilation has no field") - - // The freeze: tg's own traits, no Stun. - TEST_ASSERT(HAS_TRAIT(raider, TRAIT_IMMOBILIZED), "The NPC wasn't held still") - TEST_ASSERT(HAS_TRAIT(raider, TRAIT_HANDS_BLOCKED), "The NPC's hands weren't stopped") - TEST_ASSERT(HAS_TRAIT(raider, TRAIT_AI_PAUSED), "The NPC's AI kept running in frozen time") - TEST_ASSERT(!raider.IsStun(), "The freeze stunned the NPC instead of holding it") - TEST_ASSERT(!HAS_TRAIT(user, TRAIT_IMMOBILIZED) && !HAS_TRAIT(user, TRAIT_HANDS_BLOCKED), "The user froze in their own time stop") + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield + TEST_ASSERT(istype(field), "The dilation has no field") + + // Slow-mo from the start: a walk under a movespeed modifier, no freeze and no Stun. + TEST_ASSERT(abs(field.time_scale - 0.2) < 0.001, "The Cascade's world doesn't run at 20% from the start ([field.time_scale])") + TEST_ASSERT(raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "The NPC isn't slowed as the window opens") + TEST_ASSERT(!frozen(raider), "The NPC was frozen") + TEST_ASSERT(!raider.IsStun(), "The dilation stunned the NPC") + TEST_ASSERT(!frozen(user), "The user froze in their own dilation") var/turf/start = get_turf(user) user.Move(spot(0, 1), NORTH) - TEST_ASSERT(get_turf(user) != start, "The user couldn't move in frozen time") + TEST_ASSERT(get_turf(user) != start, "The user couldn't move in their own slow-mo") - // Frozen bodies still get hurt through tg. + // Slowed bodies still get hurt through tg. var/brute_before = raider.getBruteLoss() raider.apply_damage(10, BRUTE, BODY_ZONE_CHEST) - TEST_ASSERT(raider.getBruteLoss() > brute_before, "A frozen NPC didn't take damage") - - sleep(0.8 SECONDS) // still inside the Cascade's 1 s freeze - TEST_ASSERT(raider.next_move > next_move_was + 0.5 SECONDS, "The NPC's timers ran on through frozen time") + TEST_ASSERT(raider.getBruteLoss() > brute_before, "A slowed NPC didn't take damage") + + // Through the whole window: slowed, never frozen. + for(var/i in 1 to 4) + sleep(0.4 SECONDS) + TEST_ASSERT_NOTNULL(window.time_stop, "The slow-mo ended [i * 0.4] s into a 2 s window") + TEST_ASSERT(!frozen(raider), "The NPC was frozen [i * 0.4] s in") + TEST_ASSERT(raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "The NPC wasn't slowed [i * 0.4] s in") + TEST_ASSERT(raider.next_move > next_move_was + 1 SECONDS, "The NPC's timers ran on at full speed") + TEST_ASSERT(!length(field.frozen_mobs), "The field kept a list of frozen mobs") + + sleep(0.7 SECONDS) // past the window + TEST_ASSERT(!user.has_status_effect(/datum/status_effect/cyberware_cascade_window), "Test fixture: the window outlived its 2 s") + TEST_ASSERT_NULL(window.time_stop, "The slow-mo outlived the window") + TEST_ASSERT(!raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "The NPC stayed slow after the window") + TEST_ASSERT(!frozen(raider), "The NPC was frozen after the window") + user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) - sleep(0.5 SECONDS) // then the world runs at 20% - TEST_ASSERT_NOTNULL(window.time_stop, "The dilation ended with the freeze") - TEST_ASSERT(!HAS_TRAIT(raider, TRAIT_IMMOBILIZED) && !HAS_TRAIT(raider, TRAIT_AI_PAUSED), "The NPC stayed frozen into the slow-mo") - TEST_ASSERT(raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "The NPC isn't slowed in slow-mo") +/// The slow-mo lasts each model's whole window at that model's pace +/datum/unit_test/voidcrew_cyberware_timestop/full_window + +/datum/unit_test/voidcrew_cyberware_timestop/full_window/Run() + var/list/models = list( + /obj/item/organ/cyberimp/cyberware/cascade/street = list(6 SECONDS, 0.5), + /obj/item/organ/cyberimp/cyberware/cascade/milspec = list(7 SECONDS, 0.35), + /obj/item/organ/cyberimp/cyberware/cascade = list(8 SECONDS, 0.2), + ) + for(var/model in models) + var/list/expected = models[model] + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser", model) + var/datum/status_effect/cyberware_cascade_window/window = surge(user) + TEST_ASSERT_NOTNULL(window, "[model]: the window didn't open") + TEST_ASSERT_EQUAL(window.duration - world.time, expected[1], "[model]: the window isn't [expected[1] / 10] s") + sleep(0.1 SECONDS) + TEST_ASSERT_NOTNULL(window.time_stop, "[model]: time didn't slow with nobody else near") + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield + TEST_ASSERT(abs(field.time_scale - expected[2]) < 0.001, "[model]: the world runs at [field.time_scale], not [expected[2]]") + TEST_ASSERT(abs(field.ends_at - window.duration) <= 1, "[model]: the slow-mo ends at [field.ends_at], the window at [window.duration]") + end_surge(user) + TEST_ASSERT_NULL(window.time_stop, "[model]: the slow-mo outlived the window") + user.key = null + qdel(user) + +/// Pressing the surge again ends the window early, slow-mo and all, and the crash starts. It never opens a fresh +/// window, and an NPC's lattice never turns itself off. +/datum/unit_test/voidcrew_cyberware_timestop/toggle_off + +/datum/unit_test/voidcrew_cyberware_timestop/toggle_off/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + var/mob/living/carbon/human/npc/raider = allocate(/mob/living/carbon/human/npc, spot(2, 0)) + var/datum/action/cooldown/cyberware/cascade_surge/button = surge_button(user) + TEST_ASSERT_NOTNULL(button, "Test fixture: the lattice has no surge button") + TEST_ASSERT(button.Trigger(), "The first press didn't surge") + var/datum/status_effect/cyberware_cascade_window/window = user.has_status_effect(/datum/status_effect/cyberware_cascade_window) + TEST_ASSERT_NOTNULL(window, "The first press didn't open the window") + var/cooldown_end = button.next_use_time + TEST_ASSERT(cooldown_end > world.time, "Test fixture: the press didn't start the cooldown") + sleep(0.5 SECONDS) + TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") + TEST_ASSERT(raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "Test fixture: the NPC isn't slowed") - sleep(2.9 SECONDS) // the rest of the 3 s of slow-mo - TEST_ASSERT_NULL(window.time_stop, "The dilation outlived its 4 s") - TEST_ASSERT(!raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "The NPC stayed slow after the dilation") - TEST_ASSERT(!HAS_TRAIT(raider, TRAIT_AI_PAUSED), "The NPC's AI stayed paused after the dilation") - TEST_ASSERT(user.has_status_effect(/datum/status_effect/cyberware_cascade_window), "The window didn't carry on after the dilation") - user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) + TEST_ASSERT(button.IsAvailable(), "The surge can't be pressed while its window runs") + TEST_ASSERT(button.Trigger(), "The second press did nothing") + TEST_ASSERT(!user.has_status_effect(/datum/status_effect/cyberware_cascade_window), "The second press didn't end the window") + TEST_ASSERT(user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "Ending the window early skipped the crash") + TEST_ASSERT_NULL(window.time_stop, "The slow-mo outlived the window") + TEST_ASSERT(!raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "The NPC stayed slow after the window was cut short") + TEST_ASSERT_EQUAL(button.next_use_time, cooldown_end, "Cutting the window short changed the cooldown") + + // In the crash, and after it, the button is back on its cooldown. + TEST_ASSERT(!button.IsAvailable(), "The surge could be pressed in the crash") + TEST_ASSERT(!button.Trigger(), "A third press went through") user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + TEST_ASSERT(!button.Trigger(), "A press after the crash went through on cooldown") + TEST_ASSERT(!user.has_status_effect(/datum/status_effect/cyberware_cascade_window), "A press opened a fresh window on cooldown") + + // An NPC pressing its own surge again doesn't end it. + var/mob/living/carbon/human/npc/surger = make_surger(spot(3, 0)) + var/datum/action/cooldown/cyberware/cascade_surge/npc_button = surge_button(surger) + TEST_ASSERT(npc_button.Trigger(), "The NPC's press didn't surge") + TEST_ASSERT(!npc_button.IsAvailable(), "An NPC's surge reads as pressable in its window") + npc_button.Trigger() + TEST_ASSERT(surger.has_status_effect(/datum/status_effect/cyberware_cascade_window), "An NPC's second press ended its window") + end_surge(surger) + +/// A player coming in range mid-surge ends the slow-mo; the surge carries on as a plain one, with the plain trail +/datum/unit_test/voidcrew_cyberware_timestop/player_walks_in + +/datum/unit_test/voidcrew_cyberware_timestop/player_walks_in/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + var/mob/living/carbon/human/other = allocate(/mob/living/carbon/human/consistent, spot(3, 3)) + var/datum/status_effect/cyberware_cascade_window/window = surge(user) + sleep(0.1 SECONDS) + TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") + other.key = "sandytimestopother" + sleep(0.5 SECONDS) // a couple of field ticks + TEST_ASSERT_NULL(window.time_stop, "The slow-mo carried on with a player in range") + TEST_ASSERT(user.has_status_effect(/datum/status_effect/cyberware_cascade_window), "The surge ended with the slow-mo") + var/turf/start = get_turf(user) + user.Move(spot(0, 1), NORTH) + TEST_ASSERT(get_turf(user) != start, "Test fixture: the user couldn't step") + TEST_ASSERT_NULL(locate(/obj/effect/temp_visual/decoy/fading/cascade/stopped) in spot(0, 1), "The green trail outlived the slow-mo") + end_surge(user) /// Another player near: the plain surge, no dilation /datum/unit_test/voidcrew_cyberware_timestop/witnessed @@ -90,7 +194,7 @@ TEST_ASSERT_NOTNULL(window, "The lattice's window didn't open") sleep(0.1 SECONDS) TEST_ASSERT_NULL(window.time_stop, "Time slowed with another player in sight") - TEST_ASSERT(!HAS_TRAIT(raider, TRAIT_IMMOBILIZED), "The NPC froze without a dilation") + TEST_ASSERT(!raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "The NPC was slowed without a dilation") user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) @@ -111,6 +215,7 @@ /datum/unit_test/voidcrew_cyberware_timestop/slow_everything/Run() var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + set_window(user, 2 SECONDS) var/mob/living/carbon/human/npc/raider = allocate(/mob/living/carbon/human/npc, spot(2, 0)) var/obj/item/gun/ballistic/automatic/pistol/raider_gun = allocate(/obj/item/gun/ballistic/automatic/pistol) raider.put_in_hands(raider_gun) @@ -130,17 +235,19 @@ TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield - // The freeze: the NPC's own clock stands still, the user's doesn't. - TEST_ASSERT_EQUAL(cyberware_time_lost(raider, 10), 10, "A frozen NPC's clock kept running") + // The NPC's clock runs at 20% from the start; the user's doesn't slow. + var/lost = cyberware_time_lost(raider, 10) + TEST_ASSERT(lost > 7.9 && lost < 8.1, "A slowed NPC's clock didn't run at 20% (lost [lost] of 10)") TEST_ASSERT_EQUAL(cyberware_time_lost(user, 10), 0, "The user's clock slowed") TEST_ASSERT_EQUAL(cyberware_dilated_time(user, 10), 10, "The user's animations would be slowed") TEST_ASSERT_NULL(cyberware_time_field(user_gun), "Something the user holds would be slowed") - TEST_ASSERT(cyberware_dilated_time(raider, 1) > 5, "A frozen NPC's gore wouldn't wait out the freeze") + var/gore = cyberware_dilated_time(raider, 1) + TEST_ASSERT(gore > 4.9 && gore < 5.1, "A slowed NPC's gore doesn't play at 20% ([gore] for 1)") TEST_ASSERT_NOTNULL(cyberware_time_field(pool), "Blood on the floor in the field isn't slowed") var/list/steps = cyberware_dilated_steps(raider, list(2, 2)) - TEST_ASSERT(length(steps) == 3 && steps[1] > 5 && steps[2] > 2, "A frozen NPC's flinch wouldn't hold, then play slow") + TEST_ASSERT(length(steps) == 2 && abs(steps[1] - 10) < 0.1 && abs(steps[2] - 10) < 0.1, "A slowed NPC's flinch doesn't play at 20% from its first step ([jointext(steps, ",")])") - // Bursts queued before the freeze wait for it. + // Bursts queued in the slow-mo wait longer. raider_gun.cyberware_burst_start = cyberware_burst_clock(raider) raider_gun.cyberware_burst_gap = 1 var/datum/component/ranged_attacks/rat_gun = rat.GetComponent(/datum/component/ranged_attacks) @@ -148,36 +255,32 @@ var/gun_start = raider_gun.cyberware_burst_start var/rat_start = rat_gun.cyberware_burst_start - // Machines and fuses. - TEST_ASSERT(mech.completely_disabled, "A mech kept running in frozen time") - TEST_ASSERT(turret.last_fired + turret.shot_delay > world.time, "A turret could fire in frozen time") + // Machines and fuses: nothing is shut down, every clock runs slow. + TEST_ASSERT(!mech.completely_disabled, "The dilation shut a mech down") + TEST_ASSERT(field.held_mechs[mech], "A mech in the field wasn't taken") + TEST_ASSERT(field.held_turrets[turret], "A turret in the field wasn't taken") TEST_ASSERT(field.held_grenades[bomb], "A lit grenade in the field wasn't taken") + turret.last_fired = world.time + var/fired_at = turret.last_fired + mech.cooldown_vehicle_move = world.time + 5 + var/move_at = mech.cooldown_vehicle_move sleep(0.5 SECONDS) - TEST_ASSERT(cyberware_burst_wait(raider, raider_gun.cyberware_burst_start, 1, 1) > 0, "A queued burst shot fired in frozen time") + TEST_ASSERT(cyberware_burst_wait(raider, raider_gun.cyberware_burst_start, 1, 1) > 0, "A queued burst shot fired at full speed") TEST_ASSERT(raider_gun.cyberware_burst_start > gun_start, "The NPC's burst clock wasn't pushed back") TEST_ASSERT(rat_gun.cyberware_burst_start > rat_start, "A basic mob's burst clock wasn't pushed back") - TEST_ASSERT(turret.last_fired + turret.shot_delay > world.time, "A turret could fire in frozen time") - - sleep(0.6 SECONDS) // into the 20% slow-mo - TEST_ASSERT(!mech.completely_disabled, "A mech stayed shut down into the slow-mo") - var/lost = cyberware_time_lost(raider, 10) - TEST_ASSERT(lost > 7.9 && lost < 8.1, "A slowed NPC's clock didn't run at 20% (lost [lost] of 10)") - mech.cooldown_vehicle_move = world.time + 5 - var/move_at = mech.cooldown_vehicle_move - sleep(0.5 SECONDS) + TEST_ASSERT(turret.last_fired > fired_at, "A slowed turret's fire cooldown wasn't pushed back") TEST_ASSERT(mech.cooldown_vehicle_move > move_at, "A slowed mech's move cooldown wasn't pushed back") + TEST_ASSERT(!mech.completely_disabled, "The dilation shut a mech down") TEST_ASSERT(!(bomb.dud_flags & GRENADE_USED), "A grenade's fuse burned through at full speed") - sleep(2.6 SECONDS) // past the end - TEST_ASSERT_NULL(window.time_stop, "The dilation outlived its 4 s") + sleep(1.8 SECONDS) // past the window + TEST_ASSERT_NULL(window.time_stop, "The slow-mo outlived the window") TEST_ASSERT_EQUAL(cyberware_time_lost(raider, 10), 0, "The NPC's clock stayed slow after the dilation") TEST_ASSERT_NULL(cyberware_time_field(pool), "Blood stayed slowed after the dilation") - TEST_ASSERT(!mech.completely_disabled, "The mech stayed shut down after the dilation") - TEST_ASSERT(!(bomb.dud_flags & GRENADE_USED), "A grenade's fuse didn't lose its frozen and slowed time") + TEST_ASSERT(!(bomb.dud_flags & GRENADE_USED), "A grenade's fuse didn't lose its slowed time") TEST_ASSERT(bomb.cyberware_fuse_at > world.time, "A grenade's fuse has nothing left after losing time") bomb.cyberware_fuse_at = 0 // don't let it go off after the test - user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) /// Whether `thing` wears the dilation's tint @@ -191,7 +294,7 @@ TEST_ASSERT(lattice.Insert(raider, special = TRUE), "Test fixture: the lattice wouldn't go in the NPC") return raider -/// No reflex dodge while the user's own world is slowed; it comes back for the rest of the window +/// No reflex dodge while the user's own world is slowed; it comes back if the slow-mo ends before the window does /datum/unit_test/voidcrew_cyberware_timestop/no_dodge_while_slowed /datum/unit_test/voidcrew_cyberware_timestop/no_dodge_while_slowed/Run() @@ -205,13 +308,13 @@ var/datum/component/cyberware_dodge/arbiter = user.GetComponent(/datum/component/cyberware_dodge) TEST_ASSERT_NOTNULL(arbiter, "The window has no dodge") TEST_ASSERT(cyberware_time_stopping(user), "The user isn't marked as slowing time") - TEST_ASSERT_EQUAL(arbiter.on_check_block(user, knife, 10, "the knife", MELEE_ATTACK), FAILED_BLOCK, "The user dodged a blow in frozen time") - sleep(1.2 SECONDS) TEST_ASSERT_EQUAL(arbiter.on_check_block(user, knife, 10, "the knife", MELEE_ATTACK), FAILED_BLOCK, "The user dodged a blow in slow-mo") - sleep(3 SECONDS) // past the dilation, still in the window - TEST_ASSERT_NULL(window.time_stop, "The dilation outlived its 4 s") + sleep(1.2 SECONDS) + TEST_ASSERT_EQUAL(arbiter.on_check_block(user, knife, 10, "the knife", MELEE_ATTACK), FAILED_BLOCK, "The user dodged a blow later in slow-mo") + qdel(window.time_stop) // as when a player comes in range + TEST_ASSERT_NULL(window.time_stop, "Test fixture: the dilation didn't end") TEST_ASSERT(!cyberware_time_stopping(user), "The user still counts as slowing time") - TEST_ASSERT_EQUAL(arbiter.on_check_block(user, knife, 10, "the knife", MELEE_ATTACK), SUCCESSFUL_BLOCK, "The dodge didn't come back after the dilation") + TEST_ASSERT_EQUAL(arbiter.on_check_block(user, knife, 10, "the knife", MELEE_ATTACK), SUCCESSFUL_BLOCK, "The dodge didn't come back after the slow-mo ended") user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) @@ -230,12 +333,12 @@ TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield TEST_ASSERT(field.surging[raider], "The field didn't notice the NPC's surge") - TEST_ASSERT(!HAS_TRAIT(raider, TRAIT_IMMOBILIZED) && !HAS_TRAIT(raider, TRAIT_AI_PAUSED), "A surging NPC was frozen") + TEST_ASSERT(!frozen(raider), "A surging NPC was frozen") TEST_ASSERT(!tinted(raider), "A surging NPC was tinted") TEST_ASSERT_EQUAL(cyberware_time_lost(raider, 10), 0, "A surging NPC's clock slowed") TEST_ASSERT_NULL(cyberware_time_field(raider), "A surging NPC's gore would be slowed") - TEST_ASSERT(!field.held_shots[shot] && !shot.paused && shot.speed == shot_speed, "A surging NPC's shot was held") - sleep(1.2 SECONDS) // into the slow-mo + TEST_ASSERT(!field.held_shots[shot] && shot.speed == shot_speed, "A surging NPC's shot was held") + sleep(0.5 SECONDS) // a couple of field ticks TEST_ASSERT(!raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "A surging NPC was slowed") // Its window closes: the field takes it like anyone else. @@ -260,7 +363,7 @@ sleep(0.1 SECONDS) TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield - sleep(1.2 SECONDS) // into the slow-mo + sleep(0.3 SECONDS) TEST_ASSERT(raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "Test fixture: the NPC isn't slowed") TEST_ASSERT(field.held_shots[shot], "Test fixture: the NPC's shot isn't held") raider.hv_vis_play("sandy_test_flinch", list(list(2, 0, matrix(), 2, LINEAR_EASING), list(0, 0, matrix(), 2, LINEAR_EASING))) @@ -269,7 +372,7 @@ TEST_ASSERT_NOTNULL(surge(raider), "The NPC's window didn't open") TEST_ASSERT(field.surging[raider], "The field didn't let the surging NPC go") TEST_ASSERT(!raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "The surging NPC stayed slowed") - TEST_ASSERT(!HAS_TRAIT(raider, TRAIT_IMMOBILIZED) && !HAS_TRAIT(raider, TRAIT_AI_PAUSED), "The surging NPC stayed held") + TEST_ASSERT(!frozen(raider), "The surging NPC was frozen") TEST_ASSERT(!tinted(raider), "The surging NPC stayed tinted") TEST_ASSERT_NULL(GLOB.cyberware_time_dilated[raider], "The surging NPC is still in the field's hands") TEST_ASSERT_EQUAL(cyberware_time_lost(raider, 10), 0, "The surging NPC's clock stayed slow") @@ -312,7 +415,7 @@ field.user_multiplier = 5 end_surge(user) -/// A slower surger in the user's field is held at its relative rate through the freeze, never frozen or tinted +/// A slower surger in the user's field is held at its relative rate for the whole window, never frozen or tinted /datum/unit_test/voidcrew_cyberware_timestop/slower_surger /datum/unit_test/voidcrew_cyberware_timestop/slower_surger/Run() @@ -325,13 +428,14 @@ var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield TEST_ASSERT(field.surging[raider], "The field didn't notice the NPC's surge") TEST_ASSERT(abs(cyberware_time_scale(raider) - 0.4) < 0.001, "A street surger in a Cascade's field isn't at 40% ([cyberware_time_scale(raider)])") - TEST_ASSERT(!HAS_TRAIT(raider, TRAIT_IMMOBILIZED) && !HAS_TRAIT(raider, TRAIT_AI_PAUSED), "A surging NPC was frozen by the user's freeze") + TEST_ASSERT(!frozen(raider), "A surging NPC was frozen") TEST_ASSERT(!tinted(raider), "A surging NPC was tinted") TEST_ASSERT(raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "A slower surger isn't slowed") var/lost = cyberware_time_lost(raider, 10) TEST_ASSERT(lost > 5.9 && lost < 6.1, "A slower surger's clock didn't run at 40% (lost [lost] of 10)") - sleep(1.2 SECONDS) // into the user's slow-mo: the surger keeps its own rate - TEST_ASSERT(abs(cyberware_time_scale(raider) - 0.4) < 0.001, "The thaw changed a surger's rate") + sleep(1.2 SECONDS) // later in the window: the surger keeps its own rate + TEST_ASSERT(abs(cyberware_time_scale(raider) - 0.4) < 0.001, "A surger's rate changed mid-window ([cyberware_time_scale(raider)])") + TEST_ASSERT(raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "A slower surger stopped being slowed mid-window") end_surge(user) end_surge(raider) @@ -358,7 +462,7 @@ TEST_ASSERT_EQUAL(cyberware_dilated_time(raider, 10), 5, "A faster surger's gore doesn't play at double speed") var/datum/movespeed_modifier/pace = raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation) TEST_ASSERT(pace && pace.multiplicative_slowdown <= 0, "A faster surger doesn't walk faster") - TEST_ASSERT(field.held_shots[shot] && !shot.paused && shot.speed == shot_speed * field.time_scale, "A faster surger's shot doesn't fly at the field's pace") + TEST_ASSERT(field.held_shots[shot] && shot.speed == shot_speed * field.time_scale, "A faster surger's shot doesn't fly at the field's pace") TEST_ASSERT(!tinted(shot), "A surger's shot was tinted") var/next_move_was = world.time + 5 SECONDS @@ -379,14 +483,13 @@ TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield TEST_ASSERT_NOTNULL(surge(raider), "The NPC's window didn't open") - sleep(1.2 SECONDS) // into the slow-mo TEST_ASSERT(field.time_scale > 0 && field.time_scale < 1, "Test fixture: not in the slow-mo") var/obj/projectile/bullet/user_shot = allocate(/obj/projectile/bullet, spot(1, 0)) user_shot.firer = user var/user_speed = user_shot.speed - field.hang_shot(user_shot) - TEST_ASSERT(field.held_shots[user_shot] && !user_shot.paused, "The user's slow-mo shot wasn't held") + field.take_user_shot(user_shot) + TEST_ASSERT(field.held_shots[user_shot] && !user_shot.paused, "The user's slow-mo shot wasn't held, or hung") TEST_ASSERT_EQUAL(user_shot.speed, user_speed * field.time_scale, "The user's slow-mo shot doesn't crawl at the field's pace") TEST_ASSERT(!tinted(user_shot), "The user's slow-mo shot was tinted") @@ -394,15 +497,14 @@ npc_shot.firer = raider var/npc_speed = npc_shot.speed field.freeze_atom(npc_shot) - TEST_ASSERT(field.held_shots[npc_shot] && !npc_shot.paused, "A surging NPC's shot wasn't held") + TEST_ASSERT(field.held_shots[npc_shot] && !npc_shot.paused, "A surging NPC's shot wasn't held, or hung") TEST_ASSERT_EQUAL(npc_shot.speed, npc_speed * field.time_scale, "A surging NPC's shot doesn't fly at the field's pace") TEST_ASSERT(!tinted(npc_shot), "A surging NPC's shot was tinted") - sleep(3 SECONDS) // past the end - TEST_ASSERT_NULL(window.time_stop, "The dilation outlived its 4 s") + end_surge(user) + TEST_ASSERT_NULL(window.time_stop, "The slow-mo outlived the window") TEST_ASSERT_EQUAL(user_shot.speed, user_speed, "The user's shot didn't get its speed back") TEST_ASSERT_EQUAL(npc_shot.speed, npc_speed, "The NPC's shot didn't get its speed back") - end_surge(user) end_surge(raider) /// A surging NPC that dies is let go for good: its corpse isn't taken back and tinted @@ -426,8 +528,8 @@ end_surge(user) end_surge(raider) -/// The user hears the slowed world slowed (ware_sandy_senses.dm): world sounds in the field take its pace and -/// a quiet low rumble in the freeze; their own sounds, sounds from outside the field and other listeners don't. +/// The user hears the slowed world slowed (ware_sandy_senses.dm): world sounds in the field take its pace at full +/// volume; their own sounds, sounds from outside the field and other listeners don't. /datum/unit_test/voidcrew_cyberware_timestop/heard_slowed /datum/unit_test/voidcrew_cyberware_timestop/heard_slowed/Run() @@ -441,15 +543,14 @@ var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield TEST_ASSERT_NOTNULL(field.senses, "The field has no senses for its user") - // The freeze: a quiet, low rumble. - var/frozen_speed = cyberware_heard_speed(user, get_turf(raider), raider) - TEST_ASSERT(frozen_speed > 0 && frozen_speed < 0.5, "A world sound in the freeze played at [frozen_speed]") + // From the start: the world at the field's pace, as loud as ever. + TEST_ASSERT_EQUAL(cyberware_heard_speed(user, get_turf(raider), raider), field.time_scale, "A world sound didn't take the slow-mo pace") var/sound/shot = sound('sound/items/weapons/gun/pistol/shot.ogg') shot.volume = 100 cyberware_dilate_heard(user, get_turf(raider), shot, raider) - TEST_ASSERT(shot.frequency == frozen_speed, "A world sound in the freeze went out at frequency [shot.frequency]") - TEST_ASSERT(shot.volume < 50, "A world sound in the freeze stayed loud ([shot.volume])") - TEST_ASSERT_EQUAL(field.senses.loop_speed, frozen_speed, "The user's loops didn't slow in the freeze") + TEST_ASSERT(shot.frequency == field.time_scale, "A world sound went out at frequency [shot.frequency]") + TEST_ASSERT_EQUAL(shot.volume, 100, "A slowed world sound was made quieter") + TEST_ASSERT_EQUAL(field.senses.loop_speed, field.time_scale, "The user's loops didn't take the slow-mo pace") // The user's own sounds, other listeners and far sounds stay as they are. TEST_ASSERT_EQUAL(cyberware_heard_speed(user, get_turf(user), raider), 1, "A sound on the user's own tile was slowed") @@ -460,10 +561,6 @@ TEST_ASSERT_EQUAL(cyberware_scaled_frequency(44100, 0.5), 22050, "A rate in Hz didn't scale") TEST_ASSERT_EQUAL(cyberware_scaled_frequency(0, 0.2), 0.2, "An as-is sound didn't scale") - sleep(1.2 SECONDS) // into the 20% slow-mo - TEST_ASSERT_EQUAL(cyberware_heard_speed(user, get_turf(raider), raider), field.time_scale, "A world sound didn't take the slow-mo pace") - TEST_ASSERT_EQUAL(field.senses.loop_speed, field.time_scale, "The user's loops didn't take the slow-mo pace") - user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) TEST_ASSERT_NULL(window.time_stop, "Test fixture: the dilation outlived its window") @@ -488,17 +585,12 @@ TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield field.senses.refresh_weather(force = TRUE) // no client in a unit test - var/image/frozen = field.senses.area_images[place] - TEST_ASSERT_NOTNULL(frozen, "The rained-on area got no image") - TEST_ASSERT(frozen.override, "The weather image doesn't replace the area's look") - TEST_ASSERT(weather_states(frozen).Find("rain_high_still"), "The freeze didn't show still rain: [jointext(weather_states(frozen), ",")]") - TEST_ASSERT(!weather_states(frozen).Find("rain_high"), "The fast rain is still in the freeze image") - TEST_ASSERT_EQUAL(length(place.overlays), length(area_overlays), "The area's own overlays changed") - - sleep(1.2 SECONDS) // into the 20% slow-mo - field.senses.refresh_weather(force = TRUE) var/image/slowed = field.senses.area_images[place] + TEST_ASSERT_NOTNULL(slowed, "The rained-on area got no image") + TEST_ASSERT(slowed.override, "The weather image doesn't replace the area's look") TEST_ASSERT(weather_states(slowed).Find("rain_high_s20"), "The slow-mo didn't show slowed rain: [jointext(weather_states(slowed), ",")]") + TEST_ASSERT(!weather_states(slowed).Find("rain_high"), "The fast rain is still in the slowed image") + TEST_ASSERT_EQUAL(length(place.overlays), length(area_overlays), "The area's own overlays changed") user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) From b8ccd88e12aeec6e703b2d06d80a435260131beb Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 03:01:37 -0800 Subject: [PATCH 15/33] Roll the Sandevistan ring with the slow-mo, tint behind its front, flash on release, and count carried players as witnesses --- voidcrew/modules/cyberware/cyberware_anim.dm | 15 +++- .../modules/cyberware/ware_sandy_timestop.dm | 87 ++++++++++++++++++- .../unit_tests/voidcrew_cyberware_timestop.dm | 19 ++++ 3 files changed, 115 insertions(+), 6 deletions(-) diff --git a/voidcrew/modules/cyberware/cyberware_anim.dm b/voidcrew/modules/cyberware/cyberware_anim.dm index 4bb4691941b..bcc8afae920 100644 --- a/voidcrew/modules/cyberware/cyberware_anim.dm +++ b/voidcrew/modules/cyberware/cyberware_anim.dm @@ -741,12 +741,19 @@ var/turf/here = get_turf(owner) if(!here) return - var/datum/status_effect/cyberware_cascade_window/window = owner?.has_status_effect(/datum/status_effect/cyberware_cascade_window) - if(window?.time_stop) // a green ring that rolls out past the edge of the screen - new /obj/effect/temp_visual/cyberware_time_ring(here, stop_ring_scale, ring_alpha, ring_time, TRUE) - return new /obj/effect/temp_visual/cyberware_time_ring(here, ring_scale, ring_alpha, ring_time) +/// The world slows as the window opens: the green ring rolls out past the edge of the screen at once, with the +/// tint behind its front, instead of waiting for the last spine node. +/obj/item/organ/cyberimp/cyberware/cascade/proc/roll_stop_ring() + if(ring_timer) + deltimer(ring_timer) + ring_timer = null + var/turf/here = get_turf(owner) + if(!here) + return + new /obj/effect/temp_visual/cyberware_time_ring(here, stop_ring_scale, ring_alpha, ring_time, TRUE) + /// The crash: nodes die hips to neck and stay dark. /obj/item/organ/cyberimp/cyberware/cascade/proc/play_crash() if(!owner) diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index 0ba88177385..ac01b8fa50a 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -66,6 +66,8 @@ #define SANDY_STOP_SAME_RATE 0.01 /// Filter name of the user's edge glow. #define SANDY_STOP_OUTLINE "sandy_time_stop_outline" +/// Fullscreen category of the release flash. +#define SANDY_STOP_FLASH "sandy_time_stop_flash" /// Every atom the dilation has hold of, to its field. GLOBAL_LIST_EMPTY(cyberware_time_dilated) @@ -225,9 +227,9 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) if(body.ckey) return TRUE continue - if(ismob(found) || isitem(found)) // ghosts and camera eyes don't count; radios hold nobody + if(ismob(found)) // ghosts and camera eyes don't count continue - // a locker or a mech with a player in it + // a locker or a mech with a player in it, or a small player picked up and carried as an item for(var/mob/living/inside in found.get_all_contents()) if(inside != user && inside.ckey) return TRUE @@ -375,6 +377,18 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/user_multiplier = 1 /// Surging mobs held at their relative rate, to that rate. Never 0, never tinted. Their shots fly at the field's pace. var/list/own_scales = list() + /// Things that should wear the tint, to TRUE. + var/list/tinted = list() + /// Tinted things the ring hasn't reached yet, to the timer that tints them when it does. + var/list/tint_timers = list() + /// Where the green ring rolls out from, while it is rolling. + var/turf/ring_center + /// world.time the ring started rolling. + var/ring_start = 0 + /// How long the ring takes to reach its full size. + var/ring_time = 0 + /// The ring's full size, in tiles of radius. + var/ring_reach = 0 /datum/proximity_monitor/advanced/timestop/sandevistan/New(atom/_host, range, _ignore_if_not_on_turf = TRUE, list/immune, antimagic_flags, channelled, _time_scale = 1, _user_multiplier = 1) time_scale = _time_scale > 0 ? _time_scale : 1 @@ -698,6 +712,10 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) boss_holds -= thing surging -= thing slowed_tags -= thing + tinted -= thing + if(tint_timers[thing]) + deltimer(tint_timers[thing]) + tint_timers -= thing own_scales -= thing frozen_structures -= thing frozen_things -= thing @@ -736,8 +754,56 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) senses?.refresh_weather() /datum/proximity_monitor/advanced/timestop/sandevistan/into_the_negative_zone(atom/A) + tinted[A] = TRUE + var/wait = ring_wait(A) + if(wait > 0) + if(!tint_timers[A]) + tint_timers[A] = addtimer(CALLBACK(src, PROC_REF(ring_reached), A), wait, TIMER_STOPPABLE) + return A.add_atom_colour(cyberware_time_stop_tint(), TEMPORARY_COLOUR_PRIORITY) +/datum/proximity_monitor/advanced/timestop/sandevistan/escape_the_negative_zone(atom/A) + if(tint_timers[A]) + deltimer(tint_timers[A]) + tint_timers -= A + tinted -= A + return ..() + +/// The ring starts rolling out from `center` to `reach` tiles over `time`: everything it hasn't reached yet loses +/// its tint until the ring's front passes it. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/sweep_ring(turf/center, reach, time) + ring_center = center + ring_start = world.time + ring_time = time + ring_reach = reach + for(var/atom/movable/thing as anything in tinted) + if(tint_timers[thing]) + continue + var/wait = ring_wait(thing) + if(wait <= 0) + continue + thing.remove_atom_colour(TEMPORARY_COLOUR_PRIORITY) + tint_timers[thing] = addtimer(CALLBACK(src, PROC_REF(ring_reached), thing), wait, TIMER_STOPPABLE) + +/// How long until the rolling ring's front reaches `thing`, or 0 if it already has. The ring grows with a sine +/// ease-out (cyberware_time_ring). +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/ring_wait(atom/thing) + if(!ring_center || world.time >= ring_start + ring_time) + return 0 + var/turf/spot = get_turf(thing) + if(!spot || spot.z != ring_center.z) + return 0 + var/dist = sqrt((spot.x - ring_center.x) ** 2 + (spot.y - ring_center.y) ** 2) + var/reached = clamp((dist - 0.2) / max(ring_reach - 0.2, 0.1), 0, 1) + return ring_start + ring_time * arcsin(reached) / 90 - world.time + +/// The ring's front got to `thing`: on with its tint, if it still wants it. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/ring_reached(atom/movable/thing) + tint_timers -= thing + if(QDELETED(thing) || !tinted[thing]) + return + thing.add_atom_colour(cyberware_time_stop_tint(), TEMPORARY_COLOUR_PRIORITY) + // ========================================================================= // THE WINDOW'S SIDE // ========================================================================= @@ -766,6 +832,11 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) time_stop = null return FALSE RegisterSignal(time_stop, COMSIG_QDELETING, PROC_REF(time_stop_over)) + var/obj/item/organ/cyberimp/cyberware/cascade/lattice = cyberware_bearer_lattice(owner) + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = time_stop.chronofield + if(lattice) + lattice.roll_stop_ring() + field?.sweep_ring(here, lattice.stop_ring_scale, lattice.ring_time) owner.remove_client_colour(id) // the user sees the slowed world as it is owner.add_filter(SANDY_STOP_OUTLINE, 2, outline_filter(1, "#5AFF96D2")) return TRUE @@ -778,6 +849,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) if(QDELETED(owner)) return owner.remove_filter(SANDY_STOP_OUTLINE) + sandy_release_flash(owner) owner.add_client_colour(colour_type, id) /// Ends the dilation early (the window is closing). @@ -788,6 +860,16 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) QDEL_NULL(time_stop) if(!QDELETED(owner)) owner.remove_filter(SANDY_STOP_OUTLINE) + sandy_release_flash(owner) + +/// A pale green flash on the user's screen as the world speeds back up. +/proc/sandy_release_flash(mob/living/user) + user.overlay_fullscreen(SANDY_STOP_FLASH, /atom/movable/screen/fullscreen/flash/sandy_release) + user.clear_fullscreen(SANDY_STOP_FLASH, 0.22 SECONDS) + +/atom/movable/screen/fullscreen/flash/sandy_release + color = "#E1FFEB" + alpha = 140 /// One afterimage while time is dilated: green, gone in 0.4 s. It sits on the user's own tile, shifted back to /// where they were, so it sorts on their row and draws behind them in every facing. @@ -864,3 +946,4 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) #undef SANDY_STOP_MAX_RELATIVE #undef SANDY_STOP_SAME_RATE #undef SANDY_STOP_OUTLINE +#undef SANDY_STOP_FLASH diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm index 7875c3c55db..faed1647f21 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -198,6 +198,25 @@ user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) +/// A player hidden in a locker, or picked up and carried as an item, still counts as someone near +/datum/unit_test/voidcrew_cyberware_timestop/witnessed_hidden + +/datum/unit_test/voidcrew_cyberware_timestop/witnessed_hidden/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + var/obj/structure/closet/locker = allocate(/obj/structure/closet, spot(3, 0)) + var/mob/living/carbon/human/hider = allocate(/mob/living/carbon/human/consistent, spot(3, 0)) + hider.key = "sandytimestophider" + hider.forceMove(locker) + TEST_ASSERT(cyberware_time_stop_witnessed(user), "A player in a locker didn't count as someone near") + hider.key = null + var/mob/living/basic/mouse/pet = allocate(/mob/living/basic/mouse, spot(2, 2)) + pet.key = "sandytimestoppet" + var/obj/item/mob_holder/holder = allocate(/obj/item/mob_holder, spot(2, 2), pet) + TEST_ASSERT(pet.loc == holder, "The mouse wasn't picked up") + TEST_ASSERT(cyberware_time_stop_witnessed(user), "A player carried as an item didn't count as someone near") + pet.key = null + TEST_ASSERT(!cyberware_time_stop_witnessed(user), "Nobody's near, but the user counted as watched") + /// An NPC's surge never slows the world, whoever is near /datum/unit_test/voidcrew_cyberware_timestop/npc_surge From 5ac509ee669e088383629a44b154fc87fcd8b759 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 03:31:36 -0800 Subject: [PATCH 16/33] Keep a surging NPC's shots untinted, and bring the slow-mo tests in line with every bullet crawling --- .../modules/cyberware/ware_sandy_timestop.dm | 12 +++++++++++- .../unit_tests/voidcrew_cyberware_timestop.dm | 17 +++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index ac01b8fa50a..c6ed9f4160d 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -673,15 +673,25 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) /datum/proximity_monitor/advanced/timestop/sandevistan/proc/hold_shot(obj/projectile/shot) held_shots[shot] = shot.speed shot.speed = held_shots[shot] * scale_for(shot) + if(!user_shots[shot]) // a shot is made, and taken, before it has a firer + RegisterSignal(shot, COMSIG_PROJECTILE_FIRE, PROC_REF(held_shot_fired), override = TRUE) + +/// Signal proc for [COMSIG_PROJECTILE_FIRE] on a held shot: now it has a firer, a surging mob's shot loses the tint. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/held_shot_fired(obj/projectile/shot) + SIGNAL_HANDLER + UnregisterSignal(shot, COMSIG_PROJECTILE_FIRE) + if(shot.firer && (surging[shot.firer] || cyberware_own_surge(shot.firer))) + escape_the_negative_zone(shot) /datum/proximity_monitor/advanced/timestop/sandevistan/proc/let_go_shot(obj/projectile/shot) shot.speed = held_shots[shot] held_shots -= shot + UnregisterSignal(shot, COMSIG_PROJECTILE_FIRE) /// Signal proc for [COMSIG_PROJECTILE_FIRER_BEFORE_FIRE] on the user: watch the shot leave the barrel. /datum/proximity_monitor/advanced/timestop/sandevistan/proc/on_user_fire(datum/source, obj/projectile/shot, atom/fired_from, atom/original) SIGNAL_HANDLER - RegisterSignal(shot, COMSIG_PROJECTILE_FIRE, PROC_REF(take_user_shot)) + RegisterSignal(shot, COMSIG_PROJECTILE_FIRE, PROC_REF(take_user_shot), override = TRUE) /// Signal proc for [COMSIG_PROJECTILE_FIRE]: the user's shot keeps its colour but crawls at the field's pace like /// every bullet in it. diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm index faed1647f21..9db2e915d11 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -264,7 +264,8 @@ TEST_ASSERT(gore > 4.9 && gore < 5.1, "A slowed NPC's gore doesn't play at 20% ([gore] for 1)") TEST_ASSERT_NOTNULL(cyberware_time_field(pool), "Blood on the floor in the field isn't slowed") var/list/steps = cyberware_dilated_steps(raider, list(2, 2)) - TEST_ASSERT(length(steps) == 2 && abs(steps[1] - 10) < 0.1 && abs(steps[2] - 10) < 0.1, "A slowed NPC's flinch doesn't play at 20% from its first step ([jointext(steps, ",")])") + var/both = cyberware_dilated_time(raider, 4) // the second step may run past the window's end, back at full speed + TEST_ASSERT(length(steps) == 2 && abs(steps[1] - 10) < 0.1 && abs(steps[1] + steps[2] - both) < 0.1, "A slowed NPC's flinch doesn't play at 20% from its first step ([jointext(steps, ",")], [both] in all)") // Bursts queued in the slow-mo wait longer. raider_gun.cyberware_burst_start = cyberware_burst_clock(raider) @@ -285,7 +286,8 @@ var/move_at = mech.cooldown_vehicle_move sleep(0.5 SECONDS) - TEST_ASSERT(cyberware_burst_wait(raider, raider_gun.cyberware_burst_start, 1, 1) > 0, "A queued burst shot fired at full speed") + // 0.5 s at 20% is 1 decisecond of the NPC's own time: a 2 decisecond gap is still waiting, at full speed it's long gone + TEST_ASSERT(cyberware_burst_wait(raider, raider_gun.cyberware_burst_start, 2, 1) > 0, "A queued burst shot fired at full speed") TEST_ASSERT(raider_gun.cyberware_burst_start > gun_start, "The NPC's burst clock wasn't pushed back") TEST_ASSERT(rat_gun.cyberware_burst_start > rat_start, "A basic mob's burst clock wasn't pushed back") TEST_ASSERT(turret.last_fired > fired_at, "A slowed turret's fire cooldown wasn't pushed back") @@ -356,7 +358,7 @@ TEST_ASSERT(!tinted(raider), "A surging NPC was tinted") TEST_ASSERT_EQUAL(cyberware_time_lost(raider, 10), 0, "A surging NPC's clock slowed") TEST_ASSERT_NULL(cyberware_time_field(raider), "A surging NPC's gore would be slowed") - TEST_ASSERT(!field.held_shots[shot] && shot.speed == shot_speed, "A surging NPC's shot was held") + TEST_ASSERT(field.held_shots[shot] && shot.speed == shot_speed * field.time_scale, "A surging NPC's shot doesn't fly at the field's pace like every bullet") sleep(0.5 SECONDS) // a couple of field ticks TEST_ASSERT(!raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "A surging NPC was slowed") @@ -396,7 +398,7 @@ TEST_ASSERT_NULL(GLOB.cyberware_time_dilated[raider], "The surging NPC is still in the field's hands") TEST_ASSERT_EQUAL(cyberware_time_lost(raider, 10), 0, "The surging NPC's clock stayed slow") TEST_ASSERT_NULL(field.slowed_tags[raider], "The surging NPC's slowed animations weren't dropped") - TEST_ASSERT(!field.held_shots[shot] && shot.speed == shot_speed, "The surging NPC's shot stayed slowed") + TEST_ASSERT(field.held_shots[shot] && shot.speed == shot_speed * field.time_scale, "The surging NPC's shot left the field's pace") sleep(0.5 SECONDS) // a few field ticks TEST_ASSERT(!raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "The field took the surging NPC back") user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) @@ -506,16 +508,17 @@ var/obj/projectile/bullet/user_shot = allocate(/obj/projectile/bullet, spot(1, 0)) user_shot.firer = user - var/user_speed = user_shot.speed field.take_user_shot(user_shot) + var/user_speed = field.held_shots[user_shot] // its own speed: the field may have taken it as it appeared TEST_ASSERT(field.held_shots[user_shot] && !user_shot.paused, "The user's slow-mo shot wasn't held, or hung") TEST_ASSERT_EQUAL(user_shot.speed, user_speed * field.time_scale, "The user's slow-mo shot doesn't crawl at the field's pace") TEST_ASSERT(!tinted(user_shot), "The user's slow-mo shot was tinted") var/obj/projectile/bullet/npc_shot = allocate(/obj/projectile/bullet, spot(2, 0)) npc_shot.firer = raider - var/npc_speed = npc_shot.speed field.freeze_atom(npc_shot) + SEND_SIGNAL(npc_shot, COMSIG_PROJECTILE_FIRE) // made, then fired + var/npc_speed = field.held_shots[npc_shot] TEST_ASSERT(field.held_shots[npc_shot] && !npc_shot.paused, "A surging NPC's shot wasn't held, or hung") TEST_ASSERT_EQUAL(npc_shot.speed, npc_speed * field.time_scale, "A surging NPC's shot doesn't fly at the field's pace") TEST_ASSERT(!tinted(npc_shot), "A surging NPC's shot was tinted") @@ -626,4 +629,6 @@ /datum/unit_test/voidcrew_cyberware_timestop/weather_slowed/proc/weather_states(image/slowed) . = list() for(var/overlay in slowed.overlays) + if(overlay:icon == 'icons/effects/glow_weather.dmi') + continue // rain has no glow state: that overlay draws nothing either way . += overlay:icon_state From 3116f29aede324c9d7fcf3f61c388a3ae091a890 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 03:53:18 -0800 Subject: [PATCH 17/33] Run the Sandevistan user's own mech or vehicle at full speed in their slow-mo, and keep the slow-mo running from inside a mech --- .../modules/cyberware/ware_sandy_timestop.dm | 25 +++++++++++++++- .../unit_tests/voidcrew_cyberware_timestop.dm | 30 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index c6ed9f4160d..5ccc21f0e1a 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -329,7 +329,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) QDEL_IN(src, duration) if(QDELETED(user)) return - var/datum/proximity_monitor/advanced/timestop/sandevistan/field = new(user, freezerange, TRUE, immune, antimagic_flags, FALSE, slow_scale, user_multiplier) + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = new(user, freezerange, FALSE, immune, antimagic_flags, FALSE, slow_scale, user_multiplier) // a user inside a mech still slows the world field.stopper = src field.ends_at = world.time + duration chronofield = field @@ -453,6 +453,8 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) if(cyberware_own_surge(A)) take_surger(A, cyberware_surge_multiplier(A)) return FALSE + if(own_ride(A)) // the user's mech or vehicle is part of them: full speed + return FALSE if(ismob(A)) var/mob/M = A if(M.can_block_magic(antimagic_flags)) @@ -756,6 +758,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) throw_datum.start_time += push push_machine_deadlines(push) check_surgers() + let_go_own_ride() for(var/mob/living/boss as anything in boss_holds.Copy()) if(world.time < boss_holds[boss]) continue @@ -763,6 +766,26 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) released[boss] = TRUE senses?.refresh_weather() +/// Whether `thing` is what the user is riding or piloting: a vehicle with them aboard, or anything they're buckled to. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/own_ride(atom/movable/thing) + if(!ismob(host) || !istype(thing)) + return FALSE + var/mob/living/user = host + if(istype(thing, /obj/vehicle)) + var/obj/vehicle/ride = thing + if(ride.is_occupant(user)) + return TRUE + return isliving(user) && user.buckled == thing + +/// The user climbed into a mech or onto a vehicle the field already holds: it runs at full speed from now on. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/let_go_own_ride() + if(!isliving(host)) + return + var/mob/living/user = host + for(var/atom/movable/ride in list(user.loc, user.buckled)) + if(frozen_things[ride] && own_ride(ride)) + unfreeze_atom(ride) + /datum/proximity_monitor/advanced/timestop/sandevistan/into_the_negative_zone(atom/A) tinted[A] = TRUE var/wait = ring_wait(A) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm index 9db2e915d11..6fc3fda5f7b 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -198,6 +198,36 @@ user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) +/// The user's own mech runs at full speed in their slow-mo, whether they were in it at the surge or climbed in after +/datum/unit_test/voidcrew_cyberware_timestop/own_mech_free + +/datum/unit_test/voidcrew_cyberware_timestop/own_mech_free/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + var/obj/vehicle/sealed/mecha/ripley/mech = allocate(/obj/vehicle/sealed/mecha/ripley, spot(0, 0)) + var/obj/vehicle/sealed/mecha/ripley/other_mech = allocate(/obj/vehicle/sealed/mecha/ripley, spot(2, 2)) + user.forceMove(mech) // moved_inside() wants a client + if(!mech.is_occupant(user)) + mech.add_occupant(user) + TEST_ASSERT(mech.is_occupant(user), "Test fixture: the user isn't in the mech") + var/datum/status_effect/cyberware_cascade_window/window = surge(user) + sleep(0.1 SECONDS) + TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = window.time_stop.chronofield + TEST_ASSERT(!field.held_mechs[mech], "The user's own mech was slowed") + TEST_ASSERT(!tinted(mech), "The user's own mech was tinted") + TEST_ASSERT(field.held_mechs[other_mech], "An empty mech in the field wasn't slowed") + + // Out of the first mech and into the slowed one: it's let go on the next tick. + mech.mob_exit(user, silent = TRUE) + user.forceMove(other_mech) // moved_inside() wants a client + if(!other_mech.is_occupant(user)) + other_mech.add_occupant(user) + sleep(0.3 SECONDS) + TEST_ASSERT(!field.held_mechs[other_mech], "A mech the user climbed into stayed slowed") + TEST_ASSERT(!tinted(other_mech), "A mech the user climbed into stayed tinted") + other_mech.mob_exit(user, silent = TRUE) + end_surge(user) + /// A player hidden in a locker, or picked up and carried as an item, still counts as someone near /datum/unit_test/voidcrew_cyberware_timestop/witnessed_hidden From bf9c049b0c994a2574a524d22857bc1065380a25 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:52:48 -0800 Subject: [PATCH 18/33] Sandevistan: no surge perks inside a slow-mo, crewmates slow like NPCs A surger carried by a slow-mo (its own, or another user's that runs it at its relative rate) loses the surge's speed, quicker blows, crit bonus and dodge; the slowed world is the edge. They come back if the slow-mo ends mid-window. The user's own crewmates no longer block the slow-mo; they are slowed with the rest of the world. --- voidcrew/modules/cyberware/ware_legend.dm | 31 +++++++++++++++---- .../modules/cyberware/ware_sandy_timestop.dm | 30 +++++++++++++++--- .../unit_tests/voidcrew_cyberware_timestop.dm | 6 +++- 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_legend.dm b/voidcrew/modules/cyberware/ware_legend.dm index 3b5acf17a59..85724fc9e04 100644 --- a/voidcrew/modules/cyberware/ware_legend.dm +++ b/voidcrew/modules/cyberware/ware_legend.dm @@ -196,6 +196,8 @@ var/crash_stamina = CYBERWARE_CASCADE_CRASH_STAMINA /// Steps taken so far, for the afterimage cadence. var/steps_taken = 0 + /// Whether the surge's own speed, quicker blows, crit and dodge are on now (refresh_perks()). + var/perks_on = FALSE /// Optional second argument: the lattice whose numbers this window runs on. /datum/status_effect/cyberware_cascade_window/on_creation(mob/living/new_owner, obj/item/organ/cyberimp/cyberware/cascade/lattice) @@ -218,10 +220,8 @@ /datum/status_effect/cyberware_cascade_window/on_apply() trail_hue = rand(0, 359) - owner.add_movespeed_modifier(speed_type) owner.add_movespeed_mod_immunities(id, /datum/movespeed_modifier/reagent) cyberware_register_dodge_source(owner, id, CALLBACK(src, PROC_REF(get_dodge_chance)), melee = TRUE) - owner.hv_crit_set_attacker_bonus(id, crit_bonus) RegisterSignal(owner, COMSIG_MOVABLE_MOVED, PROC_REF(on_move)) owner.add_client_colour(colour_type, id) playsound(owner, 'sound/effects/magic/timeparadox2.ogg', 70, TRUE) @@ -232,14 +232,14 @@ owner.balloon_alert(owner, "cascade online") cyberware_cascade_window_fx(owner) try_time_stop() // ware_sandy_timestop.dm + refresh_perks() return TRUE /datum/status_effect/cyberware_cascade_window/on_remove() end_time_stop() - owner.remove_movespeed_modifier(speed_type) + set_perks(FALSE) owner.remove_movespeed_mod_immunities(id, /datum/movespeed_modifier/reagent) cyberware_unregister_dodge_source(owner, id) - owner.hv_crit_set_attacker_bonus(id, null) UnregisterSignal(owner, COMSIG_MOVABLE_MOVED) owner.remove_client_colour(id) if(!QDELETED(owner) && owner.stat != DEAD) @@ -250,13 +250,32 @@ /// Melee cooldowns scaled for the window (changeNext_move multiplies these in). /datum/status_effect/cyberware_cascade_window/nextmove_modifier() - return melee_mult + return perks_on ? melee_mult : 1 + +/** + * The surge's own edge (speed, quicker blows, crit, dodge) is off while a slow-mo carries the surger: its own + * (ware_sandy_timestop.dm), or someone else's that runs it at its relative rate. There it moves and swings at + * normal speed and only the world around it is slowed. Call whenever either changes. + */ +/datum/status_effect/cyberware_cascade_window/proc/refresh_perks() + set_perks(!time_stop && !cyberware_surge_in_field(owner)) + +/datum/status_effect/cyberware_cascade_window/proc/set_perks(on) + if(on == perks_on) + return + perks_on = on + if(on) + owner.add_movespeed_modifier(speed_type) + owner.hv_crit_set_attacker_bonus(id, crit_bonus) + else + owner.remove_movespeed_modifier(speed_type) + owner.hv_crit_set_attacker_bonus(id, null) /// Dodge-source callback for the shared arbiter: flat chance while the window /// lives (the arbiter itself enforces the moving-only and point-blank rules /// for shots, and the pinned rules for blows). /datum/status_effect/cyberware_cascade_window/proc/get_dodge_chance() - return dodge_chance + return perks_on ? dodge_chance : 0 /// Signal proc for [COMSIG_MOVABLE_MOVED]: one afterimage per step, the /// telegraph everyone in the fight can read. Each step shifts the hue, so the diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index 5ccc21f0e1a..c87dde04174 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -10,7 +10,7 @@ * Cascade T4 8 s 20% * * Pressing the surge again ends the window early, slow-mo and all, and the crash follows as usual. With another - * player near, the surge is the plain one; if one comes in range mid-surge, the slow-mo ends there and the surge + * player near (the user's own crewmates don't count: they slow like an NPC), the surge is the plain one; if one comes in range mid-surge, the slow-mo ends there and the surge * carries on without it. Only a player's surge does this (the user needs a ckey): an NPC criminal's lattice never does. * * Built on tg's time stop field (code/datums/proximity_monitor/fields/timestop.dm), hosted on the user so @@ -127,6 +127,20 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) if(field.host != surger) field.surge_free(surger, multiplier) +/// Whether some other user's field is running `surger` at its relative rate. +/proc/cyberware_surge_in_field(mob/living/surger) + for(var/datum/proximity_monitor/advanced/timestop/sandevistan/field as anything in GLOB.cyberware_time_fields) + if(field.surging[surger]) + return TRUE + return FALSE + +/// A field just took or let go of `surger`: its surge's own edge goes off or back on (ware_legend.dm). +/proc/cyberware_surge_refresh_perks(mob/living/surger) + if(QDELETED(surger)) + return + var/datum/status_effect/cyberware_cascade_window/window = surger.has_status_effect(/datum/status_effect/cyberware_cascade_window) + window?.refresh_perks() + /// The time multiplier of `body`'s open surge window, or 1 with none. /proc/cyberware_surge_multiplier(mob/living/body) if(!isliving(body)) @@ -214,24 +228,26 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/due = start + cyberware_time_lag(shooter) + gap * index return due - world.time >= world.tick_lag ? due - world.time : 0 -/// Whether a player other than `user` is within `range` tiles: any living body with a ckey, connected or not. +/// Whether a player other than `user` is within `range` tiles: any living body with a ckey, connected or not. The user's +/// own crewmates don't count: they are slowed with the rest of the world, like an NPC. /proc/cyberware_time_stop_witnessed(mob/living/user, range = SANDY_STOP_WITNESS_RANGE) var/turf/here = get_turf(user) if(!here) return TRUE + var/datum/team/user_crew = cyberware_crew_team(user) // ware_pro_combat.dm for(var/atom/movable/found as anything in SSspatial_grid.orthogonal_range_search(here, SPATIAL_GRID_CONTENTS_TYPE_HEARING, range)) if(found == user) continue if(isliving(found)) var/mob/living/body = found - if(body.ckey) + if(body.ckey && (!user_crew || cyberware_crew_team(body) != user_crew)) return TRUE continue if(ismob(found)) // ghosts and camera eyes don't count continue // a locker or a mech with a player in it, or a small player picked up and carried as an item for(var/mob/living/inside in found.get_all_contents()) - if(inside != user && inside.ckey) + if(inside != user && inside.ckey && (!user_crew || cyberware_crew_team(inside) != user_crew)) return TRUE return FALSE @@ -410,10 +426,13 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) UnregisterSignal(host, COMSIG_PROJECTILE_FIRER_BEFORE_FIRE) stopper = null GLOB.cyberware_time_fields -= src + var/list/were_surging = surging.Copy() for(var/mob/living/surger as anything in surging) UnregisterSignal(surger, COMSIG_QDELETING) surging.Cut() . = ..() // unfreeze_all() lets everything go + for(var/mob/living/surger as anything in were_surging) + cyberware_surge_refresh_perks(surger) // a surge still open gets its own edge back own_scales.Cut() released.Cut() @@ -588,6 +607,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) /datum/proximity_monitor/advanced/timestop/sandevistan/proc/take_surger(mob/living/surger, multiplier) surging[surger] = TRUE RegisterSignal(surger, COMSIG_QDELETING, PROC_REF(held_gone), override = TRUE) + cyberware_surge_refresh_perks(surger) // the field paces it now, not its own edge var/rate = relative_rate(multiplier) if(abs(rate - 1) < SANDY_STOP_SAME_RATE) return @@ -641,6 +661,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) unfreeze_atom(surger) surging -= surger UnregisterSignal(surger, COMSIG_QDELETING) + cyberware_surge_refresh_perks(surger) if(surger.stat == DEAD) released[surger] = TRUE continue @@ -884,6 +905,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) owner.remove_filter(SANDY_STOP_OUTLINE) sandy_release_flash(owner) owner.add_client_colour(colour_type, id) + refresh_perks() // the rest of the window is an ordinary surge /// Ends the dilation early (the window is closing). /datum/status_effect/cyberware_cascade_window/proc/end_time_stop() diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm index 6fc3fda5f7b..9dce304e1e5 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -345,7 +345,7 @@ TEST_ASSERT(lattice.Insert(raider, special = TRUE), "Test fixture: the lattice wouldn't go in the NPC") return raider -/// No reflex dodge while the user's own world is slowed; it comes back if the slow-mo ends before the window does +/// No reflex dodge, speed, quicker blows or crit edge while the user's own world is slowed; they come back if the slow-mo ends before the window does /datum/unit_test/voidcrew_cyberware_timestop/no_dodge_while_slowed /datum/unit_test/voidcrew_cyberware_timestop/no_dodge_while_slowed/Run() @@ -360,12 +360,16 @@ TEST_ASSERT_NOTNULL(arbiter, "The window has no dodge") TEST_ASSERT(cyberware_time_stopping(user), "The user isn't marked as slowing time") TEST_ASSERT_EQUAL(arbiter.on_check_block(user, knife, 10, "the knife", MELEE_ATTACK), FAILED_BLOCK, "The user dodged a blow in slow-mo") + TEST_ASSERT(!user.has_movespeed_modifier(window.speed_type), "The user kept the surge's speed in slow-mo") + TEST_ASSERT_EQUAL(window.nextmove_modifier(), 1, "The user kept the surge's quicker blows in slow-mo") sleep(1.2 SECONDS) TEST_ASSERT_EQUAL(arbiter.on_check_block(user, knife, 10, "the knife", MELEE_ATTACK), FAILED_BLOCK, "The user dodged a blow later in slow-mo") qdel(window.time_stop) // as when a player comes in range TEST_ASSERT_NULL(window.time_stop, "Test fixture: the dilation didn't end") TEST_ASSERT(!cyberware_time_stopping(user), "The user still counts as slowing time") TEST_ASSERT_EQUAL(arbiter.on_check_block(user, knife, 10, "the knife", MELEE_ATTACK), SUCCESSFUL_BLOCK, "The dodge didn't come back after the slow-mo ended") + TEST_ASSERT(user.has_movespeed_modifier(window.speed_type), "The surge's speed didn't come back after the slow-mo ended") + TEST_ASSERT_EQUAL(window.nextmove_modifier(), window.melee_mult, "The surge's quicker blows didn't come back after the slow-mo ended") user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) From 6de1b3b5d26edfd07ef8e0c0fab8446660ab8c96 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:53:53 -0800 Subject: [PATCH 19/33] Sandevistan: replay the slow-mo at normal speed when it ends (blocking) --- tgstation.dme | 1 + voidcrew/modules/cyberware/ware_legend.dm | 3 +- .../modules/cyberware/ware_sandy_replay.dm | 929 ++++++++++++++++++ .../modules/cyberware/ware_sandy_timestop.dm | 19 + voidcrew/modules/unit_tests/_unit_tests.dm | 1 + .../unit_tests/voidcrew_cyberware_replay.dm | 258 +++++ .../unit_tests/voidcrew_cyberware_timestop.dm | 16 +- 7 files changed, 1224 insertions(+), 3 deletions(-) create mode 100644 voidcrew/modules/cyberware/ware_sandy_replay.dm create mode 100644 voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm diff --git a/tgstation.dme b/tgstation.dme index 56c6b5be276..d430e6a22da 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7229,6 +7229,7 @@ #include "voidcrew\modules\cyberware\ware_pro_combat.dm" #include "voidcrew\modules\cyberware\ware_pro_utility.dm" #include "voidcrew\modules\cyberware\ware_registry.dm" +#include "voidcrew\modules\cyberware\ware_sandy_replay.dm" #include "voidcrew\modules\cyberware\ware_sandy_senses.dm" #include "voidcrew\modules\cyberware\ware_sandy_timestop.dm" #include "voidcrew\modules\cyberware\ware_second_wind.dm" diff --git a/voidcrew/modules/cyberware/ware_legend.dm b/voidcrew/modules/cyberware/ware_legend.dm index 85724fc9e04..838d6014248 100644 --- a/voidcrew/modules/cyberware/ware_legend.dm +++ b/voidcrew/modules/cyberware/ware_legend.dm @@ -242,7 +242,8 @@ cyberware_unregister_dodge_source(owner, id) UnregisterSignal(owner, COMSIG_MOVABLE_MOVED) owner.remove_client_colour(id) - if(!QDELETED(owner) && owner.stat != DEAD) + // A slow-mo that ran to its end replays first, and the crash lands when the replay is over (ware_sandy_replay.dm). + if(!QDELETED(owner) && owner.stat != DEAD && !sandy_replay_defer_crash(owner, crash_duration, crash_stamina)) owner.apply_status_effect(/datum/status_effect/cyberware_cascade_crash, crash_duration, crash_stamina) if(!QDELETED(owner)) cyberware_cascade_window_end_fx(owner) diff --git a/voidcrew/modules/cyberware/ware_sandy_replay.dm b/voidcrew/modules/cyberware/ware_sandy_replay.dm new file mode 100644 index 00000000000..2173816b571 --- /dev/null +++ b/voidcrew/modules/cyberware/ware_sandy_replay.dm @@ -0,0 +1,929 @@ +/** + * # The Sandevistan replay + * + * A slow-mo (ware_sandy_timestop.dm) records how everything in its field looked and where it stood. When the + * slow-mo runs out, or the user ends it, the world shows it all again from the start at normal speed: a Cascade's + * 8 s at 20% plays back in 1.6 s. A slow-mo that another player cut short doesn't replay. + * + * Nothing is moved and nothing happens twice. Every real thing stays on the tile it really ended on. For each + * frame it is given the look it had then and is drawn at the spot it stood on with a pixel offset (a "puppet"). + * Only things that are really gone (a spent shot, a gibbed body), or that ended up inside something, get a + * stand-in effect. When the replay ends every puppet gets its real look back. + * + * Recording: one sample per tick of the slowed world (world.tick_lag / time_scale of real time), so each sample is + * one tick of the replay. A sample is the thing's appearance, turf and dir, kept only when it changed. The field + * hands over everything it sees (freeze_atom() is the one place everything on, entering or made on a field turf + * goes through). The field's tint and the user's edge glow are taken out of the recorded looks. + * + * The real look under a puppet. Game code keeps changing the real things while they are drawn as something else + * (Life() runs on, so a body can bleed, fall, die or drop its gun mid-replay). The procs every such change goes + * through are wrapped at the bottom of this file: they put the real look back first, let the change land on it, and + * the puppet goes back on after. A change that writes vars directly is caught on the next frame by comparing the + * puppet with what was drawn, and the changed vars are carried over to the real look (fold()). + * + * For the length of the replay AIs are paused and the user and their crewmates can't act. Life() keeps running: + * this is a lock on decisions and input only, no stun and no damage. A player who isn't crew coming near ends the + * replay at once, a watchdog timer ends it whatever happens, and finish() can be called any number of times. + * The surge's crash starts when the replay ends. + * + * Not replayed (they show as they really are): lights, fire, smoke and particles, turf changes, attack lunges, + * flick()s, anything in vis_contents, runechat and sounds. + */ + +/// Trait source of the replay's AI pause and input lock. +#define SANDY_REPLAY_TRAIT "sandy_replay" +/// The user's edge glow in a slow-mo (SANDY_STOP_OUTLINE in ware_sandy_timestop.dm, which is included after this file). +#define SANDY_REPLAY_OUTLINE "sandy_time_stop_outline" + +#define SANDY_REPLAY_RECORDING 1 +#define SANDY_REPLAY_PENDING 2 +#define SANDY_REPLAY_PLAYING 3 +#define SANDY_REPLAY_DONE 4 + +#define SANDY_TRACK_PUPPET 1 +#define SANDY_TRACK_STANDIN 2 + +/// The furthest from its real tile a puppet is drawn, in tiles. Past it the thing is hidden for that frame. +#define SANDY_REPLAY_REACH 24 +/// The longest move between two frames that is tweened, in pixels. Longer ones are jumps. +#define SANDY_REPLAY_GLIDE_MAX 96 +/// Extra time the watchdog gives the replay before it ends it. +#define SANDY_REPLAY_WATCHDOG (1 SECONDS) + +// A track's frames are a flat list, this many entries a frame. +#define SANDY_FRAME_SIZE 6 +#define SANDY_FRAME_INDEX 1 +#define SANDY_FRAME_LOOK 2 +#define SANDY_FRAME_X 3 +#define SANDY_FRAME_Y 4 +#define SANDY_FRAME_Z 5 +#define SANDY_FRAME_DIR 6 + +/// Every replay being recorded or played, by its user. +GLOBAL_LIST_EMPTY(sandy_replays) +/// Every thing drawn as a puppet right now, to its track. +GLOBAL_LIST_EMPTY(sandy_replay_masked) +/// length(GLOB.sandy_replay_masked), as a real global: the wrappers at the bottom of this file read it on every overlay change. +GLOBAL_REAL_VAR(sandy_replay_masks) +/// Numbers from the last replay that finished, for tests and recordings. +GLOBAL_LIST_EMPTY(sandy_replay_last) + +/// One recorded thing. +/datum/sandy_replay_track + var/datum/sandy_replay/owner + /// The real thing. Null once it is deleted. + var/atom/movable/thing + /// The frame it was first seen at. Above 0 for something made, or walking in, mid-window. + var/first = 0 + /// The frame it was deleted at. + var/last = INFINITY + /// Flat list of frames: index, look, x, y, z (0 when not on a turf), dir. + var/list/frames = list() + /// Recorder: the thing's raw appearance at the last sample. + var/last_raw + /// It was on a surge during the window: the user, or a mob on its own surge in the field. + var/surger = FALSE + /// Playback: offset in `frames` of the frame shown before the current one. + var/came_from = -SANDY_FRAME_SIZE + /// Playback: SANDY_TRACK_PUPPET or SANDY_TRACK_STANDIN. + var/mode + /// Playback: offset in `frames` of the frame showing now. Below 0 before the first. + var/cursor = -SANDY_FRAME_SIZE + /// Playback: the tween from the last spot, in pixels still to cover at its start, and when it ends. + var/glide_x = 0 + var/glide_y = 0 + var/glide_time = 0 + var/glide_until = 0 + /// Puppet: the thing's real look, dir, opacity and luminosity. + var/saved_look + var/saved_dir + var/saved_opacity + var/saved_luminosity + /// Puppet: the appearance it was last drawn with, to notice changes made by anything else. + var/expect + /// Puppet: wearing its real look right now, while a wrapped proc changes it. + var/unmasked = FALSE + /// Stand-in: the effect. + var/obj/effect/abstract/sandy_replay_standin/standin + +/// Carries the recorded look of something that is gone, or isn't on a turf any more. +/obj/effect/abstract/sandy_replay_standin + name = "" + anchored = TRUE + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + animate_movement = NO_STEPS + resistance_flags = INDESTRUCTIBLE | LAVA_PROOF | FIRE_PROOF | UNACIDABLE | ACID_PROOF + +/// What the user's view follows during the replay. +/obj/effect/abstract/sandy_replay_eye + name = "" + anchored = TRUE + invisibility = INVISIBILITY_ABSTRACT + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + animate_movement = NO_STEPS + resistance_flags = INDESTRUCTIBLE | LAVA_PROOF | FIRE_PROOF | UNACIDABLE | ACID_PROOF + +/datum/sandy_replay + /// Whose slow-mo this is. + var/mob/living/user + /// The field being recorded. Null once it is over. + var/datum/proximity_monitor/advanced/timestop/sandevistan/field + var/state = SANDY_REPLAY_RECORDING + /// world.time the recording began. + var/started = 0 + /// Real time between samples. + var/interval = 1 + var/sample_timer + var/begin_timer + /// Things being recorded, to their tracks. + var/list/tracks = list() + /// Tracks of things deleted while recording. + var/list/gone = list() + /// The last frame's index. + var/total = 0 + /// The field is moving onto new turfs: what it finds there was standing there since frame 0. + var/covering = FALSE + /// world.time playback began. + var/play_started = 0 + var/play_timer + var/watchdog + /// Tracks drawn on their real things. + var/list/puppets = list() + /// Tracks drawn on stand-ins. + var/list/standins = list() + /// Mobs with their AI paused or input locked, to their simple-animal AI status (or TRUE). + var/list/locked = list() + /// Mechs switched off, to what completely_disabled was. + var/list/mechs = list() + /// The user's own track, for the camera. + var/datum/sandy_replay_track/user_track + /// What the user's view follows. + var/obj/effect/abstract/sandy_replay_eye/eye + /// The user's client pixel offsets before the replay. + var/eye_pixel_x = 0 + var/eye_pixel_y = 0 + /// The crash waiting for the replay to end: list(length, stamina). + var/list/crash + var/finished = FALSE + /// Changes to a puppet that no wrapped proc made (fold()). + var/unhooked = 0 + /// Times a wrapped proc changed a puppet. + var/unmasks = 0 + +/datum/sandy_replay/New(datum/proximity_monitor/advanced/timestop/sandevistan/_field, mob/living/_user) + field = _field + user = _user + started = world.time + interval = world.tick_lag / field.time_scale + var/datum/sandy_replay/old = GLOB.sandy_replays[user] + if(old) + qdel(old) // a replay still showing ends here + GLOB.sandy_replays[user] = src + sample_timer = addtimer(CALLBACK(src, PROC_REF(sample)), interval, TIMER_LOOP | TIMER_STOPPABLE | TIMER_DELETE_ME) + notice(user) + +/datum/sandy_replay/Destroy() + finish() + deltimer(sample_timer) + deltimer(begin_timer) + for(var/atom/movable/thing as anything in tracks) + UnregisterSignal(thing, COMSIG_QDELETING) + var/datum/sandy_replay_track/track = tracks[thing] + track.thing = null + track.owner = null + tracks.Cut() + gone.Cut() + puppets.Cut() + standins.Cut() + user_track = null + if(user && GLOB.sandy_replays[user] == src) + GLOB.sandy_replays -= user + if(field?.replay == src) + field.replay = null + field = null + user = null + return ..() + +// ========================================================================= +// RECORDING +// ========================================================================= + +/// The frame the recording is at now. +/datum/sandy_replay/proc/frame_now() + if(state != SANDY_REPLAY_RECORDING) + return total + return round((world.time - started) / interval) + +/// Whether `thing` is recorded at all. +/datum/sandy_replay/proc/recordable(atom/movable/thing) + var/static/list/skipped = typecacheof(list( + /obj/effect/abstract, + /obj/effect/timestop, + /obj/effect/temp_visual/decoy/fading/cascade, // the surge's trail + /obj/effect/temp_visual/cyberware_time_ring, + /obj/effect/hotspot, + /obj/effect/particle_effect, + /obj/effect/landmark, + /atom/movable/screen, + )) + if(!ismovable(thing) || QDELETED(thing) || thing.invisibility >= INVISIBILITY_ABSTRACT) + return FALSE + if(ismob(thing) && !isliving(thing)) // ghosts and camera eyes + return FALSE + return !skipped[thing.type] + +/// The field saw `thing`: start recording it. +/datum/sandy_replay/proc/notice(atom/movable/thing) + if(state != SANDY_REPLAY_RECORDING || tracks[thing] || !recordable(thing)) + return + var/datum/sandy_replay_track/track = new + track.owner = src + track.thing = thing + track.first = covering ? 0 : frame_now() + tracks[thing] = track + RegisterSignal(thing, COMSIG_QDELETING, PROC_REF(on_thing_deleted)) + sample_track(track, track.first) + +/// The field just changed how `thing` looks (its tint): take the sample again. +/datum/sandy_replay/proc/resample(atom/movable/thing) + var/datum/sandy_replay_track/track = tracks[thing] + if(track && state == SANDY_REPLAY_RECORDING) + track.last_raw = null + sample_track(track, frame_now()) + +/// The sample timer: one frame of everything recorded. +/datum/sandy_replay/proc/sample() + if(state != SANDY_REPLAY_RECORDING) + return + var/frame = frame_now() + for(var/atom/movable/thing as anything in tracks) + sample_track(tracks[thing], frame) + +/// Adds a frame to `track` if the thing changed since its last one. +/datum/sandy_replay/proc/sample_track(datum/sandy_replay_track/track, frame) + var/atom/movable/thing = track.thing + var/turf/here = thing.loc + var/x = 0 + var/y = 0 + var/z = 0 + if(isturf(here)) + x = here.x + y = here.y + z = here.z + var/raw = thing.appearance + var/facing = thing.dir + var/list/frames = track.frames + var/at = length(frames) - SANDY_FRAME_SIZE + if(at >= 0 && raw == track.last_raw && frames[at + SANDY_FRAME_X] == x && frames[at + SANDY_FRAME_Y] == y && frames[at + SANDY_FRAME_Z] == z && frames[at + SANDY_FRAME_DIR] == facing) + return + track.last_raw = raw + if(!track.surger && (thing == user || field?.surging[thing])) + track.surger = TRUE + var/look = clean_look(thing) + if(at >= 0) + if(frames[at + SANDY_FRAME_LOOK] == look && frames[at + SANDY_FRAME_X] == x && frames[at + SANDY_FRAME_Y] == y && frames[at + SANDY_FRAME_Z] == z && frames[at + SANDY_FRAME_DIR] == facing) + return + if(frames[at + SANDY_FRAME_INDEX] >= frame) // sampled twice in one frame: the later look stands + frames[at + SANDY_FRAME_LOOK] = look + frames[at + SANDY_FRAME_X] = x + frames[at + SANDY_FRAME_Y] = y + frames[at + SANDY_FRAME_Z] = z + frames[at + SANDY_FRAME_DIR] = facing + return + frames += list(frame, look, x, y, z, facing) + +/// `thing`'s appearance without the slow-mo's own look: the field's tint and the user's edge glow. +/datum/sandy_replay/proc/clean_look(atom/movable/thing) + var/tinted = field?.tinted[thing] + var/outlined = thing == user && thing.filter_data?[SANDY_REPLAY_OUTLINE] + if(!tinted && !outlined) + return thing.appearance + var/mutable_appearance/copy = new(thing) + if(tinted) + copy.color = untinted_colour(thing) + copy.appearance_flags |= KEEP_TOGETHER // update_atom_colour() leaves this on once anything was ever coloured + if(outlined) + copy.filters = null + for(var/name in thing.filter_data) + if(name == SANDY_REPLAY_OUTLINE) + continue + var/list/data = thing.filter_data[name] + var/list/arguments = data.Copy() + arguments -= "priority" + copy.filters += filter(arglist(arguments)) + return copy.appearance + +/// The colour [/atom/proc/update_atom_colour] would give `thing` without the field's tint. +/datum/sandy_replay/proc/untinted_colour(atom/thing) + if(!length(thing.atom_colours)) + return thing.color + for(var/priority in 1 to length(thing.atom_colours)) + if(priority == TEMPORARY_COLOUR_PRIORITY) + continue + var/list/entry = thing.atom_colours[priority] + if(!entry) + continue + if(entry[ATOM_COLOR_TYPE_INDEX] == ATOM_COLOR_TYPE_FILTER) + return null + if(length(entry[ATOM_COLOR_VALUE_INDEX])) + return entry[ATOM_COLOR_VALUE_INDEX] + return null + +/// Signal proc for [COMSIG_QDELETING] on anything recorded. +/datum/sandy_replay/proc/on_thing_deleted(atom/movable/thing) + SIGNAL_HANDLER + if(thing == user) + qdel(src) + return + var/datum/sandy_replay_track/track = tracks[thing] + tracks -= thing + if(!track) + return + if(GLOB.sandy_replay_masked[thing] == track) + GLOB.sandy_replay_masked -= thing + sandy_replay_masks = length(GLOB.sandy_replay_masked) + track.thing = null + if(state == SANDY_REPLAY_RECORDING || state == SANDY_REPLAY_PENDING) + track.last = max(frame_now(), track.first + 1) + gone += track + return + puppets -= track + +/// A player cut the slow-mo short: nothing replays. +/datum/sandy_replay/proc/cancel() + if(state == SANDY_REPLAY_RECORDING) + qdel(src) + +/// The field is over (it ran out, or the user ended it): playback starts next tick, once the slow-mo's own looks +/// are off everything. +/datum/sandy_replay/proc/field_over() + if(state != SANDY_REPLAY_RECORDING) + return + total = max(frame_now(), 1) + state = SANDY_REPLAY_PENDING + field = null + deltimer(sample_timer) + sample_timer = null + begin_timer = addtimer(CALLBACK(src, PROC_REF(begin)), world.tick_lag, TIMER_STOPPABLE | TIMER_DELETE_ME) + +// ========================================================================= +// PLAYBACK +// ========================================================================= + +/// Starts the replay: the last sample is everything's real final state, then everything goes back to frame 0. +/datum/sandy_replay/proc/begin() + begin_timer = null + if(state != SANDY_REPLAY_PENDING) + return + if(QDELETED(user) || cyberware_time_stop_witnessed(user)) + finish() + return + state = SANDY_REPLAY_PLAYING + watchdog = addtimer(CALLBACK(src, PROC_REF(finish)), (total + 2) * world.tick_lag + SANDY_REPLAY_WATCHDOG, TIMER_STOPPABLE | TIMER_DELETE_ME) + try + setup_playback() + catch(var/exception/error) + finish() + stack_trace("Sandevistan replay failed to start: [error] ([error.file]:[error.line])") + return + if(finished) + return + play_started = world.time + play_timer = addtimer(CALLBACK(src, PROC_REF(play_frame)), world.tick_lag, TIMER_LOOP | TIMER_STOPPABLE | TIMER_DELETE_ME) + play_frame() + +/datum/sandy_replay/proc/setup_playback() + lock(user) + for(var/atom/movable/thing as anything in tracks) + var/datum/sandy_replay_track/track = tracks[thing] + track.last_raw = null + sample_track(track, total) + if(isliving(thing)) + lock(thing) + else if(ismecha(thing)) + var/obj/vehicle/sealed/mecha/mech = thing + mechs[mech] = mech.completely_disabled + mech.completely_disabled = TRUE + if(length(track.frames) == SANDY_FRAME_SIZE && track.first == 0) + continue // it never changed: nothing to show + if(isturf(thing.loc)) + track.mode = SANDY_TRACK_PUPPET + puppets += track + mask(track) + else + track.mode = SANDY_TRACK_STANDIN + standins += track + for(var/datum/sandy_replay_track/track as anything in gone) + track.mode = SANDY_TRACK_STANDIN + standins += track + gone.Cut() + user_track = tracks[user] + RegisterSignal(user, COMSIG_MOVABLE_Z_CHANGED, PROC_REF(on_user_z_changed)) + if(user.client && user.stat != DEAD && isturf(user.loc) && user_track?.mode == SANDY_TRACK_PUPPET) + eye = new(user.loc) + eye_pixel_x = user.client.pixel_x + eye_pixel_y = user.client.pixel_y + user.reset_perspective(eye) + +/// Pauses `body`'s AI and locks its player's input. No stun: decisions and input only. +/datum/sandy_replay/proc/lock(mob/living/body) + if(locked[body] || QDELETED(body)) + return + locked[body] = TRUE + if(body.ckey && body.stat != DEAD) + body.add_traits(list(TRAIT_IMMOBILIZED, TRAIT_INCAPACITATED), SANDY_REPLAY_TRAIT) + if(body.ai_controller) + ADD_TRAIT(body, TRAIT_AI_PAUSED, SANDY_REPLAY_TRAIT) + if(isanimal(body)) + var/mob/living/simple_animal/animal = body + locked[body] = animal.AIStatus + animal.toggle_ai(AI_OFF) + +/datum/sandy_replay/proc/unlock(mob/living/body) + var/was = locked[body] + if(QDELETED(body)) + return + body.remove_traits(list(TRAIT_IMMOBILIZED, TRAIT_INCAPACITATED, TRAIT_AI_PAUSED), SANDY_REPLAY_TRAIT) + if(isanimal(body) && isnum(was)) + var/mob/living/simple_animal/animal = body + if(animal.AIStatus == AI_OFF) + animal.toggle_ai(was) + +/// Puts the puppet on `track`'s thing: its real look is kept, and it is drawn as its current frame. +/datum/sandy_replay/proc/mask(datum/sandy_replay_track/track) + var/atom/movable/thing = track.thing + keep_real(track) + GLOB.sandy_replay_masked[thing] = track + sandy_replay_masks = length(GLOB.sandy_replay_masked) + RegisterSignal(thing, COMSIG_MOVABLE_MOVED, PROC_REF(on_puppet_moved)) + RegisterSignal(thing, COMSIG_ATOM_DIR_CHANGE, PROC_REF(on_puppet_turned)) + show(track) + +/// Takes `track`'s thing as it is now for its real look. +/datum/sandy_replay/proc/keep_real(datum/sandy_replay_track/track) + var/atom/movable/thing = track.thing + track.saved_look = thing.appearance + track.saved_dir = thing.dir + track.saved_opacity = thing.opacity + track.saved_luminosity = thing.luminosity + +/// Gives `track`'s thing its real look back. Assigning an appearance never touches density, and opacity and +/// luminosity are pinned while it is a puppet, so nothing the game reads changes. +/datum/sandy_replay/proc/wear_real(datum/sandy_replay_track/track) + var/atom/movable/thing = track.thing + if(thing.appearance != track.expect) + fold(track) + animate(thing, time = 0, flags = ANIMATION_END_NOW) + thing.appearance = track.saved_look + thing.dir = track.saved_dir + pin(track) + +/// Appearance assignment copies opacity and luminosity: keep the real ones. +/datum/sandy_replay/proc/pin(datum/sandy_replay_track/track) + var/atom/movable/thing = track.thing + if(thing.opacity != track.saved_opacity) + thing.opacity = track.saved_opacity + if(thing.luminosity != track.saved_luminosity) + thing.luminosity = track.saved_luminosity + +/// A wrapped proc is about to change `track`'s thing: it gets its real look for that. +/datum/sandy_replay/proc/unmask(datum/sandy_replay_track/track) + wear_real(track) + track.unmasked = TRUE + unmasks++ + +/// The change is done: what the thing looks like now is its real look, and the puppet goes back on. +/datum/sandy_replay/proc/remask(datum/sandy_replay_track/track) + if(!track.unmasked || QDELETED(track.thing)) + return + track.unmasked = FALSE + keep_real(track) + show(track) + +/** + * Something changed a puppet without going through a wrapped proc (a direct var write, an animate()). Carries the + * vars that differ from what was drawn over to the real look. Best effort: a var set to the very value the puppet + * was showing can't be seen. + */ +/datum/sandy_replay/proc/fold(datum/sandy_replay_track/track) + var/mutable_appearance/now = new(track.thing) + var/mutable_appearance/drawn = new(track.expect) + var/mutable_appearance/real = new(track.saved_look) + var/changed = FALSE + // pixel_x and pixel_y are taken as plain writes (tg sets them far more often than it shifts them). pixel_w and + // pixel_z carry the puppet's own shift, so only what was added to them is carried over. + for(var/name in list("icon", "icon_state", "alpha", "layer", "plane", "invisibility", "name", "blend_mode", "pixel_x", "pixel_y")) + if(now.vars[name] != drawn.vars[name]) + real.vars[name] = now.vars[name] + changed = TRUE + if(!(now.color ~= drawn.color)) + real.color = now.color + changed = TRUE + if(!(now.transform ~= drawn.transform)) + real.transform = now.transform + changed = TRUE + for(var/name in list("pixel_w", "pixel_z")) + if(now.vars[name] != drawn.vars[name]) + real.vars[name] += now.vars[name] - drawn.vars[name] + changed = TRUE + if(!changed) + return + track.saved_look = real.appearance + unhooked++ + +/// Moves `track` on to the newest frame at or before `frame`. Returns TRUE if that is a new one. +/datum/sandy_replay/proc/advance(datum/sandy_replay_track/track, frame) + var/list/frames = track.frames + var/old = track.cursor + var/at = old + var/end = length(frames) + while(at + SANDY_FRAME_SIZE < end && frames[at + SANDY_FRAME_SIZE + SANDY_FRAME_INDEX] <= frame) + at += SANDY_FRAME_SIZE + if(at == old) + return FALSE + track.came_from = old + track.cursor = at + var/left = track.glide_until - world.time + var/carry_x = 0 + var/carry_y = 0 + if(left > 0 && track.glide_time > 0) + carry_x = track.glide_x * left / track.glide_time + carry_y = track.glide_y * left / track.glide_time + track.glide_until = 0 + if(old < 0 || !frames[old + SANDY_FRAME_Z] || frames[old + SANDY_FRAME_Z] != frames[at + SANDY_FRAME_Z]) + return TRUE + // Tween from where it was drawn to where it is now. + var/mutable_appearance/was = frames[old + SANDY_FRAME_LOOK] + var/mutable_appearance/now = frames[at + SANDY_FRAME_LOOK] + var/stepped = frames[old + SANDY_FRAME_X] != frames[at + SANDY_FRAME_X] || frames[old + SANDY_FRAME_Y] != frames[at + SANDY_FRAME_Y] + var/move_x = (frames[old + SANDY_FRAME_X] - frames[at + SANDY_FRAME_X]) * ICON_SIZE_X + (was.pixel_x + was.pixel_w) - (now.pixel_x + now.pixel_w) + carry_x + var/move_y = (frames[old + SANDY_FRAME_Y] - frames[at + SANDY_FRAME_Y]) * ICON_SIZE_Y + (was.pixel_y + was.pixel_z) - (now.pixel_y + now.pixel_z) + carry_y + if((!move_x && !move_y) || abs(move_x) > SANDY_REPLAY_GLIDE_MAX || abs(move_y) > SANDY_REPLAY_GLIDE_MAX) + return TRUE + var/time = world.tick_lag + if(stepped) + // A step glides until the next one, like a walk does. + var/ticks = 2 + for(var/next = at + SANDY_FRAME_SIZE, next < end, next += SANDY_FRAME_SIZE) + if(frames[next + SANDY_FRAME_X] != frames[at + SANDY_FRAME_X] || frames[next + SANDY_FRAME_Y] != frames[at + SANDY_FRAME_Y] || frames[next + SANDY_FRAME_Z] != frames[at + SANDY_FRAME_Z]) + ticks = frames[next + SANDY_FRAME_INDEX] - frames[at + SANDY_FRAME_INDEX] + break + time = clamp(ticks, 1, 5) * world.tick_lag + else if(left > 0) + time = left + track.glide_x = move_x + track.glide_y = move_y + track.glide_time = time + track.glide_until = world.time + time + return TRUE + +/// Draws `track`'s current frame on its thing or its stand-in. +/datum/sandy_replay/proc/show(datum/sandy_replay_track/track) + var/list/frames = track.frames + var/at = track.cursor + var/visible = at >= 0 && frames[at + SANDY_FRAME_Z] + if(track.mode == SANDY_TRACK_STANDIN) + var/turf/spot = visible ? locate(frames[at + SANDY_FRAME_X], frames[at + SANDY_FRAME_Y], frames[at + SANDY_FRAME_Z]) : null + if(!spot) + if(track.standin) + track.standin.loc = null + return + if(!track.standin) + track.standin = new(spot) + else + track.standin.loc = spot // ours, abstract and on no grid: nothing to tell + var/obj/effect/abstract/sandy_replay_standin/standin = track.standin + standin.appearance = frames[at + SANDY_FRAME_LOOK] + standin.dir = frames[at + SANDY_FRAME_DIR] + standin.mouse_opacity = MOUSE_OPACITY_TRANSPARENT + standin.opacity = FALSE + standin.luminosity = 0 + settle(track, standin, 0, 0) + return + var/atom/movable/thing = track.thing + if(QDELETED(thing) || track.unmasked) + return + var/turf/here = thing.loc + var/off_x = 0 + var/off_y = 0 + if(!isturf(here)) + visible = FALSE + if(visible) + off_x = frames[at + SANDY_FRAME_X] - here.x + off_y = frames[at + SANDY_FRAME_Y] - here.y + if(frames[at + SANDY_FRAME_Z] != here.z || abs(off_x) > SANDY_REPLAY_REACH || abs(off_y) > SANDY_REPLAY_REACH) + visible = FALSE + if(!visible) // not there yet at this frame, or not anywhere it can be drawn + animate(thing, time = 0, flags = ANIMATION_END_NOW) + thing.appearance = track.saved_look + thing.dir = track.saved_dir + pin(track) + thing.alpha = 0 + thing.mouse_opacity = MOUSE_OPACITY_TRANSPARENT + track.expect = thing.appearance + return + thing.appearance = frames[at + SANDY_FRAME_LOOK] + thing.dir = frames[at + SANDY_FRAME_DIR] + pin(track) + thing.mouse_opacity = MOUSE_OPACITY_TRANSPARENT + settle(track, thing, off_x * ICON_SIZE_X, off_y * ICON_SIZE_Y) + track.expect = thing.appearance + +/// Shifts `body` (just given a frame's look) by `off_x`, `off_y` pixels, and plays what is left of its tween. +/datum/sandy_replay/proc/settle(datum/sandy_replay_track/track, atom/movable/body, off_x, off_y) + var/to_w = body.pixel_w + off_x + var/to_z = body.pixel_z + off_y + var/left = track.glide_until - world.time + if(left > 0 && track.glide_time > 0) + body.pixel_w = to_w + track.glide_x * left / track.glide_time + body.pixel_z = to_z + track.glide_y * left / track.glide_time + animate(body, pixel_w = to_w, pixel_z = to_z, time = left, flags = ANIMATION_END_NOW) + return + animate(body, pixel_w = to_w, pixel_z = to_z, time = 0, flags = ANIMATION_END_NOW) + +/// The user's view follows their puppet. +/datum/sandy_replay/proc/follow() + var/client/viewer = user?.client + if(!eye || !viewer || !user_track || user_track.cursor < 0) + return + var/list/frames = user_track.frames + var/at = user_track.cursor + var/turf/spot = frames[at + SANDY_FRAME_Z] ? locate(frames[at + SANDY_FRAME_X], frames[at + SANDY_FRAME_Y], frames[at + SANDY_FRAME_Z]) : null + if(!spot) + return + eye.loc = spot + var/left = user_track.glide_until - world.time + if(left > 0 && user_track.glide_time > 0) + viewer.pixel_x = eye_pixel_x + user_track.glide_x * left / user_track.glide_time + viewer.pixel_y = eye_pixel_y + user_track.glide_y * left / user_track.glide_time + animate(viewer, pixel_x = eye_pixel_x, pixel_y = eye_pixel_y, time = left, flags = ANIMATION_END_NOW) + else + animate(viewer, pixel_x = eye_pixel_x, pixel_y = eye_pixel_y, time = 0, flags = ANIMATION_END_NOW) + +/** + * A surger's puppet (the user's, or a surging NPC's) just moved on to a new frame. The place where the surge's + * own look and sound join the replay: `track.came_from` and `track.cursor` are the frames it went from and to + * (offsets in `track.frames`; came_from is below 0 on its first), so a step is the two frames standing on + * different turfs. Nothing is emitted yet. + */ +/datum/sandy_replay/proc/surger_frame(datum/sandy_replay_track/track) + return + +/// The playback timer: one frame a tick. +/datum/sandy_replay/proc/play_frame() + if(finished || state != SANDY_REPLAY_PLAYING) + return + try + if(QDELETED(user) || cyberware_time_stop_witnessed(user)) + finish() + return + var/frame = round((world.time - play_started) / world.tick_lag) + if(frame > total + 1) // the last frame's tween has played out + finish() + return + frame = min(frame, total) + for(var/datum/sandy_replay_track/track as anything in puppets) + var/atom/movable/thing = track.thing + if(QDELETED(thing)) + continue + var/redraw = FALSE + if(track.unmasked) // a wrapped proc never came back + track.unmasked = FALSE + keep_real(track) + redraw = TRUE + else if(thing.appearance != track.expect) + fold(track) + redraw = TRUE + var/moved_on = advance(track, frame) + if(moved_on || redraw) + show(track) + if(track == user_track) + follow() + if(moved_on && track.surger) + surger_frame(track) + for(var/datum/sandy_replay_track/track as anything in standins.Copy()) + if(frame >= track.last) + QDEL_NULL(track.standin) + standins -= track + continue + if(advance(track, frame)) + show(track) + catch(var/exception/error) + finish() + stack_trace("Sandevistan replay frame failed: [error] ([error.file]:[error.line])") + +/// Signal proc for [COMSIG_MOVABLE_MOVED] on a puppet: its real tile changed, so its offset does too. +/datum/sandy_replay/proc/on_puppet_moved(atom/movable/thing, atom/old_loc) + SIGNAL_HANDLER + if(finished) + return + if(thing == user && isturf(old_loc) && get_dist(old_loc, thing) > 2) // a shuttle, a teleport: the recorded tiles mean nothing now + finish() + return + var/datum/sandy_replay_track/track = GLOB.sandy_replay_masked[thing] + if(track && !track.unmasked) + show(track) + +/// Signal proc for [COMSIG_ATOM_DIR_CHANGE] on a puppet: the game turned it for real. +/datum/sandy_replay/proc/on_puppet_turned(atom/movable/thing, old_dir, new_dir) + SIGNAL_HANDLER + var/datum/sandy_replay_track/track = GLOB.sandy_replay_masked[thing] + if(track) + track.saved_dir = new_dir + +/// Signal proc for [COMSIG_MOVABLE_Z_CHANGED] on the user. +/datum/sandy_replay/proc/on_user_z_changed(datum/source) + SIGNAL_HANDLER + finish() + +/** + * Ends the replay: every puppet gets its real look back, stand-ins go, AIs and players are let go, the view goes + * home and the crash lands. Safe to call at any point and any number of times; one bad thing can't stop the rest. + */ +/datum/sandy_replay/proc/finish() + if(finished) + return + finished = TRUE + var/was_playing = state == SANDY_REPLAY_PLAYING + state = SANDY_REPLAY_DONE + deltimer(play_timer) + deltimer(watchdog) + deltimer(sample_timer) + deltimer(begin_timer) + play_timer = null + watchdog = null + var/list/problems + for(var/datum/sandy_replay_track/track as anything in puppets) + try + var/atom/movable/thing = track.thing + if(thing) + if(GLOB.sandy_replay_masked[thing] == track) + GLOB.sandy_replay_masked -= thing + UnregisterSignal(thing, list(COMSIG_MOVABLE_MOVED, COMSIG_ATOM_DIR_CHANGE)) + if(!QDELETED(thing) && !track.unmasked) + wear_real(track) + track.unmasked = FALSE + catch(var/exception/error) + LAZYADD(problems, "[error] ([error.file]:[error.line])") + sandy_replay_masks = length(GLOB.sandy_replay_masked) + for(var/datum/sandy_replay_track/track as anything in standins) + QDEL_NULL(track.standin) + for(var/mob/living/body as anything in locked) + try + unlock(body) + catch(var/exception/error) + LAZYADD(problems, "[error] ([error.file]:[error.line])") + locked.Cut() + for(var/obj/vehicle/sealed/mecha/mech as anything in mechs) + if(!QDELETED(mech)) + mech.completely_disabled = mechs[mech] + mechs.Cut() + if(!QDELETED(user)) + UnregisterSignal(user, COMSIG_MOVABLE_Z_CHANGED) + if(eye) + var/client/viewer = user.client + if(viewer) + animate(viewer, pixel_x = eye_pixel_x, pixel_y = eye_pixel_y, time = 0, flags = ANIMATION_END_NOW) + if(viewer.eye == eye) + user.reset_perspective() + QDEL_NULL(eye) + GLOB.sandy_replay_last = list("played" = was_playing, "frames" = total, "puppets" = length(puppets), "standins" = length(standins), "unhooked" = unhooked, "unmasks" = unmasks) + if(crash && !QDELETED(user) && user.stat != DEAD && cyberware_bearer_lattice(user)) + user.apply_status_effect(/datum/status_effect/cyberware_cascade_crash, crash[1], crash[2]) + crash = null + if(!QDELING(src)) + qdel(src) + if(problems) + stack_trace("Sandevistan replay restore problems: [problems.Join("; ")]") + +/// The surge window is closing on `user`: if their replay is about to play or playing, the crash waits for it. +/// Returns TRUE if it took the crash. +/proc/sandy_replay_defer_crash(mob/living/user, length, stamina) + var/datum/sandy_replay/replay = GLOB.sandy_replays[user] + if(!replay || replay.finished || (replay.state != SANDY_REPLAY_PENDING && replay.state != SANDY_REPLAY_PLAYING)) + return FALSE + replay.crash = list(length, stamina) + return TRUE + +/// Whether a replay is showing on (or about to show for) `user`. +/proc/sandy_replay_showing(mob/living/user) + var/datum/sandy_replay/replay = GLOB.sandy_replays[user] + return !!replay && !replay.finished && (replay.state == SANDY_REPLAY_PENDING || replay.state == SANDY_REPLAY_PLAYING) + +// ========================================================================= +// CHANGES TO A PUPPET +// ========================================================================= +// Overlays, update_appearance(), atom colours, filters, lying turns and deaths all change a thing's look step by +// step from what it is now. On a puppet that would land on the recorded look and be lost, so each goes through a +// wrapper: real look on, the change, puppet back on. With no replay running each costs one number check. + +/// `thing` is about to be changed: if it is a puppet it wears its real look until sandy_replay_remask(). Returns +/// its track if this call took the puppet off. +/proc/sandy_replay_unmask(atom/thing) + var/datum/sandy_replay_track/track = GLOB.sandy_replay_masked[thing] + if(!track || track.unmasked || !track.owner) + return null + track.owner.unmask(track) + return track + +/proc/sandy_replay_remask(datum/sandy_replay_track/track) + track.owner?.remask(track) + +/atom/add_overlay(list/add_overlays) + if(!sandy_replay_masks) + return ..() + var/datum/sandy_replay_track/track = sandy_replay_unmask(src) + . = ..() + if(track) + sandy_replay_remask(track) + +/atom/cut_overlay(list/remove_overlays) + if(!sandy_replay_masks) + return ..() + var/datum/sandy_replay_track/track = sandy_replay_unmask(src) + . = ..() + if(track) + sandy_replay_remask(track) + +/atom/cut_overlays() + if(!sandy_replay_masks) + return ..() + var/datum/sandy_replay_track/track = sandy_replay_unmask(src) + . = ..() + if(track) + sandy_replay_remask(track) + +/atom/update_appearance(updates = ALL) + if(!sandy_replay_masks) + return ..() + var/datum/sandy_replay_track/track = sandy_replay_unmask(src) + . = ..() + if(track) + sandy_replay_remask(track) + +/atom/update_atom_colour() + if(!sandy_replay_masks) + return ..() + var/datum/sandy_replay_track/track = sandy_replay_unmask(src) + . = ..() + if(track) + sandy_replay_remask(track) + +/atom/update_filters() + if(!sandy_replay_masks) + return ..() + var/datum/sandy_replay_track/track = sandy_replay_unmask(src) + . = ..() + if(track) + sandy_replay_remask(track) + +/mob/living/update_transform(resize = RESIZE_DEFAULT_SIZE) + if(!sandy_replay_masks) + return ..() + var/datum/sandy_replay_track/track = sandy_replay_unmask(src) + . = ..() + if(track) + sandy_replay_remask(track) + +/mob/living/set_body_position(new_value) + if(!sandy_replay_masks) + return ..() + var/datum/sandy_replay_track/track = sandy_replay_unmask(src) + . = ..() + if(track) + sandy_replay_remask(track) + +/mob/living/death(gibbed) + if(!sandy_replay_masks) + return ..() + var/datum/sandy_replay_track/track = sandy_replay_unmask(src) + . = ..() + if(track) + sandy_replay_remask(track) + +/mob/living/revive(full_heal_flags = NONE, excess_healing = 0, force_grab_ghost = FALSE) + if(!sandy_replay_masks) + return ..() + var/datum/sandy_replay_track/track = sandy_replay_unmask(src) + . = ..() + if(track) + sandy_replay_remask(track) + +#undef SANDY_REPLAY_TRAIT +#undef SANDY_REPLAY_OUTLINE +#undef SANDY_REPLAY_RECORDING +#undef SANDY_REPLAY_PENDING +#undef SANDY_REPLAY_PLAYING +#undef SANDY_REPLAY_DONE +#undef SANDY_TRACK_PUPPET +#undef SANDY_TRACK_STANDIN +#undef SANDY_REPLAY_REACH +#undef SANDY_REPLAY_GLIDE_MAX +#undef SANDY_REPLAY_WATCHDOG +#undef SANDY_FRAME_SIZE +#undef SANDY_FRAME_INDEX +#undef SANDY_FRAME_LOOK +#undef SANDY_FRAME_X +#undef SANDY_FRAME_Y +#undef SANDY_FRAME_Z +#undef SANDY_FRAME_DIR diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index c87dde04174..1a36d8471ff 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -405,12 +405,18 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/ring_time = 0 /// The ring's full size, in tiles of radius. var/ring_reach = 0 + /// The recording of this slow-mo, played back when it ends (ware_sandy_replay.dm). Only a player's field has one. + var/datum/sandy_replay/replay /datum/proximity_monitor/advanced/timestop/sandevistan/New(atom/_host, range, _ignore_if_not_on_turf = TRUE, list/immune, antimagic_flags, channelled, _time_scale = 1, _user_multiplier = 1) time_scale = _time_scale > 0 ? _time_scale : 1 user_multiplier = _user_multiplier > 0 ? _user_multiplier : 1 last_tick = world.time GLOB.cyberware_time_fields += src + if(isliving(_host)) + var/mob/living/recorded = _host + if(recorded.ckey) + replay = new(src, recorded) // before the field takes its first turf, so it sees everything . = ..() STOP_PROCESSING(SSfastprocess, src) // tick() runs on tick_timer instead tick_timer = addtimer(CALLBACK(src, PROC_REF(tick)), 0.2 SECONDS, TIMER_LOOP | TIMER_STOPPABLE | TIMER_DELETE_ME) @@ -435,6 +441,9 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) cyberware_surge_refresh_perks(surger) // a surge still open gets its own edge back own_scales.Cut() released.Cut() + var/datum/sandy_replay/recording = replay + replay = null + recording?.field_over() // it plays back from here; a player cutting the slow-mo short cancelled it in tick() /// How long, in real time from now, `local` deciseconds of `thing`'s own time take: its rate in the field until /// the effect (or a boss's hold) ends, then real time. @@ -467,6 +476,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) return min(multiplier / user_multiplier, SANDY_STOP_MAX_RELATIVE) /datum/proximity_monitor/advanced/timestop/sandevistan/freeze_atom(atom/movable/A) + replay?.notice(A) // everything on, entering or made on a field turf comes through here if(!istype(A) || GLOB.cyberware_time_dilated[A] || global_frozen_atoms[A] || released[A] || immune[A] || surging[A]) return FALSE if(cyberware_own_surge(A)) @@ -512,6 +522,13 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) into_the_negative_zone(A) return TRUE +/datum/proximity_monitor/advanced/timestop/sandevistan/setup_field_turf(turf/target) + if(!replay) + return ..() + replay.covering = TRUE // what stands on a turf the field moves onto was there all along + . = ..() + replay?.covering = FALSE + /datum/proximity_monitor/advanced/timestop/sandevistan/unfreeze_all() for(var/atom/movable/thing as anything in frozen_things) unfreeze_atom(thing) @@ -762,6 +779,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) /// Every 0.2 s: end if a player came in, push back the timers of everything held, let bosses go. /datum/proximity_monitor/advanced/timestop/sandevistan/proc/tick() if(QDELETED(host) || cyberware_time_stop_witnessed(host)) + replay?.cancel() // cut short: nothing replays if(!QDELETED(stopper)) qdel(stopper) // takes this field with it return @@ -809,6 +827,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) /datum/proximity_monitor/advanced/timestop/sandevistan/into_the_negative_zone(atom/A) tinted[A] = TRUE + replay?.resample(A) // recorded without the tint from here on var/wait = ring_wait(A) if(wait > 0) if(!tint_timers[A]) diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 25cc4adeb84..cdb1ac479e0 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -102,6 +102,7 @@ #include "voidcrew_cyberware_models_b.dm" #include "voidcrew_cyberware_npcs.dm" #include "voidcrew_cyberware_passives.dm" +#include "voidcrew_cyberware_replay.dm" #include "voidcrew_cyberware_soak.dm" #include "voidcrew_cyberware_text.dm" #include "voidcrew_cyberware_timestop.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm new file mode 100644 index 00000000000..4f9855f75b0 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm @@ -0,0 +1,258 @@ +/** + * # The replay after a Sandevistan slow-mo + * + * voidcrew/modules/cyberware/ware_sandy_replay.dm. A slow-mo that runs out, or that its user ends, is shown again + * from frame 0 on the real things, drawn at their recorded spots without being moved, and every one of them ends + * exactly as it really was. A slow-mo another player cut short doesn't replay. + * + * Unit-test files compile before voidcrew/_DEFINES/, so literals stand in for the module's defines: the replay's + * states are 1 recording, 2 about to play, 3 playing, and its trait source is "sandy_replay". + */ +/datum/unit_test/voidcrew_cyberware_timestop/replay + abstract_type = /datum/unit_test/voidcrew_cyberware_timestop/replay + +/// Opens `user`'s slow-mo and returns its recording +/datum/unit_test/voidcrew_cyberware_timestop/replay/proc/start_recording(mob/living/carbon/human/user) + surge(user) + sleep(0.1 SECONDS) // the field starts async + var/datum/sandy_replay/replay = GLOB.sandy_replays[user] + TEST_ASSERT_NOTNULL(replay, "The slow-mo isn't being recorded") + TEST_ASSERT_EQUAL(replay.state, 1, "The recording didn't start in its recording state") + return replay + +/// Ends `user`'s slow-mo the way a second press of the surge does +/datum/unit_test/voidcrew_cyberware_timestop/replay/proc/press_again(mob/living/carbon/human/user) + user.remove_status_effect(/datum/status_effect/cyberware_cascade_window) + +/// Waits up to `limit` for `user`'s replay to be over +/datum/unit_test/voidcrew_cyberware_timestop/replay/proc/wait_over(mob/living/carbon/human/user, limit = 5 SECONDS) + var/until = world.time + limit + while(GLOB.sandy_replays[user] && world.time < until) + sleep(world.tick_lag) + TEST_ASSERT_NULL(GLOB.sandy_replays[user], "The replay never ended") + +/// Whether the replay holds `body` (AI pause or input lock) +/datum/unit_test/voidcrew_cyberware_timestop/replay/proc/held(mob/living/body) + return HAS_TRAIT_FROM(body, TRAIT_AI_PAUSED, "sandy_replay") || HAS_TRAIT_FROM(body, TRAIT_IMMOBILIZED, "sandy_replay") || HAS_TRAIT_FROM(body, TRAIT_INCAPACITATED, "sandy_replay") + +/// Names what differs between `thing`'s look now and the appearance `was` +/datum/unit_test/voidcrew_cyberware_timestop/replay/proc/look_difference(atom/movable/thing, was) + var/mutable_appearance/now = new(thing) + var/mutable_appearance/before = new(was) + var/list/found = list() + for(var/name in list("icon", "icon_state", "pixel_x", "pixel_y", "pixel_w", "pixel_z", "alpha", "layer", "plane", "invisibility", "mouse_opacity", "appearance_flags", "blend_mode", "name")) + if(now.vars[name] != before.vars[name]) + found += "[name] [before.vars[name]] -> [now.vars[name]]" + if(!(now.color ~= before.color)) + found += "color" + if(!(now.transform ~= before.transform)) + found += "transform" + if(length(now.overlays) != length(before.overlays)) + found += "overlays [length(before.overlays)] -> [length(now.overlays)]" + if(length(now.underlays) != length(before.underlays)) + found += "underlays [length(before.underlays)] -> [length(now.underlays)]" + if(length(now.filters) != length(before.filters)) + found += "filters [length(before.filters)] -> [length(now.filters)]" + return length(found) ? found.Join(", ") : "nothing in the vars compared" + +/// A slow-mo the user ends replays from frame 0 on the real things, without moving them, and every one of them +/// ends exactly as it really was. Changes made to them mid-replay land on their real look. +/datum/unit_test/voidcrew_cyberware_timestop/replay/plays_and_restores + +/datum/unit_test/voidcrew_cyberware_timestop/replay/plays_and_restores/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandyreplayuser") + set_window(user, 3 SECONDS) + var/mob/living/carbon/human/npc/raider = allocate(/mob/living/carbon/human/npc, spot(2, 0)) + var/mob/living/carbon/human/npc/bleeder = allocate(/mob/living/carbon/human/npc, spot(4, 0)) + var/mob/living/carbon/human/npc/victim = allocate(/mob/living/carbon/human/npc, spot(4, 2)) + var/obj/item/wrench/wrench = allocate(/obj/item/wrench, spot(1, 1)) + var/obj/item/crowbar/still = allocate(/obj/item/crowbar, spot(1, 2)) + var/obj/structure/table/table = allocate(/obj/structure/table, spot(3, 2)) + var/datum/sandy_replay/replay = start_recording(user) + TEST_ASSERT(replay.tracks[raider] && replay.tracks[wrench] && replay.tracks[table] && replay.tracks[user], "The recording missed things in the field") + + // The window: the user steps, an NPC is moved, one bleeds, one loses its head and dies, a thing is made, one is made and deleted. + sleep(0.6 SECONDS) + user.Move(spot(0, 1), NORTH) + raider.forceMove(spot(3, 0)) + wrench.forceMove(spot(2, 2)) + bleeder.cause_wound_of_type_and_severity(WOUND_SLASH, bleeder.get_bodypart(BODY_ZONE_CHEST), WOUND_SEVERITY_SEVERE) + var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) + head.dismember() + victim.death() + var/obj/item/screwdriver/made = allocate(/obj/item/screwdriver, spot(3, 1)) + var/obj/item/pen/spent = new(spot(1, 0)) + sleep(0.6 SECONDS) + qdel(spent) + sleep(0.3 SECONDS) + TEST_ASSERT(replay.tracks[made], "Something made mid-window wasn't recorded") + + // The second press: the slow-mo's looks come off, and that is everything's real final state. + press_again(user) + TEST_ASSERT_EQUAL(replay.state, 2, "Ending the slow-mo didn't queue the replay") + TEST_ASSERT(!user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "The crash didn't wait for the replay") + var/list/things = list(user, raider, bleeder, victim, wrench, still, table, made) + var/head_in_flight = !QDELETED(head) && !!head.throwing + if(!QDELETED(head) && !head_in_flight) // a head still flying really lands, and changes, during the replay + things += head + log_test("replay/plays_and_restores: severed head [QDELETED(head) ? "gone" : (head_in_flight ? "still in flight at the release, left out of the exact check" : "at rest, checked")]") + var/list/real = list() + var/list/real_spot = list() + var/list/real_dir = list() + for(var/atom/movable/thing as anything in things) + real[thing] = thing.appearance + real_spot[thing] = thing.loc + real_dir[thing] = thing.dir + var/raider_overlays = length(raider.overlays) + + replay.begin() + TEST_ASSERT_EQUAL(replay.state, 3, "The replay didn't start") + // Frame 0: drawn back where they started, standing on the tiles they ended on. + for(var/atom/movable/thing as anything in things) + TEST_ASSERT_EQUAL(thing.loc, real_spot[thing], "The replay moved [thing] for real") + TEST_ASSERT(GLOB.sandy_replay_masked[user] && GLOB.sandy_replay_masked[raider] && GLOB.sandy_replay_masked[wrench] && GLOB.sandy_replay_masked[victim], "Things that changed aren't replayed") + TEST_ASSERT(!GLOB.sandy_replay_masked[still] && !GLOB.sandy_replay_masked[table], "Things that never changed are replayed (the slow-mo's tint wasn't taken out of the recording cleanly)") + TEST_ASSERT_EQUAL(user.pixel_z, -32, "The user isn't drawn back on their first tile") + TEST_ASSERT_EQUAL(raider.pixel_w, -32, "The NPC isn't drawn back on its first tile") + TEST_ASSERT_EQUAL(wrench.pixel_w - initial(wrench.pixel_w), -32, "The item isn't drawn back on its first tile") + TEST_ASSERT_EQUAL(made.alpha, 0, "Something made mid-window shows at frame 0") + var/datum/sandy_replay_track/victim_track = replay.tracks[victim] + TEST_ASSERT(victim.appearance != real[victim], "The corpse's first frame is its last") + TEST_ASSERT(length(victim_track.frames) > 6, "The corpse has one frame") + TEST_ASSERT(length(replay.standins) >= 1, "Something deleted mid-window has no stand-in") + TEST_ASSERT(HAS_TRAIT_FROM(user, TRAIT_IMMOBILIZED, "sandy_replay") && HAS_TRAIT_FROM(user, TRAIT_INCAPACITATED, "sandy_replay"), "The user can act during the replay") + TEST_ASSERT(HAS_TRAIT_FROM(raider, TRAIT_AI_PAUSED, "sandy_replay"), "The NPC's AI runs during the replay") + TEST_ASSERT(!raider.IsStun() && !user.IsStun(), "The replay stunned someone") + TEST_ASSERT(!user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "The crash started during the replay") + + // Mid-replay: the game changes puppets. Through wrapped procs (an overlay, a lying turn) and by a plain var write. + sleep(0.1 SECONDS) + var/mutable_appearance/mark = mutable_appearance('icons/effects/effects.dmi', "shield-old") + raider.add_overlay(mark) + raider.set_lying_angle(90) + wrench.alpha = 128 + TEST_ASSERT(GLOB.sandy_replay_masked[raider], "A change to a puppet ended its replay") + + wait_over(user) + TEST_ASSERT_EQUAL(GLOB.sandy_replay_last["played"], TRUE, "The replay didn't play") + TEST_ASSERT_EQUAL(length(GLOB.sandy_replay_masked), 0, "Puppets are left after the replay") + for(var/atom/movable/thing as anything in things) + TEST_ASSERT_EQUAL(thing.loc, real_spot[thing], "[thing] isn't where it really ended") + TEST_ASSERT_EQUAL(thing.dir, real_dir[thing], "[thing] isn't facing the way it really ended") + if(thing == raider || thing == wrench || thing == user) // changed mid-replay below; the user wears the crash now + continue + TEST_ASSERT(thing.appearance == real[thing], "[thing] doesn't look exactly as it really ended: [look_difference(thing, real[thing])]") + // The changes made mid-replay are on the real looks, and nothing else is. + TEST_ASSERT_EQUAL(length(raider.overlays), raider_overlays + 1, "An overlay added mid-replay was lost, or others were") + TEST_ASSERT_EQUAL(raider.lying_angle, 90, "The lying turn didn't hold") + var/mutable_appearance/raider_was = new(real[raider]) + var/matrix/upright = matrix(raider_was.transform) + TEST_ASSERT(!(raider.transform ~= upright), "A lying turn made mid-replay isn't on the real body") + TEST_ASSERT_EQUAL(raider.pixel_w, raider_was.pixel_w, "The NPC is left drawn off its tile") + TEST_ASSERT(user.pixel_w == 0 && user.pixel_z == 0 && user.alpha == 255, "The user is left drawn off their tile or hidden") + TEST_ASSERT(user.transform ~= matrix(), "The user is left turned or scaled") + TEST_ASSERT_EQUAL(user.mouse_opacity, initial(user.mouse_opacity), "The user can't be clicked after the replay") + TEST_ASSERT_EQUAL(wrench.alpha, 128, "A var written mid-replay was lost") + TEST_ASSERT_EQUAL(wrench.pixel_w, initial(wrench.pixel_w), "The item is left drawn off its tile") + TEST_ASSERT_EQUAL(wrench.mouse_opacity, initial(wrench.mouse_opacity), "The item can't be clicked after the replay") + TEST_ASSERT_EQUAL(GLOB.sandy_replay_last["unhooked"], 1, "Puppets read as changed when nothing changed them") + TEST_ASSERT(!held(user) && !held(raider) && !held(bleeder), "Someone is still held after the replay") + TEST_ASSERT(!(locate(/obj/effect/abstract/sandy_replay_standin) in range(9, user)), "A stand-in outlived the replay") + TEST_ASSERT(user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "The crash didn't land when the replay ended") + +/// A slow-mo that runs out replays by itself, and the watchdog ends a replay whose frames stopped coming +/datum/unit_test/voidcrew_cyberware_timestop/replay/watchdog + +/datum/unit_test/voidcrew_cyberware_timestop/replay/watchdog/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandyreplaywatch") + set_window(user, 1 SECONDS) + var/mob/living/carbon/human/npc/raider = allocate(/mob/living/carbon/human/npc, spot(2, 0)) + var/datum/sandy_replay/replay = start_recording(user) + sleep(0.4 SECONDS) + raider.forceMove(spot(3, 0)) + var/until = world.time + 3 SECONDS + while(replay.state != 3 && !QDELETED(replay) && world.time < until) + sleep(world.tick_lag) + TEST_ASSERT_EQUAL(replay.state, 3, "A slow-mo that ran out didn't replay") + TEST_ASSERT(GLOB.sandy_replay_masked[raider], "The NPC that moved isn't replayed") + var/datum/sandy_replay_track/raider_track = replay.tracks[raider] + var/mutable_appearance/real = new(raider_track.saved_look) + deltimer(replay.play_timer) // the frames stop coming + replay.play_timer = null + var/stalled_at = world.time + wait_over(user) + TEST_ASSERT(world.time - stalled_at >= 1 SECONDS, "The stalled replay ended before the watchdog could have ended it") + TEST_ASSERT(!held(user) && !held(raider), "Someone is still held after the watchdog ended the replay") + TEST_ASSERT_EQUAL(raider.pixel_w, real.pixel_w, "The NPC is left drawn off its tile") + TEST_ASSERT_EQUAL(length(GLOB.sandy_replay_masked), 0, "Puppets are left after the watchdog ended the replay") + TEST_ASSERT(user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "The crash didn't land when the watchdog ended the replay") + +/// A player who isn't crew coming near ends the replay at once +/datum/unit_test/voidcrew_cyberware_timestop/replay/player_arrives + +/datum/unit_test/voidcrew_cyberware_timestop/replay/player_arrives/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandyreplayalone") + set_window(user, 3 SECONDS) + var/mob/living/carbon/human/npc/raider = allocate(/mob/living/carbon/human/npc, spot(2, 0)) + var/datum/sandy_replay/replay = start_recording(user) + sleep(1 SECONDS) + raider.forceMove(spot(3, 0)) + sleep(0.5 SECONDS) + press_again(user) + var/real = raider.appearance + replay.begin() + TEST_ASSERT_EQUAL(replay.state, 3, "The replay didn't start") + TEST_ASSERT_EQUAL(raider.pixel_w, -32, "The NPC isn't replayed") + var/mob/living/carbon/human/stranger = allocate(/mob/living/carbon/human/consistent, spot(1, 2)) + stranger.key = "sandyreplaystranger" + replay.play_frame() + TEST_ASSERT_NULL(GLOB.sandy_replays[user], "The replay went on with another player near") + TEST_ASSERT(raider.appearance == real, "The NPC didn't snap back to its real look") + TEST_ASSERT(!held(user) && !held(raider), "Someone is still held after a player ended the replay") + TEST_ASSERT(user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "The crash didn't land when a player ended the replay") + +/// A slow-mo another player cut short doesn't replay +/datum/unit_test/voidcrew_cyberware_timestop/replay/not_when_cut_short + +/datum/unit_test/voidcrew_cyberware_timestop/replay/not_when_cut_short/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandyreplaycut") + set_window(user, 1.5 SECONDS) + var/mob/living/carbon/human/npc/raider = allocate(/mob/living/carbon/human/npc, spot(2, 0)) + start_recording(user) + raider.forceMove(spot(3, 0)) + var/mob/living/carbon/human/stranger = allocate(/mob/living/carbon/human/consistent, spot(1, 2)) + stranger.key = "sandyreplaycutter" + sleep(0.5 SECONDS) // the field's next tick sees them + TEST_ASSERT_NULL(GLOB.sandy_replays[user], "A slow-mo cut short by a player kept its recording") + sleep(1.5 SECONDS) // past the end of the window + TEST_ASSERT(!user.has_status_effect(/datum/status_effect/cyberware_cascade_window), "Test fixture: the window should be over") + TEST_ASSERT_NULL(GLOB.sandy_replays[user], "A slow-mo cut short by a player replayed") + TEST_ASSERT_EQUAL(length(GLOB.sandy_replay_masked), 0, "A slow-mo cut short by a player made puppets") + TEST_ASSERT(!held(user) && !held(raider), "Someone is held with no replay") + TEST_ASSERT(user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "The crash didn't land at the end of the window") + +/// The user dying in the window is replayed too: their body isn't locked, and no crash follows +/datum/unit_test/voidcrew_cyberware_timestop/replay/user_dies + +/datum/unit_test/voidcrew_cyberware_timestop/replay/user_dies/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandyreplaydead") + set_window(user, 3 SECONDS) + var/datum/sandy_replay/replay = start_recording(user) + sleep(0.5 SECONDS) + user.Move(spot(0, 1), NORTH) + sleep(0.5 SECONDS) + user.death() + sleep(0.3 SECONDS) + if(user.has_status_effect(/datum/status_effect/cyberware_cascade_window)) + press_again(user) + TEST_ASSERT_EQUAL(replay.state, 2, "The slow-mo of a user who died didn't queue its replay") + var/real = user.appearance + replay.begin() + TEST_ASSERT_EQUAL(replay.state, 3, "The replay of a user who died didn't start") + TEST_ASSERT(GLOB.sandy_replay_masked[user], "The dead user's body isn't replayed") + TEST_ASSERT_EQUAL(user.pixel_z, -32, "The dead user isn't drawn back on their first tile") + TEST_ASSERT(user.appearance != real, "The dead user isn't drawn alive at frame 0") + TEST_ASSERT(!held(user), "A dead user was input-locked") + wait_over(user) + TEST_ASSERT(user.appearance == real, "The dead user's body doesn't look as it really ended") + TEST_ASSERT(!user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "A dead user crashed") diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm index 9dce304e1e5..86f4479b42e 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -48,6 +48,13 @@ var/obj/item/organ/cyberimp/cyberware/cascade/lattice = locate() in user.organs return locate(/datum/action/cooldown/cyberware/cascade_surge) in lattice.actions +/// Waits for the replay that follows `user`'s slow-mo to be over (ware_sandy_replay.dm): AIs are paused and the crash waits until then +/datum/unit_test/voidcrew_cyberware_timestop/proc/wait_replay(mob/living/carbon/human/user) + var/until = world.time + 5 SECONDS + while(GLOB.sandy_replays[user] && world.time < until) + sleep(world.tick_lag) + TEST_ASSERT_NULL(GLOB.sandy_replays[user], "The replay after the slow-mo never ended") + /// Nobody else near: the world runs slow from the first moment to the end of the window, nothing is ever frozen, /// and the user can act throughout /datum/unit_test/voidcrew_cyberware_timestop/alone @@ -93,7 +100,8 @@ TEST_ASSERT(!user.has_status_effect(/datum/status_effect/cyberware_cascade_window), "Test fixture: the window outlived its 2 s") TEST_ASSERT_NULL(window.time_stop, "The slow-mo outlived the window") TEST_ASSERT(!raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "The NPC stayed slow after the window") - TEST_ASSERT(!frozen(raider), "The NPC was frozen after the window") + wait_replay(user) + TEST_ASSERT(!frozen(raider), "The NPC was frozen after the window and its replay") user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) /// The slow-mo lasts each model's whole window at that model's pace @@ -142,11 +150,15 @@ TEST_ASSERT(button.IsAvailable(), "The surge can't be pressed while its window runs") TEST_ASSERT(button.Trigger(), "The second press did nothing") TEST_ASSERT(!user.has_status_effect(/datum/status_effect/cyberware_cascade_window), "The second press didn't end the window") - TEST_ASSERT(user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "Ending the window early skipped the crash") TEST_ASSERT_NULL(window.time_stop, "The slow-mo outlived the window") TEST_ASSERT(!raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "The NPC stayed slow after the window was cut short") TEST_ASSERT_EQUAL(button.next_use_time, cooldown_end, "Cutting the window short changed the cooldown") + // The crash lands once the replay of the shortened slow-mo is over, and the surge can't be pressed in between. + TEST_ASSERT(!button.Trigger(), "A press went through during the replay") + wait_replay(user) + TEST_ASSERT(user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "Ending the window early skipped the crash") + // In the crash, and after it, the button is back on its cooldown. TEST_ASSERT(!button.IsAvailable(), "The surge could be pressed in the crash") TEST_ASSERT(!button.Trigger(), "A third press went through") From b66857fb99f2710a22d801af43a96ccdf5cd9adf Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:27:03 -0800 Subject: [PATCH 20/33] Sandevistan replay: surge trail, surge sound and fight sounds, tweened turns --- code/game/sound/sound.dm | 4 + .../modules/cyberware/ware_sandy_replay.dm | 149 +++++++++++++++++- .../unit_tests/voidcrew_cyberware_replay.dm | 11 +- 3 files changed, 154 insertions(+), 10 deletions(-) diff --git a/code/game/sound/sound.dm b/code/game/sound/sound.dm index 3e632619c9f..ecfad0b6d88 100644 --- a/code/game/sound/sound.dm +++ b/code/game/sound/sound.dm @@ -31,6 +31,10 @@ var/turf/turf_source = get_turf(source) if (!turf_source) return + // VOIDCREW EDIT ADDITION START - cyberware: a Sandevistan slow-mo keeps its sounds for its replay (voidcrew/modules/cyberware/ware_sandy_replay.dm) + if(length(GLOB.sandy_replays)) + sandy_replay_hear(turf_source, soundin, vol, vary, extrarange, falloff_exponent, frequency, pressure_affected, ignore_walls, falloff_distance, use_reverb, volume_preference) + // VOIDCREW EDIT ADDITION END //allocate a channel if necessary now so its the same for everyone channel = channel || SSsounds.random_available_channel() diff --git a/voidcrew/modules/cyberware/ware_sandy_replay.dm b/voidcrew/modules/cyberware/ware_sandy_replay.dm index 2173816b571..b2f4d1b515f 100644 --- a/voidcrew/modules/cyberware/ware_sandy_replay.dm +++ b/voidcrew/modules/cyberware/ware_sandy_replay.dm @@ -26,8 +26,13 @@ * replay at once, a watchdog timer ends it whatever happens, and finish() can be called any number of times. * The surge's crash starts when the replay ends. * + * It looks and sounds like a surge: the surge-on sound plays at the start, the sounds made in the field play again + * at normal pitch (playsound() hands them over), and every surger in it (the user, or a mob on its own surge) leaves + * the surge's rolling-hue afterimage trail. A turn of a body (a fall) is tweened, as tg's lying turn is. + * * Not replayed (they show as they really are): lights, fire, smoke and particles, turf changes, attack lunges, - * flick()s, anything in vis_contents, runechat and sounds. + * flick()s, tweens other than steps and turns (flinches, the spin and arc of flying gore, blood drying and + * spreading), anything in vis_contents, runechat, and looping sounds (machines, weather). */ /// Trait source of the replay's AI pause and input lock. @@ -49,6 +54,12 @@ #define SANDY_REPLAY_GLIDE_MAX 96 /// Extra time the watchdog gives the replay before it ends it. #define SANDY_REPLAY_WATCHDOG (1 SECONDS) +/// How long a recorded turn of a body (a fall, getting up) takes in the replay: tg's own lying turn. +#define SANDY_REPLAY_TURN_TIME 2 +/// Degrees the replay trail's hue moves per step (CYBERWARE_CASCADE_HUE_STEP in ware_legend.dm). +#define SANDY_REPLAY_HUE_STEP 25 +/// The most sounds one recording keeps. +#define SANDY_REPLAY_MAX_SOUNDS 200 // A track's frames are a flat list, this many entries a frame. #define SANDY_FRAME_SIZE 6 @@ -67,6 +78,8 @@ GLOBAL_LIST_EMPTY(sandy_replay_masked) GLOBAL_REAL_VAR(sandy_replay_masks) /// Numbers from the last replay that finished, for tests and recordings. GLOBAL_LIST_EMPTY(sandy_replay_last) +/// A replay is playing a recorded sound: nobody records it again. +GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) /// One recorded thing. /datum/sandy_replay_track @@ -83,6 +96,17 @@ GLOBAL_LIST_EMPTY(sandy_replay_last) var/last_raw /// It was on a surge during the window: the user, or a mob on its own surge in the field. var/surger = FALSE + /// Surger: its surge's trail. An afterimage every this many steps, starting at this alpha. + var/trail_every = 1 + var/trail_alpha = CYBERWARE_CASCADE_AFTERIMAGE_ALPHA + /// Surger: the next afterimage's hue, and steps taken in the replay. + var/trail_hue = 0 + var/trail_steps = 0 + /// Playback: a turn (transform change) being tweened: from, to, how long it takes and when it ends. + var/turn_from + var/turn_to + var/turn_time = 0 + var/turn_until = 0 /// Playback: offset in `frames` of the frame shown before the current one. var/came_from = -SANDY_FRAME_SIZE /// Playback: SANDY_TRACK_PUPPET or SANDY_TRACK_STANDIN. @@ -169,6 +193,12 @@ GLOBAL_LIST_EMPTY(sandy_replay_last) var/unhooked = 0 /// Times a wrapped proc changed a puppet. var/unmasks = 0 + /// Sounds made in the field, each list(frame, turf, the rest of playsound()'s arguments). + var/list/sounds = list() + /// Playback: the next sound to play. + var/sound_at = 1 + /// Afterimages the replay's surgers left. + var/afterimages = 0 /datum/sandy_replay/New(datum/proximity_monitor/advanced/timestop/sandevistan/_field, mob/living/_user) field = _field @@ -278,7 +308,7 @@ GLOBAL_LIST_EMPTY(sandy_replay_last) return track.last_raw = raw if(!track.surger && (thing == user || field?.surging[thing])) - track.surger = TRUE + become_surger(track) var/look = clean_look(thing) if(at >= 0) if(frames[at + SANDY_FRAME_LOOK] == look && frames[at + SANDY_FRAME_X] == x && frames[at + SANDY_FRAME_Y] == y && frames[at + SANDY_FRAME_Z] == z && frames[at + SANDY_FRAME_DIR] == facing) @@ -329,6 +359,28 @@ GLOBAL_LIST_EMPTY(sandy_replay_last) return entry[ATOM_COLOR_VALUE_INDEX] return null +/// `track`'s thing is on a surge: the replay gives it its surge's trail. +/datum/sandy_replay/proc/become_surger(datum/sandy_replay_track/track) + track.surger = TRUE + track.trail_hue = rand(0, 359) + if(!isliving(track.thing)) + return + var/mob/living/body = track.thing + var/datum/status_effect/cyberware_cascade_window/window = body.has_status_effect(/datum/status_effect/cyberware_cascade_window) + if(window) + track.trail_every = max(1, window.afterimage_interval) + track.trail_alpha = window.afterimage_alpha + +/// A sound was played on `source`, in the field: kept, to play again in the replay. `arguments` are the rest of +/// playsound()'s, from soundin on, without the channel. +/datum/sandy_replay/proc/hear(turf/source, list/arguments) + if(state != SANDY_REPLAY_RECORDING || !field || length(sounds) >= SANDY_REPLAY_MAX_SOUNDS) + return + var/turf/centre = get_turf(field.host) + if(!centre || centre.z != source.z || get_dist(centre, source) > field.current_range) + return + sounds += list(list((world.time - started) / interval, source) + arguments) + /// Signal proc for [COMSIG_QDELETING] on anything recorded. /datum/sandy_replay/proc/on_thing_deleted(atom/movable/thing) SIGNAL_HANDLER @@ -389,6 +441,9 @@ GLOBAL_LIST_EMPTY(sandy_replay_last) if(finished) return play_started = world.time + // The surge coming on, as the replay's user did at its first frame. + var/turf/first = user_track ? locate(user_track.frames[SANDY_FRAME_X], user_track.frames[SANDY_FRAME_Y], user_track.frames[SANDY_FRAME_Z]) : null + playsound(first || user, 'sound/effects/magic/timeparadox2.ogg', 70, TRUE) play_timer = addtimer(CALLBACK(src, PROC_REF(play_frame)), world.tick_lag, TIMER_LOOP | TIMER_STOPPABLE | TIMER_DELETE_ME) play_frame() @@ -551,10 +606,22 @@ GLOBAL_LIST_EMPTY(sandy_replay_last) carry_y = track.glide_y * left / track.glide_time track.glide_until = 0 if(old < 0 || !frames[old + SANDY_FRAME_Z] || frames[old + SANDY_FRAME_Z] != frames[at + SANDY_FRAME_Z]) + track.turn_until = 0 return TRUE - // Tween from where it was drawn to where it is now. var/mutable_appearance/was = frames[old + SANDY_FRAME_LOOK] var/mutable_appearance/now = frames[at + SANDY_FRAME_LOOK] + // A turn (a fall, getting up) is a tween on the client: the recording only saw where it ended. + if(!(was.transform ~= now.transform)) + var/matrix/start = matrix(was.transform) + var/turn_left = track.turn_until - world.time + if(turn_left > 0 && track.turn_time > 0) + var/matrix/turning = track.turn_from + start = turning.Interpolate(track.turn_to, 1 - turn_left / track.turn_time) + track.turn_from = start + track.turn_to = matrix(now.transform) + track.turn_time = SANDY_REPLAY_TURN_TIME + track.turn_until = world.time + SANDY_REPLAY_TURN_TIME + // Tween from where it was drawn to where it is now. var/stepped = frames[old + SANDY_FRAME_X] != frames[at + SANDY_FRAME_X] || frames[old + SANDY_FRAME_Y] != frames[at + SANDY_FRAME_Y] var/move_x = (frames[old + SANDY_FRAME_X] - frames[at + SANDY_FRAME_X]) * ICON_SIZE_X + (was.pixel_x + was.pixel_w) - (now.pixel_x + now.pixel_w) + carry_x var/move_y = (frames[old + SANDY_FRAME_Y] - frames[at + SANDY_FRAME_Y]) * ICON_SIZE_Y + (was.pixel_y + was.pixel_z) - (now.pixel_y + now.pixel_z) + carry_y @@ -633,13 +700,20 @@ GLOBAL_LIST_EMPTY(sandy_replay_last) /datum/sandy_replay/proc/settle(datum/sandy_replay_track/track, atom/movable/body, off_x, off_y) var/to_w = body.pixel_w + off_x var/to_z = body.pixel_z + off_y + var/turn_left = track.turn_until - world.time + var/turning = turn_left > 0 && track.turn_time > 0 && track.turn_from && (body.transform ~= track.turn_to) + if(turning) + var/matrix/from = track.turn_from + body.transform = from.Interpolate(track.turn_to, 1 - turn_left / track.turn_time) var/left = track.glide_until - world.time if(left > 0 && track.glide_time > 0) body.pixel_w = to_w + track.glide_x * left / track.glide_time body.pixel_z = to_z + track.glide_y * left / track.glide_time animate(body, pixel_w = to_w, pixel_z = to_z, time = left, flags = ANIMATION_END_NOW) - return - animate(body, pixel_w = to_w, pixel_z = to_z, time = 0, flags = ANIMATION_END_NOW) + else + animate(body, pixel_w = to_w, pixel_z = to_z, time = 0, flags = ANIMATION_END_NOW) + if(turning) + animate(body, transform = track.turn_to, time = turn_left, easing = SINE_EASING, flags = ANIMATION_PARALLEL) /// The user's view follows their puppet. /datum/sandy_replay/proc/follow() @@ -664,10 +738,55 @@ GLOBAL_LIST_EMPTY(sandy_replay_last) * A surger's puppet (the user's, or a surging NPC's) just moved on to a new frame. The place where the surge's * own look and sound join the replay: `track.came_from` and `track.cursor` are the frames it went from and to * (offsets in `track.frames`; came_from is below 0 on its first), so a step is the two frames standing on - * different turfs. Nothing is emitted yet. + * different turfs. Each step leaves the surge's own rolling-hue afterimage on the tile it left, as a surge does + * outside a slow-mo (cyberware_cascade_window/on_move()). */ /datum/sandy_replay/proc/surger_frame(datum/sandy_replay_track/track) - return + var/list/frames = track.frames + var/from = track.came_from + var/dest = track.cursor + if(from < 0 || !frames[from + SANDY_FRAME_Z] || frames[from + SANDY_FRAME_Z] != frames[dest + SANDY_FRAME_Z]) + return + if(frames[from + SANDY_FRAME_X] == frames[dest + SANDY_FRAME_X] && frames[from + SANDY_FRAME_Y] == frames[dest + SANDY_FRAME_Y]) + return + track.trail_steps++ + if(track.trail_steps % track.trail_every) + return + var/turf/left = locate(frames[from + SANDY_FRAME_X], frames[from + SANDY_FRAME_Y], frames[from + SANDY_FRAME_Z]) + if(!left) + return + new /obj/effect/temp_visual/decoy/fading/cascade/replay(left, frames[dest + SANDY_FRAME_LOOK], frames[dest + SANDY_FRAME_DIR], track.trail_hue, track.trail_alpha) + afterimages++ + track.trail_hue = (track.trail_hue + SANDY_REPLAY_HUE_STEP) % 360 + +/// Plays the recorded sounds due by `frame`. +/datum/sandy_replay/proc/play_sounds(frame) + while(sound_at <= length(sounds)) + var/list/heard = sounds[sound_at] + if(heard[1] > frame) + return + sound_at++ + var/turf/source = heard[2] + if(QDELETED(source)) + continue + GLOB.sandy_replay_echoing = TRUE + playsound(source, heard[3], heard[4], heard[5], heard[6], falloff_exponent = heard[7], frequency = heard[8], pressure_affected = heard[9], ignore_walls = heard[10], falloff_distance = heard[11], use_reverb = heard[12], volume_preference = heard[13]) + GLOB.sandy_replay_echoing = FALSE + +/// One afterimage of a surger's trail in the replay: a recorded look, washed in one hue like the live trail. +/obj/effect/temp_visual/decoy/fading/cascade/replay + +/obj/effect/temp_visual/decoy/fading/cascade/replay/Initialize(mapload, look, facing, hue = 0, start_alpha = CYBERWARE_CASCADE_AFTERIMAGE_ALPHA) + . = ..(mapload, null, hue, start_alpha) + if(look) + appearance = look + dir = facing + name = "" + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + opacity = FALSE + alpha = start_alpha + color = cascade_tint_matrix(hue) + animate(src, alpha = 0, time = duration) /// The playback timer: one frame a tick. /datum/sandy_replay/proc/play_frame() @@ -682,6 +801,7 @@ GLOBAL_LIST_EMPTY(sandy_replay_last) finish() return frame = min(frame, total) + play_sounds(frame) for(var/datum/sandy_replay_track/track as anything in puppets) var/atom/movable/thing = track.thing if(QDELETED(thing)) @@ -709,6 +829,7 @@ GLOBAL_LIST_EMPTY(sandy_replay_last) if(advance(track, frame)) show(track) catch(var/exception/error) + GLOB.sandy_replay_echoing = FALSE finish() stack_trace("Sandevistan replay frame failed: [error] ([error.file]:[error.line])") @@ -787,7 +908,7 @@ GLOBAL_LIST_EMPTY(sandy_replay_last) if(viewer.eye == eye) user.reset_perspective() QDEL_NULL(eye) - GLOB.sandy_replay_last = list("played" = was_playing, "frames" = total, "puppets" = length(puppets), "standins" = length(standins), "unhooked" = unhooked, "unmasks" = unmasks) + GLOB.sandy_replay_last = list("played" = was_playing, "frames" = total, "puppets" = length(puppets), "standins" = length(standins), "unhooked" = unhooked, "unmasks" = unmasks, "afterimages" = afterimages, "sounds" = length(sounds), "sounds_played" = sound_at - 1) if(crash && !QDELETED(user) && user.stat != DEAD && cyberware_bearer_lattice(user)) user.apply_status_effect(/datum/status_effect/cyberware_cascade_crash, crash[1], crash[2]) crash = null @@ -805,6 +926,15 @@ GLOBAL_LIST_EMPTY(sandy_replay_last) replay.crash = list(length, stamina) return TRUE +/// Called from playsound(): a sound played in a slow-mo is recorded for its replay. A /sound datum is a looping +/// sound's next segment (machines, weather), which plays on live and isn't recorded. +/proc/sandy_replay_hear(turf/source, soundin, vol, vary, extrarange, falloff_exponent, frequency, pressure_affected, ignore_walls, falloff_distance, use_reverb, volume_preference) + if(GLOB.sandy_replay_echoing || isdatum(soundin)) + return + for(var/user in GLOB.sandy_replays) + var/datum/sandy_replay/replay = GLOB.sandy_replays[user] + replay.hear(source, list(soundin, vol, vary, extrarange, falloff_exponent, frequency, pressure_affected, ignore_walls, falloff_distance, use_reverb, volume_preference)) + /// Whether a replay is showing on (or about to show for) `user`. /proc/sandy_replay_showing(mob/living/user) var/datum/sandy_replay/replay = GLOB.sandy_replays[user] @@ -920,6 +1050,9 @@ GLOBAL_LIST_EMPTY(sandy_replay_last) #undef SANDY_REPLAY_REACH #undef SANDY_REPLAY_GLIDE_MAX #undef SANDY_REPLAY_WATCHDOG +#undef SANDY_REPLAY_TURN_TIME +#undef SANDY_REPLAY_HUE_STEP +#undef SANDY_REPLAY_MAX_SOUNDS #undef SANDY_FRAME_SIZE #undef SANDY_FRAME_INDEX #undef SANDY_FRAME_LOOK diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm index 4f9855f75b0..5c1bb89d09f 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm @@ -82,6 +82,7 @@ victim.death() var/obj/item/screwdriver/made = allocate(/obj/item/screwdriver, spot(3, 1)) var/obj/item/pen/spent = new(spot(1, 0)) + playsound(spot(2, 1), 'sound/items/weapons/genhit1.ogg', 50, FALSE) sleep(0.6 SECONDS) qdel(spent) sleep(0.3 SECONDS) @@ -92,8 +93,10 @@ TEST_ASSERT_EQUAL(replay.state, 2, "Ending the slow-mo didn't queue the replay") TEST_ASSERT(!user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "The crash didn't wait for the replay") var/list/things = list(user, raider, bleeder, victim, wrench, still, table, made) - var/head_in_flight = !QDELETED(head) && !!head.throwing - if(!QDELETED(head) && !head_in_flight) // a head still flying really lands, and changes, during the replay + // A head still flying (a throw, or hyperviolence's tumble) really lands during the replay, and its roll on landing + // moves its pixel_x 6 px (hv_vis_roll() in hyperviolence/visuals/flying_gore.dm): a real change, not the replay's. + var/head_in_flight = !QDELETED(head) && (!!head.throwing || !!head.GetComponent(/datum/component/hv_tumble)) + if(!QDELETED(head) && !head_in_flight) things += head log_test("replay/plays_and_restores: severed head [QDELETED(head) ? "gone" : (head_in_flight ? "still in flight at the release, left out of the exact check" : "at rest, checked")]") var/list/real = list() @@ -159,6 +162,10 @@ TEST_ASSERT(!held(user) && !held(raider) && !held(bleeder), "Someone is still held after the replay") TEST_ASSERT(!(locate(/obj/effect/abstract/sandy_replay_standin) in range(9, user)), "A stand-in outlived the replay") TEST_ASSERT(user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "The crash didn't land when the replay ended") + // It looks and sounds like a surge: the user's step left an afterimage, and the sound made in the field played again. + TEST_ASSERT(GLOB.sandy_replay_last["afterimages"] >= 1, "The user's step in the replay left no afterimage") + TEST_ASSERT(GLOB.sandy_replay_last["sounds"] >= 1, "A sound made in the field wasn't recorded") + TEST_ASSERT_EQUAL(GLOB.sandy_replay_last["sounds_played"], GLOB.sandy_replay_last["sounds"], "The recorded sounds didn't all play in the replay") /// A slow-mo that runs out replays by itself, and the watchdog ends a replay whose frames stopped coming /datum/unit_test/voidcrew_cyberware_timestop/replay/watchdog From 195be6befdb96e93291b34a67229ad60649be97b Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:34:30 -0800 Subject: [PATCH 21/33] Sandevistan: blood takes the slow-mo tint like the world; wet blood keeps drying after the replay --- .../modules/cyberware/ware_sandy_replay.dm | 22 +++++++++++-- .../modules/cyberware/ware_sandy_timestop.dm | 31 ++++++++++++++++--- .../unit_tests/voidcrew_cyberware_timestop.dm | 3 ++ 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_sandy_replay.dm b/voidcrew/modules/cyberware/ware_sandy_replay.dm index b2f4d1b515f..455a91214ec 100644 --- a/voidcrew/modules/cyberware/ware_sandy_replay.dm +++ b/voidcrew/modules/cyberware/ware_sandy_replay.dm @@ -329,13 +329,15 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) if(!tinted && !outlined) return thing.appearance var/mutable_appearance/copy = new(thing) - if(tinted) + // Blood wears the tint as a colour filter (cyberware_time_stop_tint_for()), and keeps its own colour under it. + var/tint_filter = tinted && cyberware_time_tinted_blood(thing) + if(tinted && !tint_filter) copy.color = untinted_colour(thing) copy.appearance_flags |= KEEP_TOGETHER // update_atom_colour() leaves this on once anything was ever coloured - if(outlined) + if(outlined || tint_filter) copy.filters = null for(var/name in thing.filter_data) - if(name == SANDY_REPLAY_OUTLINE) + if(name == SANDY_REPLAY_OUTLINE || (tint_filter && name == ATOM_PRIORITY_COLOR_FILTER)) continue var/list/data = thing.filter_data[name] var/list/arguments = data.Copy() @@ -883,6 +885,7 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) UnregisterSignal(thing, list(COMSIG_MOVABLE_MOVED, COMSIG_ATOM_DIR_CHANGE)) if(!QDELETED(thing) && !track.unmasked) wear_real(track) + thing.sandy_replay_resume() track.unmasked = FALSE catch(var/exception/error) LAZYADD(problems, "[error] ([error.file]:[error.line])") @@ -917,6 +920,19 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) if(problems) stack_trace("Sandevistan replay restore problems: [problems.Join("; ")]") +/** + * `src` just got its real look back after the replay. A look's vars hold where its animate()s end, so anything that + * was mid-tween when it became a puppet starts its tween again here from where it has got to by now. + */ +/atom/movable/proc/sandy_replay_resume() + return + +/// Wet blood dries (and spreads) by animate(): without this it jumps to its dried look as the replay ends. +/// update_blood_color() works out how far it has got and plays the rest (hv_vis_paint_on() for the spread). +/obj/effect/decal/cleanable/blood/sandy_replay_resume() + if(!dried || length(hv_vis_painting)) + update_atom_colour() + /// The surge window is closing on `user`: if their replay is about to play or playing, the crash waits for it. /// Returns TRUE if it took the crash. /proc/sandy_replay_defer_crash(mob/living/user, length, stamina) diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index 1a36d8471ff..797637f6736 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -48,8 +48,9 @@ * The user's reflex dodge (ware_pro_combat.dm) is off while the world is slowed: the speed gap already does that * job. It comes back if the slow-mo ends before the window does. * - * Look: affected things get a grey-green tint instead of tg's inversion. Floor items, blood decals and the - * user are never touched, so they keep their colour, and so do the user's own shots. Every bullet in the field + * Look: affected things get a grey-green tint instead of tg's inversion, and so does blood (decals, sprays, + * splatters), as a colour filter so its own drying colour carries on under it. Floor items and the user are never + * touched, so they keep their colour, and so do the user's own shots. Every bullet in the field * flies at the field's pace, whoever fired it: the user's and a surging mob's crawl in the slow-mo like the rest * (a faster surger fires sooner, but its bullets still crawl), untinted. A shot leaving the field gets its speed back. */ @@ -265,6 +266,26 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/static/list/tint = list(0.26,0.28,0.27, 0.52,0.56,0.54, 0.10,0.11,0.105, 0.02,0.05,0.04) return tint +/// Blood the field tints like the world around it (it is only looked at, never held). +/proc/cyberware_time_tinted_blood(atom/thing) + var/static/list/blood = typecacheof(list( + /obj/effect/decal/cleanable/blood, + /obj/effect/temp_visual/dir_setting/bloodsplatter, + /obj/effect/temp_visual/hv_blood_bead, + /obj/effect/temp_visual/wire_blood_drop, + )) + return !!blood[thing.type] + +/// The field's tint as `thing` wears it. Blood takes it as a colour filter: tg works a blood decal's drying colour +/// out of its `color` (update_blood_color()), which a colour matrix there would break. +/proc/cyberware_time_stop_tint_for(atom/thing) + if(cyberware_time_tinted_blood(thing)) + var/static/list/filter_tint + if(!filter_tint) + filter_tint = color_matrix_filter(cyberware_time_stop_tint()) + return filter_tint + return cyberware_time_stop_tint() + /// Flattens a sprite to luminance and paints it the dilation's green (#5AFF96). /proc/cyberware_time_stop_green() var/static/list/green @@ -506,7 +527,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) else if(istype(A, /obj/machinery/porta_turret)) frozen_structures += A held_turrets[A] = TRUE - else if((ismachinery(A) && !istype(A, /obj/machinery/light)) || isstructure(A)) + else if((ismachinery(A) && !istype(A, /obj/machinery/light)) || isstructure(A) || cyberware_time_tinted_blood(A)) frozen_structures += A // looks only else if(cyberware_lit_grenade(A)) held_grenades[A] = TRUE @@ -833,7 +854,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) if(!tint_timers[A]) tint_timers[A] = addtimer(CALLBACK(src, PROC_REF(ring_reached), A), wait, TIMER_STOPPABLE) return - A.add_atom_colour(cyberware_time_stop_tint(), TEMPORARY_COLOUR_PRIORITY) + A.add_atom_colour(cyberware_time_stop_tint_for(A), TEMPORARY_COLOUR_PRIORITY) /datum/proximity_monitor/advanced/timestop/sandevistan/escape_the_negative_zone(atom/A) if(tint_timers[A]) @@ -875,7 +896,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) tint_timers -= thing if(QDELETED(thing) || !tinted[thing]) return - thing.add_atom_colour(cyberware_time_stop_tint(), TEMPORARY_COLOUR_PRIORITY) + thing.add_atom_colour(cyberware_time_stop_tint_for(thing), TEMPORARY_COLOUR_PRIORITY) // ========================================================================= // THE WINDOW'S SIDE diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm index 86f4479b42e..9f502f6a8c2 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -305,6 +305,8 @@ var/gore = cyberware_dilated_time(raider, 1) TEST_ASSERT(gore > 4.9 && gore < 5.1, "A slowed NPC's gore doesn't play at 20% ([gore] for 1)") TEST_ASSERT_NOTNULL(cyberware_time_field(pool), "Blood on the floor in the field isn't slowed") + var/datum/proximity_monitor/advanced/timestop/sandevistan/pool_field = GLOB.cyberware_time_dilated[pool] + TEST_ASSERT(pool_field?.tinted[pool], "Blood in the field isn't tinted with the world") var/list/steps = cyberware_dilated_steps(raider, list(2, 2)) var/both = cyberware_dilated_time(raider, 4) // the second step may run past the window's end, back at full speed TEST_ASSERT(length(steps) == 2 && abs(steps[1] - 10) < 0.1 && abs(steps[1] + steps[2] - both) < 0.1, "A slowed NPC's flinch doesn't play at 20% from its first step ([jointext(steps, ",")], [both] in all)") @@ -341,6 +343,7 @@ TEST_ASSERT_NULL(window.time_stop, "The slow-mo outlived the window") TEST_ASSERT_EQUAL(cyberware_time_lost(raider, 10), 0, "The NPC's clock stayed slow after the dilation") TEST_ASSERT_NULL(cyberware_time_field(pool), "Blood stayed slowed after the dilation") + TEST_ASSERT(!pool.get_filter(ATOM_PRIORITY_COLOR_FILTER) && !islist(pool.color), "Blood kept the slow-mo's tint, or lost its own colour, after the dilation") TEST_ASSERT(!(bomb.dud_flags & GRENADE_USED), "A grenade's fuse didn't lose its slowed time") TEST_ASSERT(bomb.cyberware_fuse_at > world.time, "A grenade's fuse has nothing left after losing time") bomb.cyberware_fuse_at = 0 // don't let it go off after the test From ec57b7846d09d206289b88c3124bf89e8fdcac54 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:39:34 -0800 Subject: [PATCH 22/33] Sandevistan replay: everyone near the user holds still while it plays --- .../modules/cyberware/ware_sandy_replay.dm | 30 +++++++++++++++++-- .../unit_tests/voidcrew_cyberware_replay.dm | 5 +++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_sandy_replay.dm b/voidcrew/modules/cyberware/ware_sandy_replay.dm index 455a91214ec..7e508a1a131 100644 --- a/voidcrew/modules/cyberware/ware_sandy_replay.dm +++ b/voidcrew/modules/cyberware/ware_sandy_replay.dm @@ -58,6 +58,9 @@ #define SANDY_REPLAY_TURN_TIME 2 /// Degrees the replay trail's hue moves per step (CYBERWARE_CASCADE_HUE_STEP in ware_legend.dm). #define SANDY_REPLAY_HUE_STEP 25 +/// Everyone and everything within this many tiles of the user holds still for the replay (the slow-mo's witness range: +/// any other player this close is crew, or there would be no replay). +#define SANDY_REPLAY_HOLD_RANGE 11 /// The most sounds one recording keeps. #define SANDY_REPLAY_MAX_SOUNDS 200 @@ -458,9 +461,7 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) if(isliving(thing)) lock(thing) else if(ismecha(thing)) - var/obj/vehicle/sealed/mecha/mech = thing - mechs[mech] = mech.completely_disabled - mech.completely_disabled = TRUE + hold_mech(thing) if(length(track.frames) == SANDY_FRAME_SIZE && track.first == 0) continue // it never changed: nothing to show if(isturf(thing.loc)) @@ -475,6 +476,7 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) standins += track gone.Cut() user_track = tracks[user] + hold_nearby() RegisterSignal(user, COMSIG_MOVABLE_Z_CHANGED, PROC_REF(on_user_z_changed)) if(user.client && user.stat != DEAD && isturf(user.loc) && user_track?.mode == SANDY_TRACK_PUPPET) eye = new(user.loc) @@ -482,6 +484,26 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) eye_pixel_y = user.client.pixel_y user.reset_perspective(eye) +/// Holds every mob and mech near the user, in the bubble or not: a crewmate or an NPC just outside it can't act on +/// the held ones (or on the user) while the replay plays. Called at the start and every frame, for arrivals. +/datum/sandy_replay/proc/hold_nearby() + var/turf/here = get_turf(user) + if(!here) + return + for(var/atom/movable/found as anything in SSspatial_grid.orthogonal_range_search(here, SPATIAL_GRID_CONTENTS_TYPE_HEARING, SANDY_REPLAY_HOLD_RANGE)) + if(isliving(found)) + lock(found) + var/obj/vehicle/sealed/mecha/mech = get_atom_on_turf(found, /obj/vehicle/sealed/mecha) + if(istype(mech)) // a pilot, or something hearing inside one + hold_mech(mech) + +/// Switches `mech` off for the replay. +/datum/sandy_replay/proc/hold_mech(obj/vehicle/sealed/mecha/mech) + if(!isnull(mechs[mech]) || QDELETED(mech)) + return + mechs[mech] = mech.completely_disabled + mech.completely_disabled = TRUE + /// Pauses `body`'s AI and locks its player's input. No stun: decisions and input only. /datum/sandy_replay/proc/lock(mob/living/body) if(locked[body] || QDELETED(body)) @@ -803,6 +825,7 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) finish() return frame = min(frame, total) + hold_nearby() play_sounds(frame) for(var/datum/sandy_replay_track/track as anything in puppets) var/atom/movable/thing = track.thing @@ -1069,6 +1092,7 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) #undef SANDY_REPLAY_TURN_TIME #undef SANDY_REPLAY_HUE_STEP #undef SANDY_REPLAY_MAX_SOUNDS +#undef SANDY_REPLAY_HOLD_RANGE #undef SANDY_FRAME_SIZE #undef SANDY_FRAME_INDEX #undef SANDY_FRAME_LOOK diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm index 5c1bb89d09f..c46fda9f63d 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm @@ -107,6 +107,8 @@ real_spot[thing] = thing.loc real_dir[thing] = thing.dir var/raider_overlays = length(raider.overlays) + // Not in the slow-mo, but near the user: it holds still for the replay too. + var/mob/living/carbon/human/npc/outsider = allocate(/mob/living/carbon/human/npc, spot(5, 1)) replay.begin() TEST_ASSERT_EQUAL(replay.state, 3, "The replay didn't start") @@ -125,6 +127,7 @@ TEST_ASSERT(length(replay.standins) >= 1, "Something deleted mid-window has no stand-in") TEST_ASSERT(HAS_TRAIT_FROM(user, TRAIT_IMMOBILIZED, "sandy_replay") && HAS_TRAIT_FROM(user, TRAIT_INCAPACITATED, "sandy_replay"), "The user can act during the replay") TEST_ASSERT(HAS_TRAIT_FROM(raider, TRAIT_AI_PAUSED, "sandy_replay"), "The NPC's AI runs during the replay") + TEST_ASSERT(HAS_TRAIT_FROM(outsider, TRAIT_AI_PAUSED, "sandy_replay"), "An NPC near the user but outside the slow-mo acts during the replay") TEST_ASSERT(!raider.IsStun() && !user.IsStun(), "The replay stunned someone") TEST_ASSERT(!user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "The crash started during the replay") @@ -159,7 +162,7 @@ TEST_ASSERT_EQUAL(wrench.pixel_w, initial(wrench.pixel_w), "The item is left drawn off its tile") TEST_ASSERT_EQUAL(wrench.mouse_opacity, initial(wrench.mouse_opacity), "The item can't be clicked after the replay") TEST_ASSERT_EQUAL(GLOB.sandy_replay_last["unhooked"], 1, "Puppets read as changed when nothing changed them") - TEST_ASSERT(!held(user) && !held(raider) && !held(bleeder), "Someone is still held after the replay") + TEST_ASSERT(!held(user) && !held(raider) && !held(bleeder) && !held(outsider), "Someone is still held after the replay") TEST_ASSERT(!(locate(/obj/effect/abstract/sandy_replay_standin) in range(9, user)), "A stand-in outlived the replay") TEST_ASSERT(user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "The crash didn't land when the replay ended") // It looks and sounds like a surge: the user's step left an afterimage, and the sound made in the field played again. From 9bc7db3456c9335f8611a999d1d45839545af952 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:54:57 -0800 Subject: [PATCH 23/33] Sandevistan replay: an afterimage on every tile crossed; wet blood replays as it was drawn; test accounts for a head landing mid-replay --- .../modules/cyberware/ware_sandy_replay.dm | 91 +++++++++++++++++-- .../unit_tests/voidcrew_cyberware_replay.dm | 7 +- 2 files changed, 88 insertions(+), 10 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_sandy_replay.dm b/voidcrew/modules/cyberware/ware_sandy_replay.dm index 7e508a1a131..bebb646edd8 100644 --- a/voidcrew/modules/cyberware/ware_sandy_replay.dm +++ b/voidcrew/modules/cyberware/ware_sandy_replay.dm @@ -307,12 +307,18 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) var/facing = thing.dir var/list/frames = track.frames var/at = length(frames) - SANDY_FRAME_SIZE - if(at >= 0 && raw == track.last_raw && frames[at + SANDY_FRAME_X] == x && frames[at + SANDY_FRAME_Y] == y && frames[at + SANDY_FRAME_Z] == z && frames[at + SANDY_FRAME_DIR] == facing) + // Wet blood dries and spreads on the client (animate()): its vars already hold the end, so it is looked at every sample. + var/obj/effect/decal/cleanable/blood/wet = istype(thing, /obj/effect/decal/cleanable/blood) ? thing : null + if(wet && !wet.sandy_replay_wet()) + wet = null + if(!wet && at >= 0 && raw == track.last_raw && frames[at + SANDY_FRAME_X] == x && frames[at + SANDY_FRAME_Y] == y && frames[at + SANDY_FRAME_Z] == z && frames[at + SANDY_FRAME_DIR] == facing) return track.last_raw = raw if(!track.surger && (thing == user || field?.surging[thing])) become_surger(track) var/look = clean_look(thing) + if(wet) + look = wet_look(wet, look) if(at >= 0) if(frames[at + SANDY_FRAME_LOOK] == look && frames[at + SANDY_FRAME_X] == x && frames[at + SANDY_FRAME_Y] == y && frames[at + SANDY_FRAME_Z] == z && frames[at + SANDY_FRAME_DIR] == facing) return @@ -376,6 +382,20 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) track.trail_every = max(1, window.afterimage_interval) track.trail_alpha = window.afterimage_alpha +/// `look` (wet `blood`'s appearance) as the blood is drawn on screen right now, not where its tweens end. +/datum/sandy_replay/proc/wet_look(obj/effect/decal/cleanable/blood/blood, look) + var/colour = blood.sandy_replay_colour_now() + var/list/paint = blood.sandy_replay_paint_now() + if(!colour && !paint) + return look + var/mutable_appearance/copy = new(look) + if(colour) + copy.color = colour + if(paint) + copy.transform = paint[1] + copy.alpha = paint[2] + return copy.appearance + /// A sound was played on `source`, in the field: kept, to play again in the replay. `arguments` are the rest of /// playsound()'s, from soundin on, without the channel. /datum/sandy_replay/proc/hear(turf/source, list/arguments) @@ -773,15 +793,20 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) return if(frames[from + SANDY_FRAME_X] == frames[dest + SANDY_FRAME_X] && frames[from + SANDY_FRAME_Y] == frames[dest + SANDY_FRAME_Y]) return - track.trail_steps++ - if(track.trail_steps % track.trail_every) - return var/turf/left = locate(frames[from + SANDY_FRAME_X], frames[from + SANDY_FRAME_Y], frames[from + SANDY_FRAME_Z]) - if(!left) - return - new /obj/effect/temp_visual/decoy/fading/cascade/replay(left, frames[dest + SANDY_FRAME_LOOK], frames[dest + SANDY_FRAME_DIR], track.trail_hue, track.trail_alpha) - afterimages++ - track.trail_hue = (track.trail_hue + SANDY_REPLAY_HUE_STEP) % 360 + var/turf/reached = locate(frames[dest + SANDY_FRAME_X], frames[dest + SANDY_FRAME_Y], frames[dest + SANDY_FRAME_Z]) + if(!left || !reached || get_dist(left, reached) > SANDY_REPLAY_GLIDE_MAX / ICON_SIZE_X) + return // a jump, not steps + // Two steps can fall between two samples: each tile it crossed gets its afterimage, as each step would. + var/list/crossed = get_line(left, reached) + crossed -= reached + for(var/turf/step_from as anything in crossed) + track.trail_steps++ + if(track.trail_steps % track.trail_every) + continue + new /obj/effect/temp_visual/decoy/fading/cascade/replay(step_from, frames[dest + SANDY_FRAME_LOOK], frames[dest + SANDY_FRAME_DIR], track.trail_hue, track.trail_alpha) + afterimages++ + track.trail_hue = (track.trail_hue + SANDY_REPLAY_HUE_STEP) % 360 /// Plays the recorded sounds due by `frame`. /datum/sandy_replay/proc/play_sounds(frame) @@ -950,6 +975,54 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) /atom/movable/proc/sandy_replay_resume() return +/// Whether the decal is in the middle of drying or spreading, which happen on the client. +/obj/effect/decal/cleanable/blood/proc/sandy_replay_wet() + return (!dried && can_dry && total_dry_time && drying_time > 0) || length(hv_vis_painting) >= 2 + +/// The colour it is drawn with right now while it dries (update_blood_color() animates from this to the dried colour), +/// or null when that isn't a plain colour. +/obj/effect/decal/cleanable/blood/proc/sandy_replay_colour_now() + if(dried || !can_dry || drying_time <= 0 || !total_dry_time) + return null + var/base + for(var/priority in 1 to length(atom_colours)) // the slow-mo's tint is a filter at TEMPORARY_COLOUR_PRIORITY + if(priority == TEMPORARY_COLOUR_PRIORITY) + continue + var/list/entry = atom_colours[priority] + if(!entry) + continue + if(entry[ATOM_COLOR_TYPE_INDEX] == ATOM_COLOR_TYPE_FILTER || !istext(entry[ATOM_COLOR_VALUE_INDEX])) + return null + base = entry[ATOM_COLOR_VALUE_INDEX] + break + if(!base) + var/list/blood_DNA = GET_ATOM_BLOOD_DECALS(src) + if(!length(blood_DNA)) + blood_DNA = GET_ATOM_BLOOD_DNA(src) + base = length(blood_DNA) ? get_color_from_blood_list(blood_DNA) : BLOOD_COLOR_RED + if(!istext(base)) + return null + return BlendRGB(base, get_dried_color(base), round(1 - drying_time / total_dry_time, 0.01)) + +/// Where its hyperviolence paint (hv_vis_paint()) has got to right now: list(transform, alpha), or null. +/obj/effect/decal/cleanable/blood/proc/sandy_replay_paint_now() + var/list/keys = hv_vis_painting + if(length(keys) < 2) + return null + var/now = world.time + for(var/i in 2 to length(keys)) + var/list/ahead = keys[i] + if(now >= ahead[3]) + continue + var/list/behind = keys[i - 1] + if(now <= behind[3]) + return list(behind[1], behind[2]) + var/span = ahead[3] - behind[3] + var/progress = span > 0 ? hv_vis_eased(clamp((now - behind[3]) / span, 0, 1), ahead[4]) : 1 + var/matrix/from = behind[1] + return list(from.Interpolate(ahead[1], progress), behind[2] + (ahead[2] - behind[2]) * progress) + return null + /// Wet blood dries (and spreads) by animate(): without this it jumps to its dried look as the replay ends. /// update_blood_color() works out how far it has got and plays the rest (hv_vis_paint_on() for the spread). /obj/effect/decal/cleanable/blood/sandy_replay_resume() diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm index c46fda9f63d..f3dab414332 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm @@ -96,6 +96,7 @@ // A head still flying (a throw, or hyperviolence's tumble) really lands during the replay, and its roll on landing // moves its pixel_x 6 px (hv_vis_roll() in hyperviolence/visuals/flying_gore.dm): a real change, not the replay's. var/head_in_flight = !QDELETED(head) && (!!head.throwing || !!head.GetComponent(/datum/component/hv_tumble)) + var/head_x = QDELETED(head) ? null : head.pixel_x if(!QDELETED(head) && !head_in_flight) things += head log_test("replay/plays_and_restores: severed head [QDELETED(head) ? "gone" : (head_in_flight ? "still in flight at the release, left out of the exact check" : "at rest, checked")]") @@ -161,7 +162,11 @@ TEST_ASSERT_EQUAL(wrench.alpha, 128, "A var written mid-replay was lost") TEST_ASSERT_EQUAL(wrench.pixel_w, initial(wrench.pixel_w), "The item is left drawn off its tile") TEST_ASSERT_EQUAL(wrench.mouse_opacity, initial(wrench.mouse_opacity), "The item can't be clicked after the replay") - TEST_ASSERT_EQUAL(GLOB.sandy_replay_last["unhooked"], 1, "Puppets read as changed when nothing changed them") + // The wrench's alpha is one change no wrapper sees. A head that lands mid-replay rolls (a direct pixel_x write) and + // is the only other one: it must have moved by its roll. + var/head_rolled = head_in_flight && !QDELETED(head) && head.pixel_x != head_x + log_test("replay/plays_and_restores: head pixel_x [head_x] at the release, [QDELETED(head) ? "gone" : head.pixel_x] after; unhooked [GLOB.sandy_replay_last["unhooked"]]") + TEST_ASSERT_EQUAL(GLOB.sandy_replay_last["unhooked"], head_rolled ? 2 : 1, "Puppets read as changed when nothing changed them") TEST_ASSERT(!held(user) && !held(raider) && !held(bleeder) && !held(outsider), "Someone is still held after the replay") TEST_ASSERT(!(locate(/obj/effect/abstract/sandy_replay_standin) in range(9, user)), "A stand-in outlived the replay") TEST_ASSERT(user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "The crash didn't land when the replay ended") From c9a44a16f11f294e596e64747eb9999d158224a3 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:06:08 -0800 Subject: [PATCH 24/33] Sandevistan: tint blood with the colour matrix and hold its drying colour while tinted; trail cadence from the surge window --- .../modules/cyberware/ware_sandy_replay.dm | 41 ++++++++++++------- .../modules/cyberware/ware_sandy_timestop.dm | 23 +++++------ .../unit_tests/voidcrew_cyberware_timestop.dm | 2 +- 3 files changed, 38 insertions(+), 28 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_sandy_replay.dm b/voidcrew/modules/cyberware/ware_sandy_replay.dm index bebb646edd8..245d0f80b1d 100644 --- a/voidcrew/modules/cyberware/ware_sandy_replay.dm +++ b/voidcrew/modules/cyberware/ware_sandy_replay.dm @@ -99,8 +99,8 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) var/last_raw /// It was on a surge during the window: the user, or a mob on its own surge in the field. var/surger = FALSE - /// Surger: its surge's trail. An afterimage every this many steps, starting at this alpha. - var/trail_every = 1 + /// Surger: its surge's trail. An afterimage every this many steps (0 until its window is found), starting at this alpha. + var/trail_every = 0 var/trail_alpha = CYBERWARE_CASCADE_AFTERIMAGE_ALPHA /// Surger: the next afterimage's hue, and steps taken in the replay. var/trail_hue = 0 @@ -316,6 +316,8 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) track.last_raw = raw if(!track.surger && (thing == user || field?.surging[thing])) become_surger(track) + else if(track.surger && !track.trail_every) + read_trail(track) var/look = clean_look(thing) if(wet) look = wet_look(wet, look) @@ -338,15 +340,17 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) if(!tinted && !outlined) return thing.appearance var/mutable_appearance/copy = new(thing) - // Blood wears the tint as a colour filter (cyberware_time_stop_tint_for()), and keeps its own colour under it. - var/tint_filter = tinted && cyberware_time_tinted_blood(thing) - if(tinted && !tint_filter) - copy.color = untinted_colour(thing) + if(tinted) + if(istype(thing, /obj/effect/decal/cleanable/blood)) // blood's colour is its own, worked out as it dries + var/obj/effect/decal/cleanable/blood/blood = thing + copy.color = blood.sandy_replay_colour() + else + copy.color = untinted_colour(thing) copy.appearance_flags |= KEEP_TOGETHER // update_atom_colour() leaves this on once anything was ever coloured - if(outlined || tint_filter) + if(outlined) copy.filters = null for(var/name in thing.filter_data) - if(name == SANDY_REPLAY_OUTLINE || (tint_filter && name == ATOM_PRIORITY_COLOR_FILTER)) + if(name == SANDY_REPLAY_OUTLINE) continue var/list/data = thing.filter_data[name] var/list/arguments = data.Copy() @@ -374,6 +378,11 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) /datum/sandy_replay/proc/become_surger(datum/sandy_replay_track/track) track.surger = TRUE track.trail_hue = rand(0, 359) + read_trail(track) + +/// Takes the trail's cadence and strength from the surger's window. The user's own window isn't listed on them yet +/// while it opens the slow-mo, so this is tried again on later samples. +/datum/sandy_replay/proc/read_trail(datum/sandy_replay_track/track) if(!isliving(track.thing)) return var/mob/living/body = track.thing @@ -384,7 +393,7 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) /// `look` (wet `blood`'s appearance) as the blood is drawn on screen right now, not where its tweens end. /datum/sandy_replay/proc/wet_look(obj/effect/decal/cleanable/blood/blood, look) - var/colour = blood.sandy_replay_colour_now() + var/colour = blood.sandy_replay_colour() var/list/paint = blood.sandy_replay_paint_now() if(!colour && !paint) return look @@ -802,7 +811,7 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) crossed -= reached for(var/turf/step_from as anything in crossed) track.trail_steps++ - if(track.trail_steps % track.trail_every) + if(track.trail_steps % max(1, track.trail_every)) continue new /obj/effect/temp_visual/decoy/fading/cascade/replay(step_from, frames[dest + SANDY_FRAME_LOOK], frames[dest + SANDY_FRAME_DIR], track.trail_hue, track.trail_alpha) afterimages++ @@ -979,11 +988,9 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) /obj/effect/decal/cleanable/blood/proc/sandy_replay_wet() return (!dried && can_dry && total_dry_time && drying_time > 0) || length(hv_vis_painting) >= 2 -/// The colour it is drawn with right now while it dries (update_blood_color() animates from this to the dried colour), -/// or null when that isn't a plain colour. -/obj/effect/decal/cleanable/blood/proc/sandy_replay_colour_now() - if(dried || !can_dry || drying_time <= 0 || !total_dry_time) - return null +/// The colour update_blood_color() gives it right now without the slow-mo's tint: dried, or partway there while it +/// dries (it animates from this to the dried colour). Null when that isn't a plain colour. +/obj/effect/decal/cleanable/blood/proc/sandy_replay_colour() var/base for(var/priority in 1 to length(atom_colours)) // the slow-mo's tint is a filter at TEMPORARY_COLOUR_PRIORITY if(priority == TEMPORARY_COLOUR_PRIORITY) @@ -1002,6 +1009,10 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) base = length(blood_DNA) ? get_color_from_blood_list(blood_DNA) : BLOOD_COLOR_RED if(!istext(base)) return null + if(dried || drying_time <= 0) + return get_dried_color(base) + if(!total_dry_time) + return base return BlendRGB(base, get_dried_color(base), round(1 - drying_time / total_dry_time, 0.01)) /// Where its hyperviolence paint (hv_vis_paint()) has got to right now: list(transform, alpha), or null. diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index 797637f6736..07788585a55 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -49,7 +49,7 @@ * job. It comes back if the slow-mo ends before the window does. * * Look: affected things get a grey-green tint instead of tg's inversion, and so does blood (decals, sprays, - * splatters), as a colour filter so its own drying colour carries on under it. Floor items and the user are never + * splatters); a tinted decal's drying colour waits for the tint to come off. Floor items and the user are never * touched, so they keep their colour, and so do the user's own shots. Every bullet in the field * flies at the field's pace, whoever fired it: the user's and a surging mob's crawl in the slow-mo like the rest * (a faster surger fires sooner, but its bullets still crawl), untinted. A shot leaving the field gets its speed back. @@ -276,15 +276,14 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) )) return !!blood[thing.type] -/// The field's tint as `thing` wears it. Blood takes it as a colour filter: tg works a blood decal's drying colour -/// out of its `color` (update_blood_color()), which a colour matrix there would break. -/proc/cyberware_time_stop_tint_for(atom/thing) - if(cyberware_time_tinted_blood(thing)) - var/static/list/filter_tint - if(!filter_tint) - filter_tint = color_matrix_filter(cyberware_time_stop_tint()) - return filter_tint - return cyberware_time_stop_tint() +/// tg works a blood decal's drying colour out of its `color` (blending it toward the dried colour), which can't be +/// done with the slow-mo's tint matrix there: while tinted it wears the tint, and works its colour out again when +/// the tint comes off (update_atom_colour() calls this). A colour filter was tried first and didn't draw on decals. +/obj/effect/decal/cleanable/blood/update_blood_color() + if(islist(color) && LAZYACCESS(atom_colours, TEMPORARY_COLOUR_PRIORITY) && GLOB.cyberware_time_dilated[src]) + animate(src) + return TRUE + return ..() /// Flattens a sprite to luminance and paints it the dilation's green (#5AFF96). /proc/cyberware_time_stop_green() @@ -854,7 +853,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) if(!tint_timers[A]) tint_timers[A] = addtimer(CALLBACK(src, PROC_REF(ring_reached), A), wait, TIMER_STOPPABLE) return - A.add_atom_colour(cyberware_time_stop_tint_for(A), TEMPORARY_COLOUR_PRIORITY) + A.add_atom_colour(cyberware_time_stop_tint(), TEMPORARY_COLOUR_PRIORITY) /datum/proximity_monitor/advanced/timestop/sandevistan/escape_the_negative_zone(atom/A) if(tint_timers[A]) @@ -896,7 +895,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) tint_timers -= thing if(QDELETED(thing) || !tinted[thing]) return - thing.add_atom_colour(cyberware_time_stop_tint_for(thing), TEMPORARY_COLOUR_PRIORITY) + thing.add_atom_colour(cyberware_time_stop_tint(), TEMPORARY_COLOUR_PRIORITY) // ========================================================================= // THE WINDOW'S SIDE diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm index 9f502f6a8c2..08c5e7c3c08 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -343,7 +343,7 @@ TEST_ASSERT_NULL(window.time_stop, "The slow-mo outlived the window") TEST_ASSERT_EQUAL(cyberware_time_lost(raider, 10), 0, "The NPC's clock stayed slow after the dilation") TEST_ASSERT_NULL(cyberware_time_field(pool), "Blood stayed slowed after the dilation") - TEST_ASSERT(!pool.get_filter(ATOM_PRIORITY_COLOR_FILTER) && !islist(pool.color), "Blood kept the slow-mo's tint, or lost its own colour, after the dilation") + TEST_ASSERT(istext(pool.color), "Blood kept the slow-mo's tint, or lost its own colour, after the dilation") TEST_ASSERT(!(bomb.dud_flags & GRENADE_USED), "A grenade's fuse didn't lose its slowed time") TEST_ASSERT(bomb.cyberware_fuse_at > world.time, "A grenade's fuse has nothing left after losing time") bomb.cyberware_fuse_at = 0 // don't let it go off after the test From b356868ad4f17b6bde9d1b04291a4c2881fd6a3e Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:17:47 -0800 Subject: [PATCH 25/33] Sandevistan: tinted blood keeps its own colour under the slow-mo's tint --- .../modules/cyberware/ware_sandy_timestop.dm | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index 07788585a55..c24e04ccb43 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -276,14 +276,24 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) )) return !!blood[thing.type] -/// tg works a blood decal's drying colour out of its `color` (blending it toward the dried colour), which can't be -/// done with the slow-mo's tint matrix there: while tinted it wears the tint, and works its colour out again when -/// the tint comes off (update_atom_colour() calls this). A colour filter was tried first and didn't draw on decals. +/// tg works a blood decal's drying colour out of its `color` (blending it toward the dried colour), and its sprite is +/// white, coloured by that `color`. With the slow-mo's tint matrix in `color` it would be drawn white and its drying +/// colour lost, so while tinted it wears the tint mixed with its own colour, held where it was. It works its colour +/// out again when the tint comes off (update_atom_colour() calls this). A colour filter didn't draw on decals. /obj/effect/decal/cleanable/blood/update_blood_color() - if(islist(color) && LAZYACCESS(atom_colours, TEMPORARY_COLOUR_PRIORITY) && GLOB.cyberware_time_dilated[src]) - animate(src) + if(!islist(color) || !LAZYACCESS(atom_colours, TEMPORARY_COLOUR_PRIORITY) || !GLOB.cyberware_time_dilated[src]) + return ..() + animate(src) + var/own = sandy_replay_colour() // ware_sandy_replay.dm + if(!istext(own)) return TRUE - return ..() + var/list/rgb = rgb2num(own) + var/list/mixed = cyberware_time_stop_tint().Copy() + for(var/row in 0 to 2) + for(var/column in 1 to 3) + mixed[row * 3 + column] *= rgb[row + 1] / 255 + color = mixed + return TRUE /// Flattens a sprite to luminance and paints it the dilation's green (#5AFF96). /proc/cyberware_time_stop_green() From f03b6272a8000394e8d7db76e62c2f5cded9360c Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:33:16 -0800 Subject: [PATCH 26/33] Sandevistan replay: name the changes no wrapper saw, and check them by name --- .../modules/cyberware/ware_sandy_replay.dm | 18 ++++++++++-------- .../unit_tests/voidcrew_cyberware_replay.dm | 17 ++++++++++++----- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_sandy_replay.dm b/voidcrew/modules/cyberware/ware_sandy_replay.dm index 245d0f80b1d..eb2a55341f6 100644 --- a/voidcrew/modules/cyberware/ware_sandy_replay.dm +++ b/voidcrew/modules/cyberware/ware_sandy_replay.dm @@ -192,8 +192,9 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) /// The crash waiting for the replay to end: list(length, stamina). var/list/crash var/finished = FALSE - /// Changes to a puppet that no wrapped proc made (fold()). + /// Changes to a puppet that no wrapped proc made (fold()), and what they were. var/unhooked = 0 + var/list/folded = list() /// Times a wrapped proc changed a puppet. var/unmasks = 0 /// Sounds made in the field, each list(frame, turf, the rest of playsound()'s arguments). @@ -617,27 +618,28 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) var/mutable_appearance/now = new(track.thing) var/mutable_appearance/drawn = new(track.expect) var/mutable_appearance/real = new(track.saved_look) - var/changed = FALSE + var/list/changed = list() // pixel_x and pixel_y are taken as plain writes (tg sets them far more often than it shifts them). pixel_w and // pixel_z carry the puppet's own shift, so only what was added to them is carried over. for(var/name in list("icon", "icon_state", "alpha", "layer", "plane", "invisibility", "name", "blend_mode", "pixel_x", "pixel_y")) if(now.vars[name] != drawn.vars[name]) real.vars[name] = now.vars[name] - changed = TRUE + changed += name if(!(now.color ~= drawn.color)) real.color = now.color - changed = TRUE + changed += "color" if(!(now.transform ~= drawn.transform)) real.transform = now.transform - changed = TRUE + changed += "transform" for(var/name in list("pixel_w", "pixel_z")) if(now.vars[name] != drawn.vars[name]) real.vars[name] += now.vars[name] - drawn.vars[name] - changed = TRUE - if(!changed) + changed += name + if(!length(changed)) return track.saved_look = real.appearance unhooked++ + folded += "[track.thing.type]: [changed.Join(",")]" /// Moves `track` on to the newest frame at or before `frame`. Returns TRUE if that is a new one. /datum/sandy_replay/proc/advance(datum/sandy_replay_track/track, frame) @@ -968,7 +970,7 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) if(viewer.eye == eye) user.reset_perspective() QDEL_NULL(eye) - GLOB.sandy_replay_last = list("played" = was_playing, "frames" = total, "puppets" = length(puppets), "standins" = length(standins), "unhooked" = unhooked, "unmasks" = unmasks, "afterimages" = afterimages, "sounds" = length(sounds), "sounds_played" = sound_at - 1) + GLOB.sandy_replay_last = list("played" = was_playing, "frames" = total, "puppets" = length(puppets), "standins" = length(standins), "unhooked" = unhooked, "folded" = folded.Copy(), "unmasks" = unmasks, "afterimages" = afterimages, "sounds" = length(sounds), "sounds_played" = sound_at - 1) if(crash && !QDELETED(user) && user.stat != DEAD && cyberware_bearer_lattice(user)) user.apply_status_effect(/datum/status_effect/cyberware_cascade_crash, crash[1], crash[2]) crash = null diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm index f3dab414332..57508b321e2 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm @@ -162,11 +162,18 @@ TEST_ASSERT_EQUAL(wrench.alpha, 128, "A var written mid-replay was lost") TEST_ASSERT_EQUAL(wrench.pixel_w, initial(wrench.pixel_w), "The item is left drawn off its tile") TEST_ASSERT_EQUAL(wrench.mouse_opacity, initial(wrench.mouse_opacity), "The item can't be clicked after the replay") - // The wrench's alpha is one change no wrapper sees. A head that lands mid-replay rolls (a direct pixel_x write) and - // is the only other one: it must have moved by its roll. - var/head_rolled = head_in_flight && !QDELETED(head) && head.pixel_x != head_x - log_test("replay/plays_and_restores: head pixel_x [head_x] at the release, [QDELETED(head) ? "gone" : head.pixel_x] after; unhooked [GLOB.sandy_replay_last["unhooked"]]") - TEST_ASSERT_EQUAL(GLOB.sandy_replay_last["unhooked"], head_rolled ? 2 : 1, "Puppets read as changed when nothing changed them") + // The wrench's alpha is one change no wrapper sees. A head still tumbling at the release lands mid-replay, and its + // bounce and roll (hv_vis_roll(), flying_gore.dm) write its pixel offsets directly: that may be another. Nothing + // else may be. + var/list/folded = GLOB.sandy_replay_last["folded"] + log_test("replay/plays_and_restores: head pixel_x [head_x] at the release, [QDELETED(head) ? "gone" : head.pixel_x] after; changes no wrapper saw: [folded.Join("; ")]") + var/wrench_changes = 0 + for(var/change in folded) + if(findtext(change, "[/obj/item/wrench]:") == 1) + wrench_changes++ + else + TEST_ASSERT(head_in_flight && findtext(change, "[/obj/item/bodypart/head]") == 1, "A puppet read as changed when nothing changed it: [change]") + TEST_ASSERT_EQUAL(wrench_changes, 1, "The var written on the wrench mid-replay wasn't seen once") TEST_ASSERT(!held(user) && !held(raider) && !held(bleeder) && !held(outsider), "Someone is still held after the replay") TEST_ASSERT(!(locate(/obj/effect/abstract/sandy_replay_standin) in range(9, user)), "A stand-in outlived the replay") TEST_ASSERT(user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "The crash didn't land when the replay ended") From 4261632cbd942b88b3ff8fe8169c98766d2bb619 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:27:25 -0800 Subject: [PATCH 27/33] Sandevistan: tint loose gore with the world, end the slow-mo when the user dies --- voidcrew/modules/cyberware/ware_legend.dm | 8 +++++++- voidcrew/modules/cyberware/ware_sandy_timestop.dm | 12 ++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_legend.dm b/voidcrew/modules/cyberware/ware_legend.dm index 838d6014248..80a8365200a 100644 --- a/voidcrew/modules/cyberware/ware_legend.dm +++ b/voidcrew/modules/cyberware/ware_legend.dm @@ -223,6 +223,7 @@ owner.add_movespeed_mod_immunities(id, /datum/movespeed_modifier/reagent) cyberware_register_dodge_source(owner, id, CALLBACK(src, PROC_REF(get_dodge_chance)), melee = TRUE) RegisterSignal(owner, COMSIG_MOVABLE_MOVED, PROC_REF(on_move)) + RegisterSignal(owner, COMSIG_LIVING_DEATH, PROC_REF(on_death)) owner.add_client_colour(colour_type, id) playsound(owner, 'sound/effects/magic/timeparadox2.ogg', 70, TRUE) owner.visible_message( @@ -240,7 +241,7 @@ set_perks(FALSE) owner.remove_movespeed_mod_immunities(id, /datum/movespeed_modifier/reagent) cyberware_unregister_dodge_source(owner, id) - UnregisterSignal(owner, COMSIG_MOVABLE_MOVED) + UnregisterSignal(owner, list(COMSIG_MOVABLE_MOVED, COMSIG_LIVING_DEATH)) owner.remove_client_colour(id) // A slow-mo that ran to its end replays first, and the crash lands when the replay is over (ware_sandy_replay.dm). if(!QDELETED(owner) && owner.stat != DEAD && !sandy_replay_defer_crash(owner, crash_duration, crash_stamina)) @@ -249,6 +250,11 @@ cyberware_cascade_window_end_fx(owner) return ..() +/// Signal proc for [COMSIG_LIVING_DEATH]: the bearer's slow-mo ends with them (its replay still plays). +/datum/status_effect/cyberware_cascade_window/proc/on_death(mob/living/source, gibbed) + SIGNAL_HANDLER + end_time_stop() // ware_sandy_timestop.dm + /// Melee cooldowns scaled for the window (changeNext_move multiplies these in). /datum/status_effect/cyberware_cascade_window/nextmove_modifier() return perks_on ? melee_mult : 1 diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index c24e04ccb43..7c88d284e14 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -266,15 +266,23 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/static/list/tint = list(0.26,0.28,0.27, 0.52,0.56,0.54, 0.10,0.11,0.105, 0.02,0.05,0.04) return tint -/// Blood the field tints like the world around it (it is only looked at, never held). +/// Blood and loose gore the field tints like the world around it (it is only looked at, never held): nothing about +/// gore is in colour in the slow-mo. Severed parts and spilled intestines are items, which otherwise keep their colour +/// on the floor. /proc/cyberware_time_tinted_blood(atom/thing) var/static/list/blood = typecacheof(list( /obj/effect/decal/cleanable/blood, /obj/effect/temp_visual/dir_setting/bloodsplatter, /obj/effect/temp_visual/hv_blood_bead, /obj/effect/temp_visual/wire_blood_drop, + /obj/item/hv_intestines, + /obj/effect/ebeam/hv_gut, + /obj/item/bodypart, + /obj/item/organ, )) - return !!blood[thing.type] + if(!blood[thing.type]) + return FALSE + return isturf(thing.loc) // a part or organ still in a body, a hand or a bag is not loose gore /// tg works a blood decal's drying colour out of its `color` (blending it toward the dried colour), and its sprite is /// white, coloured by that `color`. With the slow-mo's tint matrix in `color` it would be drawn white and its drying From c1079c2c95cf9bba9ebebfbd569a88903e2c77ce Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:02:47 -0800 Subject: [PATCH 28/33] Sandevistan: blood and gore take the slow-mo's tint like everything else Bodies and items wear the tint as a colour filter, which covers their wound, gore and stain overlays. Drips, trail pieces and gut strands drawn in a holder's vis_contents are tinted on their own, a gib's meat overlay wears the tint, a splash that tg recolours after making it gets the tint back, and a hit flash plays through the tint filter instead of a second one. --- .../modules/cyberware/ware_sandy_replay.dm | 11 +- .../modules/cyberware/ware_sandy_timestop.dm | 154 ++++++++++++++++-- .../hyperviolence/visuals/_motion_core.dm | 2 + .../unit_tests/voidcrew_cyberware_timestop.dm | 24 ++- 4 files changed, 176 insertions(+), 15 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_sandy_replay.dm b/voidcrew/modules/cyberware/ware_sandy_replay.dm index eb2a55341f6..f54016c6697 100644 --- a/voidcrew/modules/cyberware/ware_sandy_replay.dm +++ b/voidcrew/modules/cyberware/ware_sandy_replay.dm @@ -39,6 +39,8 @@ #define SANDY_REPLAY_TRAIT "sandy_replay" /// The user's edge glow in a slow-mo (SANDY_STOP_OUTLINE in ware_sandy_timestop.dm, which is included after this file). #define SANDY_REPLAY_OUTLINE "sandy_time_stop_outline" +/// The slow-mo's tint filter on bodies and items (SANDY_STOP_TINT in ware_sandy_timestop.dm). +#define SANDY_REPLAY_TINT "sandy_time_stop_tint" #define SANDY_REPLAY_RECORDING 1 #define SANDY_REPLAY_PENDING 2 @@ -341,17 +343,19 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) if(!tinted && !outlined) return thing.appearance var/mutable_appearance/copy = new(thing) - if(tinted) + // Bodies and items wear the tint as a filter (cyberware_time_tints_by_filter()), the rest in `color`. + var/filtered = tinted && cyberware_time_tints_by_filter(thing) + if(tinted && !filtered) if(istype(thing, /obj/effect/decal/cleanable/blood)) // blood's colour is its own, worked out as it dries var/obj/effect/decal/cleanable/blood/blood = thing copy.color = blood.sandy_replay_colour() else copy.color = untinted_colour(thing) copy.appearance_flags |= KEEP_TOGETHER // update_atom_colour() leaves this on once anything was ever coloured - if(outlined) + if(outlined || filtered) copy.filters = null for(var/name in thing.filter_data) - if(name == SANDY_REPLAY_OUTLINE) + if(name == SANDY_REPLAY_OUTLINE || name == SANDY_REPLAY_TINT) continue var/list/data = thing.filter_data[name] var/list/arguments = data.Copy() @@ -1166,6 +1170,7 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) #undef SANDY_REPLAY_TRAIT #undef SANDY_REPLAY_OUTLINE +#undef SANDY_REPLAY_TINT #undef SANDY_REPLAY_RECORDING #undef SANDY_REPLAY_PENDING #undef SANDY_REPLAY_PLAYING diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index 7c88d284e14..617ab41da50 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -69,6 +69,8 @@ #define SANDY_STOP_OUTLINE "sandy_time_stop_outline" /// Fullscreen category of the release flash. #define SANDY_STOP_FLASH "sandy_time_stop_flash" +/// Filter name (and KEEP_TOGETHER source) of the slow-mo's tint on bodies and items. Same as SANDY_REPLAY_TINT. +#define SANDY_STOP_TINT "sandy_time_stop_tint" /// Every atom the dilation has hold of, to its field. GLOBAL_LIST_EMPTY(cyberware_time_dilated) @@ -266,6 +268,85 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/static/list/tint = list(0.26,0.28,0.27, 0.52,0.56,0.54, 0.10,0.11,0.105, 0.02,0.05,0.04) return tint +/// The wash over something drawn in `colour` (a sprite coloured white-to-`colour` by its `color`): the tint's rows +/// scaled by that colour, so it reads as that colour seen through the wash. +/proc/cyberware_time_tint_of(colour) + var/list/mixed = cyberware_time_stop_tint().Copy() + var/list/rgb = istext(colour) ? rgb2num(colour) : null + if(length(rgb) < 3) + return mixed + for(var/row in 0 to 2) + for(var/column in 1 to 3) + mixed[row * 3 + column] *= rgb[row + 1] / 255 + return mixed + +/// Whether `thing` wears the slow-mo's tint as a colour filter. Bodies and items carry their blood as RESET_COLOR +/// overlays (wounds, gore, bloody clothes, stains on a severed limb), which a tint in `color` never reaches; a filter +/// covers the whole drawn atom, overlays included. It also leaves `atom_colours` alone, so a body's own temporary +/// colour (a chem, a perk) is still there when the tint comes off. +/proc/cyberware_time_tints_by_filter(atom/thing) + return isliving(thing) || isitem(thing) + +/// Puts the slow-mo's tint on `thing`. +/proc/cyberware_time_tint_on(atom/thing) + if(cyberware_time_tints_by_filter(thing)) + // While another colour filter was animated on a tinted body (hyperviolence's hit flash), the client drew + // it in full colour: the flash runs through this filter instead (cyberware_time_flash()). + thing.remove_filter("hv_vis_flash") // a hit flash already running would show the body in full colour + thing.add_filter(SANDY_STOP_TINT, 20, color_matrix_filter(cyberware_time_stop_tint())) + ADD_KEEP_TOGETHER(thing, SANDY_STOP_TINT) + return + // A blood splash, bead or strand carries its colour in `color` alone, which the tint replaces: kept as its own + // colour, so it is red again (not white) when the tint comes off. + if((istype(thing, /obj/effect/temp_visual) || istype(thing, /obj/effect/ebeam)) && istext(thing.color) && !LAZYACCESS(thing.atom_colours, FIXED_COLOUR_PRIORITY)) + thing.add_atom_colour(thing.color, FIXED_COLOUR_PRIORITY) + thing.add_atom_colour(cyberware_time_stop_tint(), TEMPORARY_COLOUR_PRIORITY) + if(istype(thing, /obj/effect/decal/cleanable/blood/gibs)) // its meat is a RESET_COLOR overlay: see its update_overlays() + thing.update_appearance(UPDATE_OVERLAYS) + +/// `first` then the slow-mo's tint, as one colour matrix (both 12-number RGB matrices). +/proc/cyberware_time_tint_after(list/first) + var/list/tint = cyberware_time_stop_tint() + var/list/out = list() + for(var/row in 0 to 3) + for(var/column in 1 to 3) + var/sum = row == 3 ? tint[9 + column] : 0 + for(var/k in 0 to 2) + sum += first[row * 3 + k + 1] * tint[k * 3 + column] + out += sum + return out + +/** + * A hit flash (`flash`, a colour matrix held for `times`: in, hold, out) on a `body` the slow-mo tints. The client + * drew a tinted body in full colour while a second colour filter was animated on it, so the flash plays through the + * tint filter itself: tint over the flash, held, then back to the plain tint. FALSE if the body isn't tinted. + */ +/proc/cyberware_time_flash(atom/body, list/flash, list/times) + var/tint_filter = body.filter_data?[SANDY_STOP_TINT] && body.get_filter(SANDY_STOP_TINT) + if(!tint_filter) + return FALSE + var/list/flashed = cyberware_time_tint_after(flash) + animate(tint_filter, color = flashed, time = times[1], easing = LINEAR_EASING) + animate(color = flashed, time = times[2], easing = LINEAR_EASING) + animate(color = cyberware_time_stop_tint(), time = times[3], easing = LINEAR_EASING) + return TRUE + +/// Takes the slow-mo's tint off `thing`. +/proc/cyberware_time_tint_off(atom/thing) + if(cyberware_time_tints_by_filter(thing)) + thing.remove_filter(SANDY_STOP_TINT) + REMOVE_KEEP_TOGETHER(thing, SANDY_STOP_TINT) + return + thing.remove_atom_colour(TEMPORARY_COLOUR_PRIORITY) + if(istype(thing, /obj/effect/decal/cleanable/blood/gibs)) + thing.update_appearance(UPDATE_OVERLAYS) + +/// Whether `thing` is drawn with the slow-mo's tint right now. +/proc/cyberware_time_has_tint(atom/thing) + if(cyberware_time_tints_by_filter(thing)) + return !!thing.filter_data?[SANDY_STOP_TINT] + return LAZYACCESS(thing.atom_colours, TEMPORARY_COLOUR_PRIORITY) && compare_list(thing.atom_colours[TEMPORARY_COLOUR_PRIORITY][ATOM_COLOR_VALUE_INDEX], cyberware_time_stop_tint()) + /// Blood and loose gore the field tints like the world around it (it is only looked at, never held): nothing about /// gore is in colour in the slow-mo. Severed parts and spilled intestines are items, which otherwise keep their colour /// on the floor. @@ -289,20 +370,36 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) /// colour lost, so while tinted it wears the tint mixed with its own colour, held where it was. It works its colour /// out again when the tint comes off (update_atom_colour() calls this). A colour filter didn't draw on decals. /obj/effect/decal/cleanable/blood/update_blood_color() - if(!islist(color) || !LAZYACCESS(atom_colours, TEMPORARY_COLOUR_PRIORITY) || !GLOB.cyberware_time_dilated[src]) + if(!sandy_time_tinted()) return ..() animate(src) var/own = sandy_replay_colour() // ware_sandy_replay.dm if(!istext(own)) return TRUE - var/list/rgb = rgb2num(own) - var/list/mixed = cyberware_time_stop_tint().Copy() - for(var/row in 0 to 2) - for(var/column in 1 to 3) - mixed[row * 3 + column] *= rgb[row + 1] / 255 - color = mixed + color = cyberware_time_tint_of(own) return TRUE +/// Whether the slow-mo's tint is on this blood right now. +/obj/effect/decal/cleanable/blood/proc/sandy_time_tinted() + return islist(color) && LAZYACCESS(atom_colours, TEMPORARY_COLOUR_PRIORITY) && GLOB.cyberware_time_dilated[src] + +/// tg's create_splatter() sets the splash's colour after making it, over the slow-mo's tint: the tint goes back on. +/mob/living/create_splatter(splatter_dir = pick(GLOB.cardinals)) + . = ..() + for(var/obj/effect/temp_visual/dir_setting/bloodsplatter/splatter in get_turf(src)) + if(GLOB.cyberware_time_dilated[splatter]) + splatter.update_atom_colour() + +/// A gib's meat and bone are a KEEP_APART, RESET_COLOR overlay that the tint in `color` skips: tinted, it wears the +/// wash over its own colour too, and gets its colour back when the tint comes off (cyberware_time_tint_off()). +/obj/effect/decal/cleanable/blood/gibs/update_overlays() + . = ..() + if(!sandy_time_tinted()) + return + for(var/mutable_appearance/bit in .) + if(bit.appearance_flags & RESET_COLOR) + bit.color = cyberware_time_tint_of(istext(bit.color) ? bit.color : COLOR_WHITE) + /// Flattens a sprite to luminance and paints it the dilation's green (#5AFF96). /proc/cyberware_time_stop_green() var/static/list/green @@ -445,6 +542,8 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/ring_reach = 0 /// The recording of this slow-mo, played back when it ends (ware_sandy_replay.dm). Only a player's field has one. var/datum/sandy_replay/replay + /// Tinted blood decals and gut strands, checked each tick for drips, trail pieces or strand pictures new in their vis_contents. + var/list/blood_holders = list() /datum/proximity_monitor/advanced/timestop/sandevistan/New(atom/_host, range, _ignore_if_not_on_turf = TRUE, list/immune, antimagic_flags, channelled, _time_scale = 1, _user_multiplier = 1) time_scale = _time_scale > 0 ? _time_scale : 1 @@ -558,6 +657,8 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) RegisterSignal(A, COMSIG_QDELETING, PROC_REF(held_gone), override = TRUE) if(tint) into_the_negative_zone(A) + if(istype(A, /obj/effect/decal/cleanable/blood) || istype(A, /obj/effect/ebeam)) + tint_blood_bits(A) return TRUE /datum/proximity_monitor/advanced/timestop/sandevistan/setup_field_turf(turf/target) @@ -584,6 +685,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) held_turrets -= A held_grenades -= A frozen_structures -= A + blood_holders -= A user_shots -= A slowed_tags -= A own_scales -= A @@ -806,6 +908,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) tint_timers -= thing own_scales -= thing frozen_structures -= thing + blood_holders -= thing frozen_things -= thing GLOB.cyberware_time_dilated -= thing @@ -841,6 +944,8 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) continue unfreeze_atom(boss) released[boss] = TRUE + for(var/obj/effect/holder as anything in blood_holders) + tint_blood_bits(holder) senses?.refresh_weather() /// Whether `thing` is what the user is riding or piloting: a vehicle with them aboard, or anything they're buckled to. @@ -871,14 +976,40 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) if(!tint_timers[A]) tint_timers[A] = addtimer(CALLBACK(src, PROC_REF(ring_reached), A), wait, TIMER_STOPPABLE) return - A.add_atom_colour(cyberware_time_stop_tint(), TEMPORARY_COLOUR_PRIORITY) + cyberware_time_tint_on(A) /datum/proximity_monitor/advanced/timestop/sandevistan/escape_the_negative_zone(atom/A) if(tint_timers[A]) deltimer(tint_timers[A]) tint_timers -= A + if(!tinted[A]) + return tinted -= A - return ..() + cyberware_time_tint_off(A) + +/// Drips and trail pieces are blood drawn in a blood decal's vis_contents with RESET_COLOR, and a gut strand (a beam) +/// draws its picture from one shared object in each segment's vis_contents: the holder's tint never reaches them. Each +/// is tinted on its own like the holder, and let go with the field. Blood that drips onto a tinted decal later is +/// picked up by tick(). +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/tint_blood_bits(obj/effect/holder) + blood_holders[holder] = TRUE // a new drip or trail has no bits yet: they come as more blood lands on it + if(!length(holder.vis_contents)) + return + for(var/obj/effect/bit in holder.vis_contents) + if(!istype(bit, /obj/effect/decal/cleanable/blood) && !istype(bit, /obj/effect/ebeam)) + continue + if(frozen_things[bit] || QDELETED(bit)) + continue + GLOB.cyberware_time_dilated[bit] = src + frozen_things[bit] = TRUE + frozen_structures += bit + RegisterSignal(bit, COMSIG_QDELETING, PROC_REF(held_gone), override = TRUE) + tinted[bit] = TRUE // not recorded: the replay draws the decal, not what is in its vis_contents + var/wait = ring_wait(bit) + if(wait > 0) + tint_timers[bit] = addtimer(CALLBACK(src, PROC_REF(ring_reached), bit), wait, TIMER_STOPPABLE) + else + cyberware_time_tint_on(bit) /// The ring starts rolling out from `center` to `reach` tiles over `time`: everything it hasn't reached yet loses /// its tint until the ring's front passes it. @@ -893,7 +1024,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) var/wait = ring_wait(thing) if(wait <= 0) continue - thing.remove_atom_colour(TEMPORARY_COLOUR_PRIORITY) + cyberware_time_tint_off(thing) tint_timers[thing] = addtimer(CALLBACK(src, PROC_REF(ring_reached), thing), wait, TIMER_STOPPABLE) /// How long until the rolling ring's front reaches `thing`, or 0 if it already has. The ring grows with a sine @@ -913,7 +1044,7 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) tint_timers -= thing if(QDELETED(thing) || !tinted[thing]) return - thing.add_atom_colour(cyberware_time_stop_tint(), TEMPORARY_COLOUR_PRIORITY) + cyberware_time_tint_on(thing) // ========================================================================= // THE WINDOW'S SIDE @@ -1059,3 +1190,4 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) #undef SANDY_STOP_SAME_RATE #undef SANDY_STOP_OUTLINE #undef SANDY_STOP_FLASH +#undef SANDY_STOP_TINT diff --git a/voidcrew/modules/hyperviolence/visuals/_motion_core.dm b/voidcrew/modules/hyperviolence/visuals/_motion_core.dm index a597b647805..eba6f5a1c08 100644 --- a/voidcrew/modules/hyperviolence/visuals/_motion_core.dm +++ b/voidcrew/modules/hyperviolence/visuals/_motion_core.dm @@ -300,6 +300,8 @@ /// A pale flash over the body for the frames of a hit-stop. A colour filter, gone again half a second later. /mob/living/proc/hv_vis_flash() + if(cyberware_time_flash(src, list(0.6,0,0, 0,0.6,0, 0,0,0.6, 0.4,0.4,0.4), cyberware_dilated_steps(src, list(0.5, HV_VIS_HITSTOP_TIME, 1)) || list(0.5, HV_VIS_HITSTOP_TIME, 1))) + return // in a Sandevistan slow-mo the flash plays through the slow-mo's tint filter (ware_sandy_timestop.dm) if(!get_filter(HV_VIS_FLASH_FILTER)) add_filter(HV_VIS_FLASH_FILTER, 10, color_matrix_filter(list(1,0,0, 0,1,0, 0,0,1, 0,0,0))) var/flash = get_filter(HV_VIS_FLASH_FILTER) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm index 08c5e7c3c08..0062b4f327f 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -287,6 +287,12 @@ var/obj/vehicle/sealed/mecha/ripley/mech = allocate(/obj/vehicle/sealed/mecha/ripley, spot(3, 2)) var/obj/machinery/porta_turret/turret = allocate(/obj/machinery/porta_turret, spot(3, 1)) var/obj/effect/decal/cleanable/blood/pool = allocate(/obj/effect/decal/cleanable/blood, spot(2, 1)) + // Drips drawn in another drip's vis_contents with RESET_COLOR, as tg's make_blood_splatter() adds them + var/obj/effect/decal/cleanable/blood/drip/drips = allocate(/obj/effect/decal/cleanable/blood/drip, spot(1, 3)) + var/obj/effect/decal/cleanable/blood/drip/drop = allocate(/obj/effect/decal/cleanable/blood/drip, drips) + drop.appearance_flags |= RESET_COLOR + drips.vis_contents += drop + var/obj/effect/decal/cleanable/blood/gibs/gibs = allocate(/obj/effect/decal/cleanable/blood/gibs, spot(2, 3)) var/obj/item/grenade/bomb = allocate(/obj/item/grenade, spot(1, 1)) bomb.arm_grenade(null, 3 SECONDS, msg = FALSE) @@ -330,6 +336,12 @@ var/move_at = mech.cooldown_vehicle_move sleep(0.5 SECONDS) + // Blood and gore take the world's tint, including what a `color` tint can't reach (RESET_COLOR overlays and vis_contents) + TEST_ASSERT(tinted(raider), "A slowed NPC wasn't tinted") + TEST_ASSERT(raider.filter_data?["sandy_time_stop_tint"], "A slowed NPC's tint doesn't cover its wound and blood overlays") + TEST_ASSERT(tinted(drop), "A drip drawn in another drip's vis_contents kept its colour in the slow-mo") + TEST_ASSERT(islist(drop.color), "A drip in the slow-mo isn't drawn with the tint") + TEST_ASSERT(gib_bits_tinted(gibs), "A gib's meat overlay kept its colour in the slow-mo") // 0.5 s at 20% is 1 decisecond of the NPC's own time: a 2 decisecond gap is still waiting, at full speed it's long gone TEST_ASSERT(cyberware_burst_wait(raider, raider_gun.cyberware_burst_start, 2, 1) > 0, "A queued burst shot fired at full speed") TEST_ASSERT(raider_gun.cyberware_burst_start > gun_start, "The NPC's burst clock wasn't pushed back") @@ -344,6 +356,9 @@ TEST_ASSERT_EQUAL(cyberware_time_lost(raider, 10), 0, "The NPC's clock stayed slow after the dilation") TEST_ASSERT_NULL(cyberware_time_field(pool), "Blood stayed slowed after the dilation") TEST_ASSERT(istext(pool.color), "Blood kept the slow-mo's tint, or lost its own colour, after the dilation") + TEST_ASSERT(!tinted(raider), "An NPC kept the slow-mo's tint after the dilation") + TEST_ASSERT(!tinted(drop) && istext(drop.color), "A drip kept the slow-mo's tint, or lost its own colour, after the dilation") + TEST_ASSERT(!gib_bits_tinted(gibs), "A gib's meat overlay kept the slow-mo's tint after the dilation") TEST_ASSERT(!(bomb.dud_flags & GRENADE_USED), "A grenade's fuse didn't lose its slowed time") TEST_ASSERT(bomb.cyberware_fuse_at > world.time, "A grenade's fuse has nothing left after losing time") bomb.cyberware_fuse_at = 0 // don't let it go off after the test @@ -351,7 +366,14 @@ /// Whether `thing` wears the dilation's tint /datum/unit_test/voidcrew_cyberware_timestop/proc/tinted(atom/thing) - return length(thing.atom_colours) >= TEMPORARY_COLOUR_PRIORITY && !isnull(thing.atom_colours[TEMPORARY_COLOUR_PRIORITY]) + return cyberware_time_has_tint(thing) + +/// Whether one of `gibs`' RESET_COLOR overlays (its meat and bone) is drawn with a colour matrix +/datum/unit_test/voidcrew_cyberware_timestop/proc/gib_bits_tinted(obj/effect/decal/cleanable/blood/gibs/gibs) + for(var/mutable_appearance/bit as anything in gibs.overlays) + if((bit.appearance_flags & RESET_COLOR) && islist(bit.color)) + return TRUE + return FALSE /// A raider NPC at `where` with its own Cascade lattice (or `lattice_type`) /datum/unit_test/voidcrew_cyberware_timestop/proc/make_surger(turf/where, lattice_type = /obj/item/organ/cyberimp/cyberware/cascade) From 5b27ac3f9ae83ba8a109d27290a079a8cc0606ef Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:26:55 -0800 Subject: [PATCH 29/33] Sandevistan tests: a dead user's slow-mo ends at death; gib tint check looks at the meat overlay --- voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm | 8 +++----- .../modules/unit_tests/voidcrew_cyberware_timestop.dm | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm index 57508b321e2..e13ba4fc8f9 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm @@ -263,11 +263,9 @@ sleep(0.5 SECONDS) user.Move(spot(0, 1), NORTH) sleep(0.5 SECONDS) - user.death() - sleep(0.3 SECONDS) - if(user.has_status_effect(/datum/status_effect/cyberware_cascade_window)) - press_again(user) - TEST_ASSERT_EQUAL(replay.state, 2, "The slow-mo of a user who died didn't queue its replay") + user.death() // ends the slow-mo there and then; the replay starts a tick later + TEST_ASSERT_EQUAL(replay.state, 2, "The slow-mo of a user who died didn't end at death with its replay queued") + deltimer(replay.begin_timer) var/real = user.appearance replay.begin() TEST_ASSERT_EQUAL(replay.state, 3, "The replay of a user who died didn't start") diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm index 0062b4f327f..cb377fa6e5b 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -368,10 +368,10 @@ /datum/unit_test/voidcrew_cyberware_timestop/proc/tinted(atom/thing) return cyberware_time_has_tint(thing) -/// Whether one of `gibs`' RESET_COLOR overlays (its meat and bone) is drawn with a colour matrix +/// Whether `gibs`' meat and bone overlay is drawn with a colour matrix /datum/unit_test/voidcrew_cyberware_timestop/proc/gib_bits_tinted(obj/effect/decal/cleanable/blood/gibs/gibs) for(var/mutable_appearance/bit as anything in gibs.overlays) - if((bit.appearance_flags & RESET_COLOR) && islist(bit.color)) + if(bit.icon_state == "[gibs.icon_state]-overlay" && islist(bit.color)) return TRUE return FALSE From b556106c72dd104f12313f94d618d0d7992dcd54 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:51:49 -0800 Subject: [PATCH 30/33] Sandevistan: gut strand ends keep the slow-mo tint, gib meat replays untinted tg's beam Draw() colours a strand's cut-short last segment after the field has tinted it, so a gut strand's end showed in full colour during the slow-mo. The tint goes back on after each draw. The replay recorded gibs with their tinted meat overlay; it now records the untinted overlay. Tests cover both. --- .../modules/cyberware/ware_sandy_replay.dm | 3 ++ .../modules/cyberware/ware_sandy_timestop.dm | 33 ++++++++++++++++++- .../unit_tests/voidcrew_cyberware_timestop.dm | 23 ++++++++++++- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_sandy_replay.dm b/voidcrew/modules/cyberware/ware_sandy_replay.dm index f54016c6697..9f0c2b76091 100644 --- a/voidcrew/modules/cyberware/ware_sandy_replay.dm +++ b/voidcrew/modules/cyberware/ware_sandy_replay.dm @@ -349,6 +349,9 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) if(istype(thing, /obj/effect/decal/cleanable/blood)) // blood's colour is its own, worked out as it dries var/obj/effect/decal/cleanable/blood/blood = thing copy.color = blood.sandy_replay_colour() + if(istype(blood, /obj/effect/decal/cleanable/blood/gibs)) // its meat overlay carries the tint too + var/obj/effect/decal/cleanable/blood/gibs/gibs = blood + gibs.sandy_untinted_overlays(copy) else copy.color = untinted_colour(thing) copy.appearance_flags |= KEEP_TOGETHER // update_atom_colour() leaves this on once anything was ever coloured diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm index 617ab41da50..43b01fa6daf 100644 --- a/voidcrew/modules/cyberware/ware_sandy_timestop.dm +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -390,16 +390,47 @@ GLOBAL_LIST_EMPTY(cyberware_time_fields) if(GLOB.cyberware_time_dilated[splatter]) splatter.update_atom_colour() +/// tg's Draw() gives a beam's last, cut-short segment the beam's colour right after making it, over the tint the +/// field put on it as it was made (a gut strand's end showed raw in the slow-mo): the tint goes back on, and a +/// strand drawn in the slow-mo has its picture in vis_contents tinted at once rather than on the next field tick. +/datum/beam/Draw() + . = ..() + for(var/obj/effect/ebeam/segment as anything in elements) + if(QDELETED(segment) || !cyberware_time_has_tint(segment)) + continue + if(istext(segment.color) && !LAZYACCESS(segment.atom_colours, FIXED_COLOUR_PRIORITY)) + segment.add_atom_colour(segment.color, FIXED_COLOUR_PRIORITY) // its own colour again once the tint comes off + segment.update_atom_colour() + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = GLOB.cyberware_time_dilated[segment] + if(istype(field)) + field.tint_blood_bits(segment) + +/obj/effect/decal/cleanable/blood/gibs + /// Set while sandy_untinted_overlays() builds the overlays as they are without the slow-mo's tint. + var/sandy_skip_tint = FALSE + /// A gib's meat and bone are a KEEP_APART, RESET_COLOR overlay that the tint in `color` skips: tinted, it wears the /// wash over its own colour too, and gets its colour back when the tint comes off (cyberware_time_tint_off()). /obj/effect/decal/cleanable/blood/gibs/update_overlays() . = ..() - if(!sandy_time_tinted()) + if(sandy_skip_tint || !sandy_time_tinted()) return for(var/mutable_appearance/bit in .) if(bit.appearance_flags & RESET_COLOR) bit.color = cyberware_time_tint_of(istext(bit.color) ? bit.color : COLOR_WHITE) +/// Swaps the tinted meat overlay in `look` (a copy of this gib's appearance) for the untinted one, so the replay +/// shows the meat in its own colour (ware_sandy_replay.dm, clean_look()). +/obj/effect/decal/cleanable/blood/gibs/proc/sandy_untinted_overlays(mutable_appearance/look) + if(managed_overlays) + look.overlays -= managed_overlays + sandy_skip_tint = TRUE + var/list/plain = update_overlays() + sandy_skip_tint = FALSE + for(var/overlay in plain) + if(overlay) + look.overlays += overlay + /// Flattens a sprite to luminance and paints it the dilation's green (#5AFF96). /proc/cyberware_time_stop_green() var/static/list/green diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm index cb377fa6e5b..06e8baa3b74 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -334,6 +334,9 @@ var/fired_at = turret.last_fired mech.cooldown_vehicle_move = world.time + 5 var/move_at = mech.cooldown_vehicle_move + // A gut strand drawn in the slow-mo: one tile across and one up is 45 px, so its last segment is cut short and + // drawn with its own icon, which tg's Draw() colours after the field has tinted it + var/datum/beam/strand = raider.Beam(turret, "gut_beam", 'voidcrew/modules/hyperviolence/braingut/icons/gore_guts.dmi', INFINITY, 10, /obj/effect/ebeam/hv_gut, emissive = FALSE) sleep(0.5 SECONDS) // Blood and gore take the world's tint, including what a `color` tint can't reach (RESET_COLOR overlays and vis_contents) @@ -342,6 +345,9 @@ TEST_ASSERT(tinted(drop), "A drip drawn in another drip's vis_contents kept its colour in the slow-mo") TEST_ASSERT(islist(drop.color), "A drip in the slow-mo isn't drawn with the tint") TEST_ASSERT(gib_bits_tinted(gibs), "A gib's meat overlay kept its colour in the slow-mo") + TEST_ASSERT(length(strand.elements) == 2, "Test fixture: the strand isn't two segments ([length(strand.elements)])") + for(var/obj/effect/ebeam/segment as anything in strand.elements) + TEST_ASSERT(islist(segment.color), "A gut strand segment drawn in the slow-mo kept its colour ([segment.color], own icon: [isicon(segment.icon)])") // 0.5 s at 20% is 1 decisecond of the NPC's own time: a 2 decisecond gap is still waiting, at full speed it's long gone TEST_ASSERT(cyberware_burst_wait(raider, raider_gun.cyberware_burst_start, 2, 1) > 0, "A queued burst shot fired at full speed") TEST_ASSERT(raider_gun.cyberware_burst_start > gun_start, "The NPC's burst clock wasn't pushed back") @@ -358,7 +364,14 @@ TEST_ASSERT(istext(pool.color), "Blood kept the slow-mo's tint, or lost its own colour, after the dilation") TEST_ASSERT(!tinted(raider), "An NPC kept the slow-mo's tint after the dilation") TEST_ASSERT(!tinted(drop) && istext(drop.color), "A drip kept the slow-mo's tint, or lost its own colour, after the dilation") - TEST_ASSERT(!gib_bits_tinted(gibs), "A gib's meat overlay kept the slow-mo's tint after the dilation") + // The replay is playing now: the gib shows its recorded look, which must be untinted too + TEST_ASSERT(GLOB.sandy_replays[user], "Test fixture: no replay after the slow-mo") + TEST_ASSERT(!gib_bits_tinted(gibs), "A gib's meat overlay kept the slow-mo's tint in the replay ([gib_diag(gibs)])") + for(var/obj/effect/ebeam/segment as anything in strand.elements) + TEST_ASSERT(!islist(segment.color), "A gut strand segment kept the slow-mo's tint after the dilation") + wait_replay(user) + TEST_ASSERT(!gib_bits_tinted(gibs), "A gib's meat overlay kept the slow-mo's tint after the replay ([gib_diag(gibs)])") + qdel(strand) TEST_ASSERT(!(bomb.dud_flags & GRENADE_USED), "A grenade's fuse didn't lose its slowed time") TEST_ASSERT(bomb.cyberware_fuse_at > world.time, "A grenade's fuse has nothing left after losing time") bomb.cyberware_fuse_at = 0 // don't let it go off after the test @@ -375,6 +388,14 @@ return TRUE return FALSE +/// What `gibs` is drawn with, for a failure message +/datum/unit_test/voidcrew_cyberware_timestop/proc/gib_diag(obj/effect/decal/cleanable/blood/gibs/gibs) + var/list/meat = list() + for(var/mutable_appearance/bit as anything in gibs.overlays) + if(bit.icon_state == "[gibs.icon_state]-overlay") + meat += json_encode(bit.color) + return "meat [meat.Join(" ")], color [json_encode(gibs.color)], atom_colours [json_encode(gibs.atom_colours)], dilated [!!GLOB.cyberware_time_dilated[gibs]], puppet [!!GLOB.sandy_replay_masked[gibs]]" + /// A raider NPC at `where` with its own Cascade lattice (or `lattice_type`) /datum/unit_test/voidcrew_cyberware_timestop/proc/make_surger(turf/where, lattice_type = /obj/item/organ/cyberimp/cyberware/cascade) var/mob/living/carbon/human/npc/raider = allocate(/mob/living/carbon/human/npc, where) From 5c1917a22b009f9bbb0aff4c171b0a3bd38fe6f2 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:55:59 -0800 Subject: [PATCH 31/33] Sandevistan replay: the user's crit and damage screens come off for the replay; a knockdown that ends mid-replay gets up after it --- .../modules/cyberware/ware_sandy_replay.dm | 57 ++++++++++++++- .../unit_tests/voidcrew_cyberware_replay.dm | 69 +++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/voidcrew/modules/cyberware/ware_sandy_replay.dm b/voidcrew/modules/cyberware/ware_sandy_replay.dm index 9f0c2b76091..721b96b8702 100644 --- a/voidcrew/modules/cyberware/ware_sandy_replay.dm +++ b/voidcrew/modules/cyberware/ware_sandy_replay.dm @@ -24,7 +24,8 @@ * For the length of the replay AIs are paused and the user and their crewmates can't act. Life() keeps running: * this is a lock on decisions and input only, no stun and no damage. A player who isn't crew coming near ends the * replay at once, a watchdog timer ends it whatever happens, and finish() can be called any number of times. - * The surge's crash starts when the replay ends. + * The surge's crash starts when the replay ends. A user who went down in the slow-mo still watches it: their crit, + * oxygen, damage and knocked-out screens are off their view while it plays and back as it ends (clear_view()). * * It looks and sounds like a surge: the surge-on sound plays at the start, the sounds made in the field play again * at normal pitch (playsound() hands them over), and every surger in it (the user, or a mob on its own surge) leaves @@ -191,6 +192,8 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) /// The user's client pixel offsets before the replay. var/eye_pixel_x = 0 var/eye_pixel_y = 0 + /// The user's crit and knocked-out screens were taken off their view for the replay (clear_view()). + var/view_cleared = FALSE /// The crash waiting for the replay to end: list(length, stamina). var/list/crash var/finished = FALSE @@ -520,6 +523,49 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) eye_pixel_x = user.client.pixel_x eye_pixel_y = user.client.pixel_y user.reset_perspective(eye) + clear_view() + +/// Fullscreens that follow the user's real health (update_damage_hud()): crit, crit vision, oxygen and the brute and +/// burn vignette. With the blindness of being knocked out (only when nothing else blinds them) they'd paint the +/// user's present state over a replay of the moments before it. +#define SANDY_REPLAY_CRIT_SCREENS list("crit", "critvision", "oxy", "brute") + +/** + * A user who went down in the slow-mo watches their replay like anyone else: their health and knocked-out screens + * come off their client's view (not off the mob, so tg keeps updating them) and go back in finish(). Called at the + * start and every frame, as Life() can put one back. + */ +/datum/sandy_replay/proc/clear_view() + var/client/viewer = user?.client + if(!eye || !viewer) + return + for(var/category in SANDY_REPLAY_CRIT_SCREENS) + var/atom/movable/screen/fullscreen/screen = user.screens[category] + if(screen && (screen in viewer.screen)) + viewer.screen -= screen + view_cleared = TRUE + var/datum/status_effect/grouped/blindness/blind = user.has_status_effect(/datum/status_effect/grouped/blindness) + if(!blind || length(blind.sources) != 1 || !(UNCONSCIOUS_TRAIT in blind.sources)) + return + var/atom/movable/screen/fullscreen/dark = user.screens[blind.id] + if(dark && (dark in viewer.screen)) + viewer.screen -= dark + view_cleared = TRUE + if(user.remove_client_colour(REF(blind))) + view_cleared = TRUE + +/// Puts back what clear_view() took off, as the user's real state now has it. +/datum/sandy_replay/proc/restore_view() + if(!view_cleared || QDELETED(user)) + return + view_cleared = FALSE + // Worked out again from the user's state now (Life() ran on through the replay), then shown. + user.update_damage_hud() + var/datum/status_effect/grouped/blindness/blind = user.has_status_effect(/datum/status_effect/grouped/blindness) + blind?.update_blindness() + user.reload_fullscreen() + +#undef SANDY_REPLAY_CRIT_SCREENS /// Holds every mob and mech near the user, in the bubble or not: a crewmate or an NPC just outside it can't act on /// the held ones (or on the user) while the replay plays. Called at the start and every frame, for arrivals. @@ -560,6 +606,10 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) if(QDELETED(body)) return body.remove_traits(list(TRAIT_IMMOBILIZED, TRAIT_INCAPACITATED, TRAIT_AI_PAUSED), SANDY_REPLAY_TRAIT) + // A knockdown or crit that ended mid-replay tried to stand them up then (get_up()'s do_after), and the lock's + // TRAIT_INCAPACITATED failed it: nothing would try again, so they'd lie there. tg's own end-of-floored step. + if(body.stat != DEAD && body.body_position == LYING_DOWN && !HAS_TRAIT(body, TRAIT_FLOORED)) + body.on_floored_end() if(isanimal(body) && isnum(was)) var/mob/living/simple_animal/animal = body if(animal.AIStatus == AI_OFF) @@ -869,6 +919,7 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) return frame = min(frame, total) hold_nearby() + clear_view() play_sounds(frame) for(var/datum/sandy_replay_track/track as anything in puppets) var/atom/movable/thing = track.thing @@ -976,6 +1027,10 @@ GLOBAL_VAR_INIT(sandy_replay_echoing, FALSE) animate(viewer, pixel_x = eye_pixel_x, pixel_y = eye_pixel_y, time = 0, flags = ANIMATION_END_NOW) if(viewer.eye == eye) user.reset_perspective() + try + restore_view() + catch(var/exception/error) + LAZYADD(problems, "[error] ([error.file]:[error.line])") QDEL_NULL(eye) GLOB.sandy_replay_last = list("played" = was_playing, "frames" = total, "puppets" = length(puppets), "standins" = length(standins), "unhooked" = unhooked, "folded" = folded.Copy(), "unmasks" = unmasks, "afterimages" = afterimages, "sounds" = length(sounds), "sounds_played" = sound_at - 1) if(crash && !QDELETED(user) && user.stat != DEAD && cyberware_bearer_lattice(user)) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm index e13ba4fc8f9..42552b21d3d 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm @@ -276,3 +276,72 @@ wait_over(user) TEST_ASSERT(user.appearance == real, "The dead user's body doesn't look as it really ended") TEST_ASSERT(!user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "A dead user crashed") + +/// A user who goes into crit in the window is replayed upright from their first tile and goes down at the frame they +/// went down, and after the replay is exactly as they really are: still down, still in crit, not held +/datum/unit_test/voidcrew_cyberware_timestop/replay/user_crit + +/datum/unit_test/voidcrew_cyberware_timestop/replay/user_crit/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandyreplaycrit") + set_window(user, 3 SECONDS) + var/datum/sandy_replay/replay = start_recording(user) + sleep(0.5 SECONDS) + user.Move(spot(0, 1), NORTH) + sleep(0.5 SECONDS) + user.apply_damage(user.health - user.crit_threshold + 10, BRUTE, BODY_ZONE_CHEST) + TEST_ASSERT_EQUAL(user.stat, SOFT_CRIT, "Test fixture: the user should be in soft crit") + TEST_ASSERT_EQUAL(user.body_position, LYING_DOWN, "Test fixture: a user in crit should be lying down") + sleep(0.3 SECONDS) + TEST_ASSERT_EQUAL(replay.state, 1, "Crit ended the slow-mo") + press_again(user) + deltimer(replay.begin_timer) + var/real = user.appearance + var/real_angle = user.lying_angle + var/matrix/real_turn = matrix(user.transform) + TEST_ASSERT(!(real_turn ~= matrix()), "Test fixture: a user lying in crit should be turned") + replay.begin() + TEST_ASSERT_EQUAL(replay.state, 3, "The replay of a user in crit didn't start") + TEST_ASSERT(GLOB.sandy_replay_masked[user], "The user in crit isn't replayed") + TEST_ASSERT_EQUAL(user.pixel_z, -32, "The user in crit isn't drawn back on their first tile") + TEST_ASSERT(user.transform ~= matrix(), "The user in crit isn't drawn upright at frame 0") + var/down_at = 0 + var/until = world.time + 5 SECONDS + while(GLOB.sandy_replays[user] && world.time < until) + if(!down_at && !(user.transform ~= matrix())) + down_at = world.time + sleep(world.tick_lag) + TEST_ASSERT_NULL(GLOB.sandy_replays[user], "The replay never ended") + TEST_ASSERT(down_at > replay.play_started, "The user in crit never goes down in their replay, or is down from its first frame") + TEST_ASSERT(user.appearance == real, "The user in crit doesn't look as they really are after the replay: [look_difference(user, real)]") + TEST_ASSERT(user.transform ~= real_turn, "The user in crit isn't turned as they really lie after the replay") + TEST_ASSERT_EQUAL(user.stat, SOFT_CRIT, "The replay changed the user's stat") + TEST_ASSERT_EQUAL(user.body_position, LYING_DOWN, "The user in crit stood up after the replay") + TEST_ASSERT_EQUAL(user.lying_angle, real_angle, "The user in crit's lying angle changed over the replay") + TEST_ASSERT(!held(user), "The user in crit is still held after the replay") + +/// A user knocked down in the window whose knockdown wears off during the replay gets up once it ends: the replay's +/// input lock fails tg's get-up while it plays +/datum/unit_test/voidcrew_cyberware_timestop/replay/gets_up_after + +/datum/unit_test/voidcrew_cyberware_timestop/replay/gets_up_after/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandyreplayknock") + set_window(user, 3 SECONDS) + var/obj/item/organ/cyberimp/cyberware/cascade/lattice = locate() in user.organs + lattice.crash_stamina = 0 // the crash's stamina dump would floor them again + var/datum/sandy_replay/replay = start_recording(user) + sleep(0.5 SECONDS) + user.Paralyze(20 SECONDS) + TEST_ASSERT_EQUAL(user.body_position, LYING_DOWN, "Test fixture: a paralyzed user should be lying down") + sleep(0.3 SECONDS) + press_again(user) + deltimer(replay.begin_timer) + replay.begin() + TEST_ASSERT_EQUAL(replay.state, 3, "The replay didn't start") + user.SetParalyzed(0) // wears off mid-replay + TEST_ASSERT_EQUAL(user.body_position, LYING_DOWN, "Test fixture: the replay's lock should keep the user from getting up while it plays") + wait_over(user) + var/until = world.time + 3 SECONDS + while(user.body_position == LYING_DOWN && world.time < until) + sleep(world.tick_lag) + TEST_ASSERT_EQUAL(user.body_position, STANDING_UP, "A user whose knockdown wore off during the replay never got up after it") + TEST_ASSERT_EQUAL(user.lying_angle, 0, "The user got up but is still turned") From bbd727919f5118aa7e9346b1ffb15ff4344ec94a Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Thu, 12 Nov 2026 10:00:22 -0800 Subject: [PATCH 32/33] Sandevistan replay crit test: compare the user's look without the crash's node looks, which land after the replay --- voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm index 42552b21d3d..928484fff6d 100644 --- a/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm @@ -312,7 +312,11 @@ sleep(world.tick_lag) TEST_ASSERT_NULL(GLOB.sandy_replays[user], "The replay never ended") TEST_ASSERT(down_at > replay.play_started, "The user in crit never goes down in their replay, or is down from its first frame") - TEST_ASSERT(user.appearance == real, "The user in crit doesn't look as they really are after the replay: [look_difference(user, real)]") + // the crash lands when the replay ends and plays its node looks, so the whole appearance can't match; compare the + // parts the replay drew over + TEST_ASSERT(user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "Test fixture: the crash should land on a user in crit") + var/difference = look_difference(user, real) + TEST_ASSERT(difference == "nothing in the vars compared", "The user in crit doesn't look as they really are after the replay: [difference]") TEST_ASSERT(user.transform ~= real_turn, "The user in crit isn't turned as they really lie after the replay") TEST_ASSERT_EQUAL(user.stat, SOFT_CRIT, "The replay changed the user's stat") TEST_ASSERT_EQUAL(user.body_position, LYING_DOWN, "The user in crit stood up after the replay") From e16be1aad203beabddb5f48dd42ca73b81f17f59 Mon Sep 17 00:00:00 2001 From: Jackriip <97582790+Jackriip@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:03:49 -0800 Subject: [PATCH 33/33] Cyberware: the Cascade and Redline crashes no longer tint the screen --- voidcrew/modules/cyberware/ware_legend.dm | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/voidcrew/modules/cyberware/ware_legend.dm b/voidcrew/modules/cyberware/ware_legend.dm index 80a8365200a..ed1afefea1b 100644 --- a/voidcrew/modules/cyberware/ware_legend.dm +++ b/voidcrew/modules/cyberware/ware_legend.dm @@ -357,7 +357,6 @@ /datum/status_effect/cyberware_cascade_crash/on_apply() owner.add_movespeed_modifier(/datum/movespeed_modifier/cyberware_cascade_crash) owner.adjustStaminaLoss(crash_stamina) - owner.add_client_colour(/datum/client_colour/cyberware_cascade_crash, id) playsound(owner, 'sound/machines/warning-buzzer.ogg', 50, TRUE) owner.visible_message( span_warning("[owner] staggers, every movement suddenly leaden."), @@ -369,7 +368,6 @@ /datum/status_effect/cyberware_cascade_crash/on_remove() owner.remove_movespeed_modifier(/datum/movespeed_modifier/cyberware_cascade_crash) - owner.remove_client_colour(id) if(!QDELETED(owner) && owner.stat != DEAD) owner.balloon_alert(owner, "systems restabilized") if(!QDELETED(owner)) @@ -389,13 +387,6 @@ fade_in = 3 fade_out = 3 -/// The crash's screen: drained and gray-brown. -/datum/client_colour/cyberware_cascade_crash - priority = CLIENT_COLOR_ORGAN_PRIORITY - color = list(0.55, 0.3, 0.25, 0.35, 0.55, 0.25, 0.25, 0.25, 0.5) - fade_in = 3 - fade_out = 5 - // ========================================================================= // REDLINE CORE // ========================================================================= @@ -835,7 +826,6 @@ /datum/status_effect/cyberware_redline_crash/on_apply() owner.add_movespeed_modifier(/datum/movespeed_modifier/cyberware_redline_crash) owner.adjustStaminaLoss(stamina_dump) - owner.add_client_colour(/datum/client_colour/cyberware_redline_crash, id) owner.visible_message( span_warning("[owner] sags as the red drains out of [owner.p_their()] eyes."), span_userdanger("The core slams shut and your legs go out from under you."), @@ -847,7 +837,6 @@ /datum/status_effect/cyberware_redline_crash/on_remove() owner.remove_movespeed_modifier(/datum/movespeed_modifier/cyberware_redline_crash) - owner.remove_client_colour(id) if(!QDELETED(owner) && owner.stat != DEAD) owner.balloon_alert(owner, "core recovered") if(!QDELETED(owner)) @@ -864,13 +853,6 @@ fade_in = 2 fade_out = 10 -/// The crash's screen: burnt out, brown-gray. -/datum/client_colour/cyberware_redline_crash - priority = CLIENT_COLOR_ORGAN_PRIORITY - color = list(0.6, 0.35, 0.3, 0.3, 0.5, 0.25, 0.25, 0.25, 0.45) - fade_in = 3 - fade_out = 5 - // ========================================================================= // GOVERNOR DELETE // =========================================================================