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/game/sound/sound.dm b/code/game/sound/sound.dm index 60452addb21..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() @@ -200,6 +204,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/code/modules/projectiles/gun.dm b/code/modules/projectiles/gun.dm index 78941bbe241..52ff771aac3 100644 --- a/code/modules/projectiles/gun.dm +++ b/code/modules/projectiles/gun.dm @@ -448,6 +448,12 @@ if(!user || !firing_burst) firing_burst = FALSE return FALSE + // 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)) if(iteration > 1 && !(user.is_holding(src))) //for burst firing firing_burst = FALSE @@ -517,6 +523,11 @@ 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 + 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) firing_burst = TRUE 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/tgstation.dme b/tgstation.dme index 34c6bdf341f..d430e6a22da 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7229,6 +7229,9 @@ #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" #include "voidcrew\modules\cyberware\ware_street.dm" #include "voidcrew\modules\cyberware\ware_street_passives.dm" 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/cyberware_anim.dm b/voidcrew/modules/cyberware/cyberware_anim.dm index 0284e0a0c88..bcc8afae920 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,20 @@ /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 + 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() @@ -784,9 +800,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/icons/sandy_weather.dmi b/voidcrew/modules/cyberware/icons/sandy_weather.dmi new file mode 100644 index 00000000000..4d3f4fae89a Binary files /dev/null and b/voidcrew/modules/cyberware/icons/sandy_weather.dmi differ diff --git a/voidcrew/modules/cyberware/ware_legend.dm b/voidcrew/modules/cyberware/ware_legend.dm index 58e909208b7..ed1afefea1b 100644 --- a/voidcrew/modules/cyberware/ware_legend.dm +++ b/voidcrew/modules/cyberware/ware_legend.dm @@ -90,6 +90,11 @@ var/window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade /// What the bearer reads on activation. var/online_message = "CASCADE ONLINE." + /// 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. + var/time_multiplier = 5 /obj/item/organ/cyberimp/cyberware/cascade/on_mob_remove(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -125,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 @@ -172,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) @@ -188,15 +214,16 @@ online_message = lattice.online_message crash_duration = lattice.crash_duration crash_stamina = lattice.crash_stamina + dilation_scale = lattice.dilation_scale + time_multiplier = lattice.time_multiplier return ..() /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)) + 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( @@ -205,30 +232,57 @@ ) 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() - owner.remove_movespeed_modifier(speed_type) + end_time_stop() + 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) + UnregisterSignal(owner, list(COMSIG_MOVABLE_MOVED, COMSIG_LIVING_DEATH)) 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) 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 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 @@ -240,6 +294,10 @@ steps_taken++ if(steps_taken % afterimage_interval) // cheaper chrome leaves a sparser trail return + 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 new /obj/effect/temp_visual/decoy/fading/cascade(old_loc, owner, trail_hue, afterimage_alpha) trail_hue = (trail_hue + CYBERWARE_CASCADE_HUE_STEP) % 360 @@ -299,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."), @@ -311,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)) @@ -331,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 // ========================================================================= @@ -777,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."), @@ -789,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)) @@ -806,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 // ========================================================================= diff --git a/voidcrew/modules/cyberware/ware_line_sandy.dm b/voidcrew/modules/cyberware/ware_line_sandy.dm index 303ff54f346..6065c983024 100644 --- a/voidcrew/modules/cyberware/ware_line_sandy.dm +++ b/voidcrew/modules/cyberware/ware_line_sandy.dm @@ -52,6 +52,8 @@ afterimage_alpha = 120 window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade/aftermarket online_message = "REFLEX RIG ONLINE." + dilation_scale = 0.5 + time_multiplier = 2 /datum/action/cooldown/cyberware/cascade_surge/street name = "Reflex Surge" @@ -89,6 +91,8 @@ afterimage_alpha = 160 window_alert = /atom/movable/screen/alert/status_effect/cyberware_cascade/aftermarket online_message = "LATTICE ONLINE." + 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_pro_combat.dm b/voidcrew/modules/cyberware/ware_pro_combat.dm index 9a7e77c66a0..9301652c748 100644 --- a/voidcrew/modules/cyberware/ware_pro_combat.dm +++ b/voidcrew/modules/cyberware/ware_pro_combat.dm @@ -117,6 +117,8 @@ return 0 if(world.time - last_move_time > CYBERWARE_DODGE_MOVE_WINDOW) return 0 + if(cyberware_time_stopping(source)) // the slowed world is the dodge (ware_sandy_timestop.dm) + return 0 if(source.stat != CONSCIOUS || source.body_position == LYING_DOWN || source.buckled) return 0 // Point-blank shots always land: an adjacent shooter is inside the @@ -182,7 +184,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)) diff --git a/voidcrew/modules/cyberware/ware_sandy_replay.dm b/voidcrew/modules/cyberware/ware_sandy_replay.dm new file mode 100644 index 00000000000..721b96b8702 --- /dev/null +++ b/voidcrew/modules/cyberware/ware_sandy_replay.dm @@ -0,0 +1,1251 @@ +/** + * # 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. 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 + * 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, 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. +#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 +#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) +/// 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 +/// 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 + +// 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) +/// 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 + 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 + /// 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 + 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. + 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 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 + /// 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). + 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 + 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 + // 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) + else if(track.surger && !track.trail_every) + read_trail(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 + 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) + // 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() + 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 + if(outlined || filtered) + copy.filters = null + for(var/name in thing.filter_data) + if(name == SANDY_REPLAY_OUTLINE || name == SANDY_REPLAY_TINT) + 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 + +/// `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) + 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 + 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 + +/// `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() + 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) + 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 + 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 + // 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() + +/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)) + hold_mech(thing) + 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] + 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) + 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. +/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)) + 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) + // 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) + 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/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 += name + if(!(now.color ~= drawn.color)) + real.color = now.color + changed += "color" + if(!(now.transform ~= drawn.transform)) + real.transform = now.transform + 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 += 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) + 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]) + track.turn_until = 0 + return TRUE + 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 + 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/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) + 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() + 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. 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) + 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 + var/turf/left = locate(frames[from + SANDY_FRAME_X], frames[from + SANDY_FRAME_Y], frames[from + SANDY_FRAME_Z]) + 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 % 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++ + 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() + 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) + hold_nearby() + clear_view() + play_sounds(frame) + 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) + GLOB.sandy_replay_echoing = FALSE + 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) + thing.sandy_replay_resume() + 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() + 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)) + 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("; ")]") + +/** + * `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 + +/// 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 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) + 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 + 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. +/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() + 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) + 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 + +/// 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] + 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_TINT +#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_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 +#undef SANDY_FRAME_X +#undef SANDY_FRAME_Y +#undef SANDY_FRAME_Z +#undef SANDY_FRAME_DIR diff --git a/voidcrew/modules/cyberware/ware_sandy_senses.dm b/voidcrew/modules/cyberware/ware_sandy_senses.dm new file mode 100644 index 00000000000..0c2cc49b8f8 --- /dev/null +++ b/voidcrew/modules/cyberware/ware_sandy_senses.dm @@ -0,0 +1,299 @@ +/** + * # 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). 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 copy from + * sandy_weather.dmi. There is no particle weather to slow. + */ + +#define SANDY_WEATHER_ICON 'voidcrew/modules/cyberware/icons/sandy_weather.dmi' +/// 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. `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 + +/// 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) + +/// `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.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 + /// 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) + clear_images() + field = null + user = null + return ..() + +/// The field started: bring the loops and the weather in line with its pace. +/datum/sandy_senses/proc/update() + 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. +/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` times normal, in place. +/datum/sandy_senses/proc/set_loops(speed) + if(loop_speed == speed) + return + loop_speed = speed + if(!user.client) + return + 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, 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), 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) + 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) + 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`: the nearest baked pace. +/proc/cyberware_weather_tag(scale) + 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_WEATHER_VIEW_MARGIN diff --git a/voidcrew/modules/cyberware/ware_sandy_timestop.dm b/voidcrew/modules/cyberware/ware_sandy_timestop.dm new file mode 100644 index 00000000000..43b01fa6daf --- /dev/null +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -0,0 +1,1224 @@ +/** + * # 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 runs in slow motion for the whole window: + * + * model window world runs at + * street 6 s 50% + * service 7 s 35% + * 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 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 + * 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 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 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). + * 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 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, 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 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); 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. + */ + +/// 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 slowed before it shrugs the field off. +#define SANDY_STOP_BOSS_HOLD (1 SECONDS) +/// 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 +/// 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" +/// 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) + +/// 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 + var/datum/proximity_monitor/advanced/timestop/sandevistan/field = GLOB.cyberware_time_dilated[thing] + return field ? field.scale_for(thing) : 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] || 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. +/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 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, 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)) + 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 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(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) + 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.scale_for(thing) + for(var/time in times) + real += time / scale + return real + var/local = 0 + var/reached = 0 + 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: 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.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.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) + 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) + 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. 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 && (!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 && (!user_crew || cyberware_crew_team(inside) != user_crew)) + 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 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 + +/// 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. +/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, + )) + 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 +/// 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(!sandy_time_tinted()) + return ..() + animate(src) + var/own = sandy_replay_colour() // ware_sandy_replay.dm + if(!istext(own)) + return TRUE + 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() + +/// 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_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 + if(!green) + var/red = 90 / 255 + var/grn = 1 + var/blue = 150 / 255 + // 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, + 0.11 * gain * red, 0.11 * gain * grn, 0.11 * gain * blue, + lift * red, lift * grn, lift * blue, + ) + 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 EFFECT +// ========================================================================= + +/// 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 dilation this is. The field is hosted on them. + var/mob/living/user + /// 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, 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] + slow_scale = scale + user_multiplier = multiplier + 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, 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 + +// ========================================================================= +// THE FIELD +// ========================================================================= + +/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, 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. + var/tick_timer + /// 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. 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() + /// 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. Their move cooldown runs slow. + 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() + /// 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() + /// The user's slowed weather and sound (ware_sandy_senses.dm). + var/datum/sandy_senses/senses + /// 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, 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 + /// 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 + 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) + 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) + 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() + 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. +/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]) + var/rate = scale_for(thing) + if(ends <= now || rate == 1) + return 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) + 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) + 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)) + 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)) + immune[A] = TRUE + return FALSE + var/tint = TRUE + if(isliving(A)) + hold_mob(A) + else if(isprojectile(A)) + var/obj/projectile/shot = A + if(shot.firer && immune[shot.firer]) + 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)) + hold_mech(A) + 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) || cyberware_time_tinted_blood(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) + held_throws[A] = 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) + 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) + 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) + 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) + held_throws -= A + held_mechs -= A + held_turrets -= A + held_grenades -= A + frozen_structures -= A + blood_holders -= A + user_shots -= A + slowed_tags -= A + own_scales -= A + UnregisterSignal(A, COMSIG_QDELETING) + escape_the_negative_zone(A) + frozen_things -= A + GLOB.cyberware_time_dilated -= A + +// ----- mobs ----- + +/// 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 + 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) + unslow_mob(victim) + held_mobs -= victim + boss_holds -= victim + +/// 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) + 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) / rate + +/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 (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) + 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 + + for(var/obj/item/gun/gun in victim.held_items) + gun.cyberware_burst_start += push + if(ranged) + ranged.cyberware_burst_start += push + +// ----- mobs on their own surge ----- + +/** + * `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. + */ +/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 + own_scales[surger] = rate + hold_mob(surger) + GLOB.cyberware_time_dilated[surger] = src + frozen_things[surger] = TRUE + +/** + * `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. 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]) + return + var/list/its_shots = list() + 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) + 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() 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(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) + cyberware_surge_refresh_perks(surger) + 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) + +// ----- 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] = TRUE + for(var/mob/living/pilot in mech.occupants) + freeze_atom(pilot) + +/// 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 + 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) + 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), 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. +/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 + user_shots[shot] = TRUE + escape_the_negative_zone(shot) // in case it was tinted before it had a firer + if(held_shots[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) + +/// 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 + held_throws -= thing + held_mechs -= thing + held_turrets -= thing + held_grenades -= thing + 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 + blood_holders -= thing + frozen_things -= thing + GLOB.cyberware_time_dilated -= thing + +// ----- the clock ----- + +/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. +/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 + 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/atom/movable/thing as anything in held_throws) + var/datum/thrownthing/throw_datum = thing.throwing + if(throw_datum) + 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 + 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. +/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 + replay?.resample(A) // recorded without the tint from here on + 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 + 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 + 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. +/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 + 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 +/// 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 + cyberware_time_tint_on(thing) + +// ========================================================================= +// THE WINDOW'S SIDE +// ========================================================================= + +/datum/status_effect/cyberware_cascade_window + /// 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 + +/// 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_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, 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)) + 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 + +/// 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 + if(QDELETED(owner)) + return + 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() + if(!time_stop) + return + UnregisterSignal(time_stop, COMSIG_QDELETING) + 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. +/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 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) + 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 +#undef SANDY_STOP_MAX_RELATIVE +#undef SANDY_STOP_SAME_RATE +#undef SANDY_STOP_OUTLINE +#undef SANDY_STOP_FLASH +#undef SANDY_STOP_TINT diff --git a/voidcrew/modules/hyperviolence/_core.dm b/voidcrew/modules/hyperviolence/_core.dm index 2aafc284d62..02245a4fddf 100644 --- a/voidcrew/modules/hyperviolence/_core.dm +++ b/voidcrew/modules/hyperviolence/_core.dm @@ -37,17 +37,24 @@ var/matrix/fallen = matrix(transform) var/matrix/upright = matrix(transform) upright.Turn(-angle) + // 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 = HV_DEATH_THROES_TIME) - animate(transform = fallen, time = UPDATE_TRANSFORM_ANIMATION_TIME, 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) + 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) 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] : step_time if(i == 1) - animate(src, pixel_w = shift, time = step_time, flags = ANIMATION_RELATIVE|ANIMATION_PARALLEL) + 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..eba6f5a1c08 100644 --- a/voidcrew/modules/hyperviolence/visuals/_motion_core.dm +++ b/voidcrew/modules/hyperviolence/visuals/_motion_core.dm @@ -202,12 +202,20 @@ /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. + 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/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) + 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 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 +226,24 @@ /atom/proc/hv_vis_play_over(list/steps) if(!length(steps)) return + // 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/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]) + 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 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. @@ -283,15 +300,18 @@ /// 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) 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.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 /// 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..9d07adf8be9 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 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] : 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..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), 1, TIMER_DELETE_ME) + 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) @@ -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..664754db19f 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.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 60c87030104..21a513d8242 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 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). + 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/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index dea05d12c44..07803f27eb2 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -103,8 +103,10 @@ #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" #include "voidcrew_cyberware_widowline.dm" #include "voidcrew_cyberware_zoom.dm" #include "voidcrew_derelict_helpers.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..928484fff6d --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_replay.dm @@ -0,0 +1,351 @@ +/** + * # 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)) + playsound(spot(2, 1), 'sound/items/weapons/genhit1.ogg', 50, FALSE) + 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) + // 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")]") + 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) + // 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") + // 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(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") + + // 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") + // 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") + // 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 + +/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() // 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") + 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") + +/// 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") + // 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") + 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") 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..06e8baa3b74 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -0,0 +1,726 @@ +/** + * # 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 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. + */ +/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 (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(lattice_type) + TEST_ASSERT(lattice.Insert(user, special = TRUE), "Test fixture: the lattice wouldn't go in") + return user + +/// 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/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) + 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 ..() + +/// 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 + +/// 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 + +/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 + 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) // the field starts async + 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(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 their own slow-mo") + + // 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 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") + 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 +/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") + + 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_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") + 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 + +/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 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(!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) + +/// 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 + +/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 + +/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, "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") + 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) + 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)) + // 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) + + 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 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") + 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)") + + // 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) + 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: 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 + // 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) + 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") + 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") + 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") + 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(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(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") + // 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 + 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 cyberware_time_has_tint(thing) + +/// 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.icon_state == "[gibs.icon_state]-overlay" && islist(bit.color)) + 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) + 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 + +/// 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() + 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 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) + +/// 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(!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.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") + + // 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(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))) + 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(!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") + 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 * 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) + 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 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() + 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(!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) // 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) + +/// 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.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 + 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) + +/// 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/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 + TEST_ASSERT_NOTNULL(surge(raider), "The NPC's window didn't open") + 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 + 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 + 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") + + 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(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 + +/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) + +/// 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() + 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") + + // 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 == 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") + 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") + + 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/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) + 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) + if(overlay:icon == 'icons/effects/glow_weather.dmi') + continue // rain has no glow state: that overlay draws nothing either way + . += overlay:icon_state