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..3e632619c9f 100644 --- a/code/game/sound/sound.dm +++ b/code/game/sound/sound.dm @@ -200,6 +200,14 @@ if(HAS_TRAIT(src, TRAIT_SOUND_DEBUGGED)) to_chat(src, span_admin("Max Range-[max_distance] Distance-[distance] Vol-[round(sound_to_use.volume, 0.01)] Sound-[sound_to_use.file]")) + // VOIDCREW EDIT CHANGE START - cyberware: a Sandevistan user hears the slowed world slowed (voidcrew/modules/cyberware/ware_sandy_senses.dm) + if(length(GLOB.cyberware_time_fields)) + var/sandy_frequency = sound_to_use.frequency + cyberware_dilate_heard(src, turf_source, sound_to_use, usr) + SEND_SOUND(src, sound_to_use) + sound_to_use.frequency = sandy_frequency // the datum can be shared with the next listener + return + // VOIDCREW EDIT CHANGE END SEND_SOUND(src, sound_to_use) /proc/sound_to_playing_players(soundin, volume = 100, vary = FALSE, frequency = 0, channel = 0, pressure_affected = FALSE, sound/S) diff --git a/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..56c6b5be276 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -7229,6 +7229,8 @@ #include "voidcrew\modules\cyberware\ware_pro_combat.dm" #include "voidcrew\modules\cyberware\ware_pro_utility.dm" #include "voidcrew\modules\cyberware\ware_registry.dm" +#include "voidcrew\modules\cyberware\ware_sandy_senses.dm" +#include "voidcrew\modules\cyberware\ware_sandy_timestop.dm" #include "voidcrew\modules\cyberware\ware_second_wind.dm" #include "voidcrew\modules\cyberware\ware_street.dm" #include "voidcrew\modules\cyberware\ware_street_passives.dm" diff --git a/tools/gear_catalog/ratings/cyberware.json b/tools/gear_catalog/ratings/cyberware.json index 65fdbe71618..f6116cd316f 100644 --- a/tools/gear_catalog/ratings/cyberware.json +++ b/tools/gear_catalog/ratings/cyberware.json @@ -449,11 +449,11 @@ "/obj/item/organ/cyberimp/cyberware/dermal_mesh": { "confidence": "high", "group": "Armor and frame", - "hash": "5b60dbb7a15c8084", + "hash": "", "name": "Dermal Mesh weave", - "note": "While online, adds physiology armor to a human wearer: melee 15, bullet 15, laser 10, wound 5. The armor is removed during an EMP reboot or an over-capacity brownout.", + "note": "While online, adds physiology armor to a human wearer: melee 15, bullet 15, laser 10, wound 5. Bullets and buckshot pellets from the front glance off at 15% (each pellet rolls on its own), keep flying at reduced damage and cost the wearer 5 stamina each. Never lasers, energy, melee or shots into the back. Shares the dodge arbiter: dodge plus glance never stop more than 60% of shots. The armor and the glance are off during an EMP reboot or an over-capacity brownout.", "rank": 5, - "reason": "15 melee, 15 bullet, 10 laser on the whole body, on top of whatever you wear. A clear step up for load 2 and 2,000 cr. EMP switches it off for 9 s.", + "reason": "15 melee, 15 bullet, 10 laser on the whole body, on top of whatever you wear, and one bullet in seven from the front glances off. A clear step up for load 2 and 2,000 cr. EMP switches it off for 9 s.", "tier": "yellow" }, "/obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave": { @@ -461,9 +461,9 @@ "group": "Armor and frame", "hash": "", "name": "Hardweave plating", - "note": "While online, adds physiology armor to a human wearer: melee 20, bullet 20, laser 15, energy 10, wound 10. The armor is removed during an EMP reboot or an over-capacity brownout.", + "note": "While online, adds physiology armor to a human wearer: melee 20, bullet 20, laser 15, energy 10, wound 10. Bullets and buckshot pellets from the front glance off at 30% (each pellet rolls on its own), keep flying at reduced damage and cost the wearer 5 stamina each. Never lasers, energy, melee or shots into the back. Shares the dodge arbiter: dodge plus glance never stop more than 60% of shots. The armor and the glance are off during an EMP reboot or an over-capacity brownout.", "rank": 3, - "reason": "20 melee, 20 bullet, 15 laser, 10 energy and 10 wound armor on the whole body, on top of whatever you wear. A size up from the Mesh and well short of the Slabskin Plate, which adds bomb, fire and acid. Load 3, 3,000 cr at reputation 4. EMP switches it off for 9 s.", + "reason": "20 melee, 20 bullet, 15 laser, 10 energy and 10 wound armor on the whole body, on top of whatever you wear, and nearly a third of bullets from the front glance off. A size up from the Mesh and well short of the Slabskin Plate, which adds bomb, fire, acid and a bigger glance. Load 3, 3,000 cr at reputation 4. Strong for yellow against gunfire from the front, still useless against lasers and flanking. EMP switches it off for 9 s.", "tier": "yellow" }, "/obj/item/organ/cyberimp/cyberware/doppler": { @@ -779,11 +779,11 @@ "/obj/item/organ/cyberimp/cyberware/slabskin": { "confidence": "high", "group": "Armor and frame", - "hash": "7b785a3adf8b7c37", + "hash": "", "name": "Slabskin plate", - "note": "While online it adds built-in armor to the bearer (melee 25, bullet 25, laser 25, energy 20, bomb 25, fire 20, acid 20, wound 15), and any brute or burn hit of 5 or more throws sparks and often a ricochet sound so others can see the plate. The armor switches off while the ware is EMP'd or browned out.", + "note": "While online it adds built-in armor to the bearer (melee 25, bullet 25, laser 25, energy 20, bomb 25, fire 20, acid 20, wound 15), and any brute or burn hit of 5 or more throws sparks and often a ricochet sound so others can see the plate. Bullets and buckshot pellets from the front glance off at 45% (each pellet rolls on its own), keep flying at reduced damage and cost the wearer 5 stamina each. Never lasers, energy, melee or shots into the back. Shares the dodge arbiter: dodge plus glance never stop more than 60% of shots. The armor and the glance switch off while the ware is EMP'd or browned out.", "rank": 1, - "reason": "25 armor against melee, bullets and lasers on the whole body, multiplied on top of whatever you wear, plus bomb, fire, acid and wounds. About a second armor vest that covers your head and limbs. Load 6, 3 vouchers at reputation 22. EMP switches it off for 9 s.", + "reason": "25 armor against melee, bullets and lasers on the whole body, multiplied on top of whatever you wear, plus bomb, fire, acid and wounds, and almost half of bullets from the front glance off. About a second armor vest that covers your head and limbs, and the best answer to a gunline in the game. Load 6, 3 vouchers at reputation 22. EMP switches it off for 9 s.", "tier": "red" }, "/obj/item/organ/cyberimp/cyberware/slipwire": { 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..3b5acf17a59 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 @@ -188,6 +212,8 @@ 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() @@ -205,9 +231,11 @@ ) owner.balloon_alert(owner, "cascade online") cyberware_cascade_window_fx(owner) + try_time_stop() // ware_sandy_timestop.dm return TRUE /datum/status_effect/cyberware_cascade_window/on_remove() + end_time_stop() owner.remove_movespeed_modifier(speed_type) owner.remove_movespeed_mod_immunities(id, /datum/movespeed_modifier/reagent) cyberware_unregister_dodge_source(owner, id) @@ -240,6 +268,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 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_military_body.dm b/voidcrew/modules/cyberware/ware_military_body.dm index a5cf534aba9..ffd818d7dd5 100644 --- a/voidcrew/modules/cyberware/ware_military_body.dm +++ b/voidcrew/modules/cyberware/ware_military_body.dm @@ -264,7 +264,7 @@ */ /obj/item/organ/cyberimp/cyberware/slabskin name = "\improper Slabskin plate" - desc = "A keel and rib plates of sintered ceramic, grown into the skin of the chest and back. Bullets leave scuffs and a noise like a dropped pan." + desc = "A keel and rib plates of sintered ceramic, grown into the skin of the chest and back. Bullets leave scuffs, when they don't bounce off with a noise like a dropped pan." icon_state = "slabskin" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_DERMAL @@ -276,6 +276,8 @@ /// TRUE while the plate armor is mixed into the bearer's physiology. /// Guards the failing-gated passive hooks against double add/subtract. var/plate_armor_applied = FALSE + /// Percent of bullets from the front that glance off, through the shared dodge arbiter. The top of the dermal ladder. + var/deflect_chance = 45 /obj/item/organ/cyberimp/cyberware/slabskin/on_mob_insert(mob/living/carbon/organ_owner, special = FALSE, movement_flags) . = ..() @@ -283,6 +285,7 @@ /obj/item/organ/cyberimp/cyberware/slabskin/chrome_passives_on(mob/living/carbon/bearer) . = ..() + cyberware_register_deflect_source(bearer, REF(src), CALLBACK(src, PROC_REF(get_deflect_chance))) if(plate_armor_applied || !ishuman(bearer)) return plate_armor_applied = TRUE @@ -291,6 +294,7 @@ /obj/item/organ/cyberimp/cyberware/slabskin/chrome_passives_off(mob/living/carbon/bearer) . = ..() + cyberware_unregister_deflect_source(bearer, REF(src)) if(!plate_armor_applied) return plate_armor_applied = FALSE // reset before the validity skip, or the plate never re-arms after a bearer deletes @@ -303,6 +307,12 @@ . = ..() UnregisterSignal(organ_owner, COMSIG_MOB_APPLY_DAMAGE) +/// Deflect-source callback: nothing while failing. +/obj/item/organ/cyberimp/cyberware/slabskin/proc/get_deflect_chance() + if(organ_flags & ORGAN_FAILING) + return 0 + return deflect_chance + /** * Signal proc for [COMSIG_MOB_APPLY_DAMAGE]: the telegraph. Solid brute or * burn hits spark and ping off the plate where everyone can see them. diff --git a/voidcrew/modules/cyberware/ware_models_b.dm b/voidcrew/modules/cyberware/ware_models_b.dm index 9db419c8cba..7de0991d93c 100644 --- a/voidcrew/modules/cyberware/ware_models_b.dm +++ b/voidcrew/modules/cyberware/ware_models_b.dm @@ -44,12 +44,13 @@ /obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave name = "\improper Hardweave plating" - desc = "Rib plates over the chest and back with a layered polymer weave sewn in below them, a size up from the usual mesh. The scarring is the part nobody mentions." + desc = "Rib plates over the chest and back with a layered polymer weave sewn in below them, a size up from the usual mesh. Rounds that come in at an angle skip off. The scarring is the part nobody mentions." icon_state = "dermal_mesh_hardweave" aug_overlay = "dermal_mesh_hardweave" chrome_load = 3 tier = CYBERWARE_TIER_2 mesh_armor = /datum/armor/cyberware_hardweave + deflect_chance = 30 // ========================================================================= // STRUT FRAME (T2): the bones and nothing else diff --git a/voidcrew/modules/cyberware/ware_pro_combat.dm b/voidcrew/modules/cyberware/ware_pro_combat.dm index 30371c5e83e..9301652c748 100644 --- a/voidcrew/modules/cyberware/ware_pro_combat.dm +++ b/voidcrew/modules/cyberware/ware_pro_combat.dm @@ -13,7 +13,10 @@ * COMSIG_ATOM_PRE_BULLET_ACT handler. A source registered with melee = TRUE * (the Cascade line's windows) also dodges blows, through tg's own * check_block() via COMSIG_LIVING_CHECK_BLOCK, so a dodged blow is a real - * miss and never fake damage. + * miss and never fake damage. The same arbiter rolls the dermal plating's + * bullet deflection (see cyberware_register_deflect_source()), capped so a + * dodge and a deflect together never stop more than CYBERWARE_AVOID_CAP + * percent of shots. * - cyberware_is_ally(): the crew filter for auto-targeting ware (Deadeye's * tag, Widowline's cleave). Allies are people who share a ship team with * you; NPC boarders standing on your deck are NOT allies, and rival players @@ -27,6 +30,10 @@ #define CYBERWARE_DODGE_MOVE_WINDOW (1 SECONDS) /// The shortest gap between two dodge lines from a bearer nobody is playing (an NPC under fire). #define CYBERWARE_DODGE_LINE_GAP (1.5 SECONDS) +/// Dodge and deflect together never stop more than this percent of shots. +#define CYBERWARE_AVOID_CAP 60 +/// Stamina each deflected bullet costs the bearer, so focused fire still wears them down. +#define CYBERWARE_DEFLECT_STAMINA 5 /** * The one dodge roll for all cyberware. Lives on the mob; wares and status @@ -53,6 +60,10 @@ var/list/dodge_sources = list() /// key -> /datum/callback for the sources that also dodge blows. Each is in dodge_sources too. var/list/melee_sources = list() + /// key -> /datum/callback returning that source's live bullet-deflect chance (the dermal plating). + var/list/deflect_sources = list() + /// Dodge and deflect together never stop more than this percent of shots. + var/avoid_cap = CYBERWARE_AVOID_CAP /// world.time of the bearer's last move. var/last_move_time = 0 /// world.time a dodge was last announced: an NPC under fire says it at most once every CYBERWARE_DODGE_LINE_GAP, so chat isn't flooded @@ -73,6 +84,7 @@ /datum/component/cyberware_dodge/Destroy() dodge_sources = null melee_sources = null + deflect_sources = null return ..() /// Signal proc for [COMSIG_MOVABLE_MOVED]: timestamp the movement window. @@ -80,25 +92,85 @@ SIGNAL_HANDLER last_move_time = world.time -/// Signal proc for [COMSIG_ATOM_PRE_BULLET_ACT]: the actual dodge roll. +/** + * Signal proc for [COMSIG_ATOM_PRE_BULLET_ACT]: the dodge roll, then the + * deflect roll. One handler for both, so the cap on the two together holds + * no matter which chrome registered first. + */ /datum/component/cyberware_dodge/proc/on_pre_bullet_act(mob/living/source, obj/projectile/hitting_projectile, def_zone, piercing_hit, blocked) SIGNAL_HANDLER - if(world.time - last_move_time > CYBERWARE_DODGE_MOVE_WINDOW) - return NONE - if(source.stat != CONSCIOUS || source.body_position == LYING_DOWN || source.buckled) - return NONE if(!istype(hitting_projectile) || !hitting_projectile.is_hostile_projectile()) return NONE + var/dodge_chance = live_dodge_chance(source, hitting_projectile) + if(dodge_chance > 0 && prob(dodge_chance)) + INVOKE_ASYNC(src, PROC_REF(perform_sidestep), source, hitting_projectile) + return COMPONENT_BULLET_PIERCED + var/deflect_chance = min(live_deflect_chance(source, hitting_projectile), cyberware_deflect_cap(dodge_chance, avoid_cap)) + if(deflect_chance <= 0 || !prob(deflect_chance)) + return NONE + glance(source, hitting_projectile) + return COMPONENT_BULLET_PIERCED + +/// The dodge chance against this shot right now: 0 unless the bearer is moving, upright and not point-blank. +/datum/component/cyberware_dodge/proc/live_dodge_chance(mob/living/source, obj/projectile/hitting_projectile) + if(!length(dodge_sources)) + 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 // reflex loop. This is the anti-"unhittable in a brawl" rule. var/atom/shooter = hitting_projectile.firer if(shooter && shooter != source && get_dist(source, shooter) <= 1) - return NONE - var/best_chance = best_chance(dodge_sources) - if(best_chance <= 0 || !prob(best_chance)) - return NONE - INVOKE_ASYNC(src, PROC_REF(perform_sidestep), source, hitting_projectile) - return COMPONENT_BULLET_PIERCED + return 0 + return best_chance(dodge_sources) + +/** + * The deflect chance against this shot right now. Only real bullets (and + * each buckshot pellet on its own, since every pellet is its own projectile) + * glance off; lasers, energy and everything else never do. The plate only + * covers what the bearer is facing: a shot into the back always lands. + */ +/datum/component/cyberware_dodge/proc/live_deflect_chance(mob/living/source, obj/projectile/hitting_projectile) + if(!length(deflect_sources)) + return 0 + if(!istype(hitting_projectile, /obj/projectile/bullet) || hitting_projectile.armor_flag != BULLET) + return 0 + if(source.stat == DEAD) + return 0 + if(cyberware_shot_from_behind(source, hitting_projectile)) + return 0 + return best_chance(deflect_sources) + +/** + * The real glance: the bullet's heading turns back off the plate at a random + * angle and the round keeps flying (COMPONENT_BULLET_PIERCED, the same exit + * tg's own reflect takes), so it can still hit whoever stands in its new + * path. It loses energy like any tg ricochet. The bearer stays in the + * bullet's impacted list, so it can't come back for them. + */ +/datum/component/cyberware_dodge/proc/glance(mob/living/source, obj/projectile/bullet) + bullet.set_angle(SIMPLIFY_DEGREES(bullet.angle + pick(-1, 1) * rand(100, 170))) + bullet.damage *= bullet.ricochet_decay_damage + bullet.stamina *= bullet.ricochet_decay_damage + INVOKE_ASYNC(src, PROC_REF(perform_glance), source, bullet.name) + +/// The visible and physical half of a glance: sparks, a ping, and the stamina the hit still costs. +/datum/component/cyberware_dodge/proc/perform_glance(mob/living/source, bullet_name) + if(QDELETED(source)) + return + do_sparks(1, TRUE, source) + playsound(source, SFX_RICOCHET, 60, TRUE) + source.adjustStaminaLoss(CYBERWARE_DEFLECT_STAMINA) + if(source.client || world.time - last_message_at >= CYBERWARE_DODGE_LINE_GAP) + last_message_at = world.time + source.visible_message( + span_danger("\The [bullet_name] glances off [source]!"), + span_danger("\The [bullet_name] glances off you!"), + ) /// The highest live chance among these sources. Never additive. /datum/component/cyberware_dodge/proc/best_chance(list/sources) @@ -112,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)) @@ -206,9 +278,45 @@ return arbiter.dodge_sources -= key arbiter.melee_sources -= key - if(!length(arbiter.dodge_sources)) + if(!length(arbiter.dodge_sources) && !length(arbiter.deflect_sources)) + qdel(arbiter) + +/** + * Adds (or refreshes) a keyed bullet-deflect source on the bearer, through + * the same arbiter as the dodge. The callback returns the source's CURRENT + * chance. Never additive: the highest live source rolls. + */ +/proc/cyberware_register_deflect_source(mob/living/bearer, key, datum/callback/chance_callback) + if(!istype(bearer) || !key || !chance_callback) + return + var/datum/component/cyberware_dodge/arbiter = bearer.LoadComponent(/datum/component/cyberware_dodge) + arbiter.deflect_sources[key] = chance_callback + +/// Removes a keyed deflect source; the arbiter cleans itself up with the last source of either kind. +/proc/cyberware_unregister_deflect_source(mob/living/bearer, key) + var/datum/component/cyberware_dodge/arbiter = bearer?.GetComponent(/datum/component/cyberware_dodge) + if(!arbiter) + return + arbiter.deflect_sources -= key + if(!length(arbiter.dodge_sources) && !length(arbiter.deflect_sources)) qdel(arbiter) +/** + * The most a deflect may roll after a dodge roll at dodge_chance missed, so + * that dodge and deflect together never stop more than avoid_cap + * percent of shots: dodge + (1 - dodge) * deflect <= cap. + */ +/proc/cyberware_deflect_cap(dodge_chance, avoid_cap = CYBERWARE_AVOID_CAP) + dodge_chance = clamp(dodge_chance, 0, 100) + if(dodge_chance >= avoid_cap) + return 0 + return (avoid_cap - dodge_chance) * 100 / (100 - dodge_chance) + +/// Whether this shot is travelling the way the bearer faces, so it strikes them in the back. +/proc/cyberware_shot_from_behind(mob/living/bearer, obj/projectile/shot) + var/offset = SIMPLIFY_DEGREES(shot.angle - dir2angle(bearer.dir) + 180) - 180 + return abs(offset) < 90 + // ---- Shared ally filter ------------------------------------------------ /** @@ -811,6 +919,8 @@ #undef CYBERWARE_DODGE_MOVE_WINDOW #undef CYBERWARE_DODGE_LINE_GAP +#undef CYBERWARE_AVOID_CAP +#undef CYBERWARE_DEFLECT_STAMINA #undef CYBERWARE_DEADEYE_TAG_RANGE #undef CYBERWARE_DEADEYE_TAG_SHOTS #undef CYBERWARE_DEADEYE_TAG_DURATION 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..5ccc21f0e1a --- /dev/null +++ b/voidcrew/modules/cyberware/ware_sandy_timestop.dm @@ -0,0 +1,982 @@ +/** + * # 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 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. Floor items, blood decals and the + * user are never touched, so they keep their colour, and so do the user's own shots. Every bullet in the field + * flies at the field's pace, whoever fired it: the user's and a surging mob's crawl in the slow-mo like the rest + * (a faster surger fires sooner, but its bullets still crawl), untinted. 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" + +/// 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) + +/// 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. +/proc/cyberware_time_stop_witnessed(mob/living/user, range = SANDY_STOP_WITNESS_RANGE) + var/turf/here = get_turf(user) + if(!here) + return TRUE + for(var/atom/movable/found as anything in SSspatial_grid.orthogonal_range_search(here, SPATIAL_GRID_CONTENTS_TYPE_HEARING, range)) + if(found == user) + continue + if(isliving(found)) + var/mob/living/body = found + if(body.ckey) + 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) + 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 + +/// 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 + +/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 + . = ..() + 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 + for(var/mob/living/surger as anything in surging) + UnregisterSignal(surger, COMSIG_QDELETING) + surging.Cut() + . = ..() // unfreeze_all() lets everything go + own_scales.Cut() + released.Cut() + +/// How long, in real time from now, `local` deciseconds of `thing`'s own time take: 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) + 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)) + 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) + return TRUE + +/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 + 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) + 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) + 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 + 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)) + 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 + senses?.refresh_weather() + +/// Whether `thing` is what the user is riding or piloting: a vehicle with them aboard, or anything they're buckled to. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/own_ride(atom/movable/thing) + if(!ismob(host) || !istype(thing)) + return FALSE + var/mob/living/user = host + if(istype(thing, /obj/vehicle)) + var/obj/vehicle/ride = thing + if(ride.is_occupant(user)) + return TRUE + return isliving(user) && user.buckled == thing + +/// The user climbed into a mech or onto a vehicle the field already holds: it runs at full speed from now on. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/let_go_own_ride() + if(!isliving(host)) + return + var/mob/living/user = host + for(var/atom/movable/ride in list(user.loc, user.buckled)) + if(frozen_things[ride] && own_ride(ride)) + unfreeze_atom(ride) + +/datum/proximity_monitor/advanced/timestop/sandevistan/into_the_negative_zone(atom/A) + tinted[A] = TRUE + var/wait = ring_wait(A) + if(wait > 0) + if(!tint_timers[A]) + tint_timers[A] = addtimer(CALLBACK(src, PROC_REF(ring_reached), A), wait, TIMER_STOPPABLE) + return + A.add_atom_colour(cyberware_time_stop_tint(), TEMPORARY_COLOUR_PRIORITY) + +/datum/proximity_monitor/advanced/timestop/sandevistan/escape_the_negative_zone(atom/A) + if(tint_timers[A]) + deltimer(tint_timers[A]) + tint_timers -= A + tinted -= A + return ..() + +/// The ring starts rolling out from `center` to `reach` tiles over `time`: everything it hasn't reached yet loses +/// its tint until the ring's front passes it. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/sweep_ring(turf/center, reach, time) + ring_center = center + ring_start = world.time + ring_time = time + ring_reach = reach + for(var/atom/movable/thing as anything in tinted) + if(tint_timers[thing]) + continue + var/wait = ring_wait(thing) + if(wait <= 0) + continue + thing.remove_atom_colour(TEMPORARY_COLOUR_PRIORITY) + tint_timers[thing] = addtimer(CALLBACK(src, PROC_REF(ring_reached), thing), wait, TIMER_STOPPABLE) + +/// How long until the rolling ring's front reaches `thing`, or 0 if it already has. The ring grows with a sine +/// ease-out (cyberware_time_ring). +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/ring_wait(atom/thing) + if(!ring_center || world.time >= ring_start + ring_time) + return 0 + var/turf/spot = get_turf(thing) + if(!spot || spot.z != ring_center.z) + return 0 + var/dist = sqrt((spot.x - ring_center.x) ** 2 + (spot.y - ring_center.y) ** 2) + var/reached = clamp((dist - 0.2) / max(ring_reach - 0.2, 0.1), 0, 1) + return ring_start + ring_time * arcsin(reached) / 90 - world.time + +/// The ring's front got to `thing`: on with its tint, if it still wants it. +/datum/proximity_monitor/advanced/timestop/sandevistan/proc/ring_reached(atom/movable/thing) + tint_timers -= thing + if(QDELETED(thing) || !tinted[thing]) + return + thing.add_atom_colour(cyberware_time_stop_tint(), TEMPORARY_COLOUR_PRIORITY) + +// ========================================================================= +// THE WINDOW'S SIDE +// ========================================================================= + +/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) + +/// 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 diff --git a/voidcrew/modules/cyberware/ware_street.dm b/voidcrew/modules/cyberware/ware_street.dm index 70d6d2a1751..9fe5c186db2 100644 --- a/voidcrew/modules/cyberware/ware_street.dm +++ b/voidcrew/modules/cyberware/ware_street.dm @@ -745,10 +745,14 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( * add/subtract pair per flip is the whole lifecycle; re-applying on species * gain would stack the armor. The pair rides the failing-gated passive hooks * (BAL-4): EMP-scrambled or browned-out mesh armors nothing. + * + * The dermal ladder (Mesh, Hardweave, Slabskin) also turns bullets: a + * passive deflect source in the shared dodge arbiter (ware_pro_combat.dm), + * 15/30/45% by rung, front arc only, rides the same passive hooks. */ /obj/item/organ/cyberimp/cyberware/dermal_mesh name = "\improper Dermal Mesh weave" - desc = "A subdermal lattice of impact polymer. It won't stop anything serious, but it takes the edge off." + desc = "A subdermal lattice of impact polymer. It won't stop anything serious, but now and then a round skips off it." icon_state = "dermal_mesh" zone = BODY_ZONE_CHEST slot = ORGAN_SLOT_CYBERWARE_DERMAL @@ -761,9 +765,12 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( var/datum/armor/mesh_armor = /datum/armor/cyberware_dermal_mesh /// TRUE while mesh_armor is mixed in; guards against double add/subtract. var/mesh_armor_applied = FALSE + /// Percent of bullets from the front that glance off, through the shared dodge arbiter. + var/deflect_chance = 15 /obj/item/organ/cyberimp/cyberware/dermal_mesh/chrome_passives_on(mob/living/carbon/bearer) . = ..() + cyberware_register_deflect_source(bearer, REF(src), CALLBACK(src, PROC_REF(get_deflect_chance))) if(mesh_armor_applied || !ishuman(bearer)) return mesh_armor_applied = TRUE @@ -772,6 +779,7 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( /obj/item/organ/cyberimp/cyberware/dermal_mesh/chrome_passives_off(mob/living/carbon/bearer) . = ..() + cyberware_unregister_deflect_source(bearer, REF(src)) if(!mesh_armor_applied) return mesh_armor_applied = FALSE // reset before the validity skip, see Shock Coils @@ -780,6 +788,12 @@ GLOBAL_LIST_INIT(cyberware_ink_palette, list( var/mob/living/carbon/human/human_bearer = bearer human_bearer.physiology.armor = human_bearer.physiology.armor.subtract_other_armor(mesh_armor) +/// Deflect-source callback: nothing while failing. +/obj/item/organ/cyberimp/cyberware/dermal_mesh/proc/get_deflect_chance() + if(organ_flags & ORGAN_FAILING) + return 0 + return deflect_chance + // ---- 11. Gecko Grips --------------------------------------------------- /** 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..a597b647805 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. @@ -288,10 +305,11 @@ var/flash = get_filter(HV_VIS_FLASH_FILTER) if(!flash) return - animate(flash, color = list(0.6,0,0, 0,0.6,0, 0,0,0.6, 0.4,0.4,0.4), time = 0.5, easing = LINEAR_EASING) - animate(color = list(0.6,0,0, 0,0.6,0, 0,0,0.6, 0.4,0.4,0.4), time = HV_VIS_HITSTOP_TIME, easing = LINEAR_EASING) - animate(color = list(1,0,0, 0,1,0, 0,0,1, 0,0,0), time = 1, easing = LINEAR_EASING) - addtimer(CALLBACK(src, TYPE_PROC_REF(/datum, remove_filter), HV_VIS_FLASH_FILTER), 0.5 SECONDS, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_DELETE_ME) + var/list/real = cyberware_dilated_steps(src, list(0.5, HV_VIS_HITSTOP_TIME, 1)) || list(0.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 499ec70cafb..4a98a25a2c1 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -94,6 +94,7 @@ #include "voidcrew_cyberware_anim.dm" #include "voidcrew_cyberware_anim2.dm" #include "voidcrew_cyberware_camo.dm" +#include "voidcrew_cyberware_deflect.dm" #include "voidcrew_cyberware_final.dm" #include "voidcrew_cyberware_kit.dm" #include "voidcrew_cyberware_mantis.dm" @@ -104,6 +105,7 @@ #include "voidcrew_cyberware_passives.dm" #include "voidcrew_cyberware_soak.dm" #include "voidcrew_cyberware_text.dm" +#include "voidcrew_cyberware_timestop.dm" #include "voidcrew_cyberware_widowline.dm" #include "voidcrew_cyberware_zoom.dm" #include "voidcrew_derelict_helpers.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_cyberware_deflect.dm b/voidcrew/modules/unit_tests/voidcrew_cyberware_deflect.dm new file mode 100644 index 00000000000..696e4b85e95 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_deflect.dm @@ -0,0 +1,72 @@ +/// The dermal ladder turns bullets from the front: a real glance that leaves +/// the bearer unhurt and keeps the round flying on a new heading, costs a +/// little stamina, never touches lasers or shots into the back, switches off +/// with the chrome, and never lifts dodge plus deflect past the shared cap. +/datum/unit_test/voidcrew_cyberware_deflect + +/datum/unit_test/voidcrew_cyberware_deflect/Run() + var/obj/item/organ/cyberimp/cyberware/dermal_mesh/mesh = allocate(/obj/item/organ/cyberimp/cyberware/dermal_mesh) + var/obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave/hardweave = allocate(/obj/item/organ/cyberimp/cyberware/dermal_mesh/hardweave) + var/obj/item/organ/cyberimp/cyberware/slabskin/slab = allocate(/obj/item/organ/cyberimp/cyberware/slabskin) + TEST_ASSERT_EQUAL(mesh.deflect_chance, 15, "Dermal Mesh deflects at the wrong chance") + TEST_ASSERT_EQUAL(hardweave.deflect_chance, 30, "Hardweave deflects at the wrong chance") + TEST_ASSERT_EQUAL(slab.deflect_chance, 45, "Slabskin deflects at the wrong chance") + + // The cap: dodge + (1 - dodge) * deflect never passes 60%. + TEST_ASSERT_EQUAL(cyberware_deflect_cap(0), 60, "The deflect cap is wrong with no dodge") + TEST_ASSERT_EQUAL(cyberware_deflect_cap(60), 0, "A full-cap dodge still left room to deflect") + for(var/dodge in list(0, 15, 30, 45, 59)) + var/deflect = min(slab.deflect_chance, cyberware_deflect_cap(dodge)) + var/combined = dodge + (100 - dodge) * deflect / 100 + TEST_ASSERT(combined <= 60.001, "Dodge [dodge] plus deflect stops [combined]% of shots") + + var/mob/living/carbon/human/patient = allocate(/mob/living/carbon/human/consistent) + var/mob/living/carbon/human/shooter = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + TEST_ASSERT(slab.Insert(patient, TRUE), "Slabskin fixture failed") + var/datum/component/cyberware_dodge/arbiter = patient.GetComponent(/datum/component/cyberware_dodge) + TEST_ASSERT_NOTNULL(arbiter, "Slabskin didn't register a deflect source") + // Force every glance: a 100% plate under a lifted cap. The cap itself is checked above. + slab.deflect_chance = 100 + arbiter.avoid_cap = 100 + patient.setDir(SOUTH) + + // A bullet from the front (travelling north into a bearer facing south) glances off. + var/obj/projectile/bullet/c9mm/front = make_shot(/obj/projectile/bullet/c9mm, shooter, 0) + var/front_damage = front.damage + TEST_ASSERT_EQUAL(patient.bullet_act(front, BODY_ZONE_CHEST), BULLET_ACT_FORCE_PIERCE, "A bullet from the front didn't glance off") + TEST_ASSERT_EQUAL(patient.getBruteLoss(), 0, "A glanced bullet still hurt") + TEST_ASSERT(front.angle != 0, "The glanced bullet kept its heading") + TEST_ASSERT(front.damage < front_damage, "The glanced bullet lost no energy") + TEST_ASSERT(patient.getStaminaLoss() > 0, "A glance cost no stamina") + + // Every buckshot pellet is a bullet of its own and rolls on its own. + var/obj/projectile/bullet/pellet/shotgun_buckshot/pellet = make_shot(/obj/projectile/bullet/pellet/shotgun_buckshot, shooter, 0) + TEST_ASSERT_EQUAL(patient.bullet_act(pellet, BODY_ZONE_CHEST), BULLET_ACT_FORCE_PIERCE, "A buckshot pellet didn't glance off") + + // A bullet into the back always lands. + var/obj/projectile/bullet/c9mm/back = make_shot(/obj/projectile/bullet/c9mm, shooter, 180) + TEST_ASSERT_NOTEQUAL(patient.bullet_act(back, BODY_ZONE_CHEST), BULLET_ACT_FORCE_PIERCE, "A bullet into the back glanced off") + TEST_ASSERT(patient.getBruteLoss() > 0, "A bullet into the back did nothing") + + // Lasers are never deflected. + var/obj/projectile/beam/laser/laser = make_shot(/obj/projectile/beam/laser, shooter, 0) + TEST_ASSERT_NOTEQUAL(patient.bullet_act(laser, BODY_ZONE_CHEST), BULLET_ACT_FORCE_PIERCE, "A laser glanced off the plate") + TEST_ASSERT(patient.getFireLoss() > 0, "The laser did nothing") + + // A failing plate deflects nothing. + slab.organ_flags |= ORGAN_FAILING + var/obj/projectile/bullet/c9mm/failing = make_shot(/obj/projectile/bullet/c9mm, shooter, 0) + TEST_ASSERT_NOTEQUAL(patient.bullet_act(failing, BODY_ZONE_CHEST), BULLET_ACT_FORCE_PIERCE, "A failing plate still deflected") + slab.organ_flags &= ~ORGAN_FAILING + + // Taking the plate out takes the deflect with it. + slab.Remove(patient) + TEST_ASSERT_NULL(patient.GetComponent(/datum/component/cyberware_dodge), "The deflect source outlived the plate") + +/// A hostile shot from the shooter, heading at the given angle, ready for bullet_act(). +/datum/unit_test/voidcrew_cyberware_deflect/proc/make_shot(shot_type, mob/living/shooter, heading) + var/obj/projectile/shot = allocate(shot_type) + shot.firer = shooter + shot.def_zone = BODY_ZONE_CHEST + shot.set_angle(heading) + return shot 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..6fc3fda5f7b --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_cyberware_timestop.dm @@ -0,0 +1,664 @@ +/** + * # 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 + +/// 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") + TEST_ASSERT(!frozen(raider), "The NPC was frozen after the window") + 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(user.has_status_effect(/datum/status_effect/cyberware_cascade_crash), "Ending the window early skipped the crash") + TEST_ASSERT_NULL(window.time_stop, "The slow-mo outlived the window") + TEST_ASSERT(!raider.has_movespeed_modifier(/datum/movespeed_modifier/cyberware_dilation), "The NPC stayed slow after the window was cut short") + TEST_ASSERT_EQUAL(button.next_use_time, cooldown_end, "Cutting the window short changed the cooldown") + + // In the crash, and after it, the button is back on its cooldown. + TEST_ASSERT(!button.IsAvailable(), "The surge could be pressed in the crash") + TEST_ASSERT(!button.Trigger(), "A third press went through") + user.remove_status_effect(/datum/status_effect/cyberware_cascade_crash) + TEST_ASSERT(!button.Trigger(), "A press after the crash went through on cooldown") + TEST_ASSERT(!user.has_status_effect(/datum/status_effect/cyberware_cascade_window), "A press opened a fresh window on cooldown") + + // An NPC pressing its own surge again doesn't end it. + var/mob/living/carbon/human/npc/surger = make_surger(spot(3, 0)) + var/datum/action/cooldown/cyberware/cascade_surge/npc_button = surge_button(surger) + TEST_ASSERT(npc_button.Trigger(), "The NPC's press didn't surge") + TEST_ASSERT(!npc_button.IsAvailable(), "An NPC's surge reads as pressable in its window") + npc_button.Trigger() + TEST_ASSERT(surger.has_status_effect(/datum/status_effect/cyberware_cascade_window), "An NPC's second press ended its window") + end_surge(surger) + +/// A player coming in range mid-surge ends the slow-mo; the surge carries on as a plain one, with the plain trail +/datum/unit_test/voidcrew_cyberware_timestop/player_walks_in + +/datum/unit_test/voidcrew_cyberware_timestop/player_walks_in/Run() + var/mob/living/carbon/human/user = make_user(spot(0, 0), "sandytimestopuser") + var/mob/living/carbon/human/other = allocate(/mob/living/carbon/human/consistent, spot(3, 3)) + var/datum/status_effect/cyberware_cascade_window/window = surge(user) + sleep(0.1 SECONDS) + TEST_ASSERT_NOTNULL(window.time_stop, "Time didn't slow with nobody else near") + other.key = "sandytimestopother" + sleep(0.5 SECONDS) // a couple of field ticks + TEST_ASSERT_NULL(window.time_stop, "The slow-mo carried on with a player in range") + TEST_ASSERT(user.has_status_effect(/datum/status_effect/cyberware_cascade_window), "The surge ended with the slow-mo") + var/turf/start = get_turf(user) + user.Move(spot(0, 1), NORTH) + TEST_ASSERT(get_turf(user) != start, "Test fixture: the user couldn't step") + TEST_ASSERT_NULL(locate(/obj/effect/temp_visual/decoy/fading/cascade/stopped) in spot(0, 1), "The green trail outlived the slow-mo") + end_surge(user) + +/// Another player near: the plain surge, no dilation +/datum/unit_test/voidcrew_cyberware_timestop/witnessed + +/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)) + 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/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 + + sleep(0.5 SECONDS) + // 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(!(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 length(thing.atom_colours) >= TEMPORARY_COLOUR_PRIORITY && !isnull(thing.atom_colours[TEMPORARY_COLOUR_PRIORITY]) + +/// 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 while the user's own world is slowed; it comes back if the slow-mo ends before the window does +/datum/unit_test/voidcrew_cyberware_timestop/no_dodge_while_slowed + +/datum/unit_test/voidcrew_cyberware_timestop/no_dodge_while_slowed/Run() + 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") + 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") + 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 diff --git a/wiki/content/cyberware.md b/wiki/content/cyberware.md index a6838a6896a..8339f1313e4 100644 --- a/wiki/content/cyberware.md +++ b/wiki/content/cyberware.md @@ -47,7 +47,7 @@ Prices are the same at both counters, stock is limited per round, and load is th | Cargo Cavity | 1,000 cr | 1 | A chest stash for one small item that no scanner, pat-down or Chrome Read finds | | Rockjaw drill fist | 1,200 cr | 2 | Deployable forearm mining drill; ore from walls you crack auto-pockets into a hopper | | Fixer's Fingers | 1,800 cr | 2 | Fingertip screwdriver, wrench and wirecutters, plus 25% faster timed actions full-time | -| Dermal Mesh weave | 2,000 cr | 2 | Light built-in armor: melee 15, bullet 15, laser 10 | +| Dermal Mesh weave | 2,000 cr | 2 | Light built-in armor: melee 15, bullet 15, laser 10. 15% of bullets from the front glance off | ### Tier 2: Pro chrome (credits or vouchers) @@ -76,7 +76,7 @@ Prices are the same at both counters, stock is limited per round, and load is th | Widowline monowire | 3 | 5 | A two-tile whip that ignores armor entirely and cuts through into one person standing right behind the target for 75% | | Popup Ronin housing | *loot only* | 5 | Deployable full-auto machine pistol in 10x24mm caseless. Reloads are sold at the parlor or printed with Experimental Ammunition | | Rigger socket | 3 | 4 | Jack into your own ship's helm from anywhere aboard and fly it from a corridor | -| Slabskin plate | 3 | 6 | Heavy built-in armor in every damage type. Solid hits visibly spark off you. Everyone can see you are plated | +| Slabskin plate | 3 | 6 | Heavy built-in armor in every damage type. 45% of bullets from the front glance off. Solid hits visibly spark off you. Everyone can see you are plated | | Mantis Blades (pair) | 4 | 3/arm | Deployable hardlight blades, plus a five-tile lunge that knocks the target down. Press and hold the lunge on a target to crouch, and let go to spring up to seven tiles; a solid hit or a stun while you crouch wastes it. A kill brings the lunge back almost at once. Three quick cuts to one limb open its artery, a two-blade lunge skewers someone already hurt, every third cut with both blades out crosses them and staggers, and a swing at the head of someone dying takes it off | | Bunker Buster housing | *loot only* | 5 | Two shaped micro-rockets: 60 damage, no splash, will never breach a hull. Reloads are sold at the parlor or printed with Experimental Ammunition | | Ghostskin weave | 4 | 5 | Ten seconds of optical camo. NPCs will not target you, and Chrome Reads scatter. Attacking, throwing, taking a hit or a shock, or an EMP drops it | @@ -118,7 +118,7 @@ The newer makers fill in the gaps under the big names. Each takes the same slot - **Brawler myomers**: a cased pair of forearms that hit hard, break rock, slam a held body down and force an airlock, slower than Gorilla Arms. - **Tinker's fingers** and the **Deepcore drill fist**: a looser set of fingertip tools, and a heavier drill on the same ore hopper. - **Holdout housing**: a snub revolver folded into the forearm, loaded one round at a time. -- **Hardweave plating** and the **Strut frame**: a heavier dermal weave, and a frame that keeps your bones whole. +- **Hardweave plating** and the **Strut frame**: a heavier dermal weave that turns 30% of bullets from the front, and a frame that keeps your bones whole. - **Deep-lung bladder**, **Heat-sink lining** and **Frostline lining**: a longer breath, a lining for the heat, a lining for the cold. - **Twitchwire** and the **Weaver shunt**: a twitchy reflex shunt, and the service model. - **Thumper ear**: a louder, shorter-ranged sonar behind the ear. @@ -137,4 +137,5 @@ Most of the roster also appears in zone loot caches. Street and utility chrome a - **EMP is the hard counter.** A pulse downs every piece you carry (nine seconds on a heavy blast, four and a half on a light one) then it reboots on its own. Gecko Grips spasm and throw whatever you are holding, Ghostskin camo collapses, and a Cascade window crashes on the spot. A Cradle tune-up cancels the reboot early. Actions and arm weapons stay unavailable while the chrome is offline. An EMP interrupting Lazarus during its charge does not spend the revival charge. - **Detection cuts both ways.** Chrome optics carry the Chrome Read, a scan that names a target's ware (street optics only count it), and the subject always feels the handshake. The Cargo Cavity never resolves on any scan, and Ghostskin scatters reads entirely. - **Death does not keep it.** Gibbing, dusting or destroying a limb ejects every piece onto the floor, and killers absolutely loot a chromed corpse. Cloning leaves the chrome on the body. +- **Plating turns bullets.** Dermal Mesh, Hardweave and Slabskin make bullets and buckshot pellets glance off with a spark and a ping, each pellet on its own. It never stops lasers, energy or blows, and never a shot into your back. The round keeps flying, so whoever stands beside you can still catch it. Each glance costs a little stamina, so steady fire still wears you down. A dodge and a glance together never stop more than 60% of shots. - **Nothing is subtle.** Slabskin sparks on every hit, the Chromatic Dermis flares whenever your chrome fires, and the heavy installs change how you look. Chromed is a fashion statement whether you want it or not.