diff --git a/__byond_var_id_padding.dm b/__byond_var_id_padding.dm new file mode 100644 index 00000000000..7d7f62da483 --- /dev/null +++ b/__byond_var_id_padding.dm @@ -0,0 +1,105 @@ +// BYOND bug workaround (VOIDCREW): DM numbers every variable name from one table, and a proc static, +// global or local that lands on id 65535 compiles wrong. A static or global fails with ": invalid +// variable", and a local gets a false "variable defined but not used" warning and reads the wrong value. +// Object vars and proc arguments on that id are fine. Once a build has more than 65535 names, about half +// of all changes put some static or local on the broken id. Seen on 516.1659 and 516.1667. +// +// This file is included first, so its 70,000 unused var names take ids 0 to 69999, and id 65535 always +// falls on one of them. A type holds at most about 65k vars, so they are split over seven subtypes. +// Nothing ever makes a /datum/byond_var_id_padding. Remove this once BYOND fixes it. + +#define VAR_ID_PAD_1(a, b, c, d, e) var/var_id_pad_##a##b##c##d##e +#define VAR_ID_PAD_10(a, b, c, d) VAR_ID_PAD_1(a, b, c, d, 0);VAR_ID_PAD_1(a, b, c, d, 1);VAR_ID_PAD_1(a, b, c, d, 2);VAR_ID_PAD_1(a, b, c, d, 3);VAR_ID_PAD_1(a, b, c, d, 4);VAR_ID_PAD_1(a, b, c, d, 5);VAR_ID_PAD_1(a, b, c, d, 6);VAR_ID_PAD_1(a, b, c, d, 7);VAR_ID_PAD_1(a, b, c, d, 8);VAR_ID_PAD_1(a, b, c, d, 9) +#define VAR_ID_PAD_100(a, b, c) VAR_ID_PAD_10(a, b, c, 0);VAR_ID_PAD_10(a, b, c, 1);VAR_ID_PAD_10(a, b, c, 2);VAR_ID_PAD_10(a, b, c, 3);VAR_ID_PAD_10(a, b, c, 4);VAR_ID_PAD_10(a, b, c, 5);VAR_ID_PAD_10(a, b, c, 6);VAR_ID_PAD_10(a, b, c, 7);VAR_ID_PAD_10(a, b, c, 8);VAR_ID_PAD_10(a, b, c, 9) +#define VAR_ID_PAD_1000(a, b) VAR_ID_PAD_100(a, b, 0);VAR_ID_PAD_100(a, b, 1);VAR_ID_PAD_100(a, b, 2);VAR_ID_PAD_100(a, b, 3);VAR_ID_PAD_100(a, b, 4);VAR_ID_PAD_100(a, b, 5);VAR_ID_PAD_100(a, b, 6);VAR_ID_PAD_100(a, b, 7);VAR_ID_PAD_100(a, b, 8);VAR_ID_PAD_100(a, b, 9) + +/datum/byond_var_id_padding + +/datum/byond_var_id_padding/part_0 + VAR_ID_PAD_1000(0, 0) + VAR_ID_PAD_1000(0, 1) + VAR_ID_PAD_1000(0, 2) + VAR_ID_PAD_1000(0, 3) + VAR_ID_PAD_1000(0, 4) + VAR_ID_PAD_1000(0, 5) + VAR_ID_PAD_1000(0, 6) + VAR_ID_PAD_1000(0, 7) + VAR_ID_PAD_1000(0, 8) + VAR_ID_PAD_1000(0, 9) + +/datum/byond_var_id_padding/part_1 + VAR_ID_PAD_1000(1, 0) + VAR_ID_PAD_1000(1, 1) + VAR_ID_PAD_1000(1, 2) + VAR_ID_PAD_1000(1, 3) + VAR_ID_PAD_1000(1, 4) + VAR_ID_PAD_1000(1, 5) + VAR_ID_PAD_1000(1, 6) + VAR_ID_PAD_1000(1, 7) + VAR_ID_PAD_1000(1, 8) + VAR_ID_PAD_1000(1, 9) + +/datum/byond_var_id_padding/part_2 + VAR_ID_PAD_1000(2, 0) + VAR_ID_PAD_1000(2, 1) + VAR_ID_PAD_1000(2, 2) + VAR_ID_PAD_1000(2, 3) + VAR_ID_PAD_1000(2, 4) + VAR_ID_PAD_1000(2, 5) + VAR_ID_PAD_1000(2, 6) + VAR_ID_PAD_1000(2, 7) + VAR_ID_PAD_1000(2, 8) + VAR_ID_PAD_1000(2, 9) + +/datum/byond_var_id_padding/part_3 + VAR_ID_PAD_1000(3, 0) + VAR_ID_PAD_1000(3, 1) + VAR_ID_PAD_1000(3, 2) + VAR_ID_PAD_1000(3, 3) + VAR_ID_PAD_1000(3, 4) + VAR_ID_PAD_1000(3, 5) + VAR_ID_PAD_1000(3, 6) + VAR_ID_PAD_1000(3, 7) + VAR_ID_PAD_1000(3, 8) + VAR_ID_PAD_1000(3, 9) + +/datum/byond_var_id_padding/part_4 + VAR_ID_PAD_1000(4, 0) + VAR_ID_PAD_1000(4, 1) + VAR_ID_PAD_1000(4, 2) + VAR_ID_PAD_1000(4, 3) + VAR_ID_PAD_1000(4, 4) + VAR_ID_PAD_1000(4, 5) + VAR_ID_PAD_1000(4, 6) + VAR_ID_PAD_1000(4, 7) + VAR_ID_PAD_1000(4, 8) + VAR_ID_PAD_1000(4, 9) + +/datum/byond_var_id_padding/part_5 + VAR_ID_PAD_1000(5, 0) + VAR_ID_PAD_1000(5, 1) + VAR_ID_PAD_1000(5, 2) + VAR_ID_PAD_1000(5, 3) + VAR_ID_PAD_1000(5, 4) + VAR_ID_PAD_1000(5, 5) + VAR_ID_PAD_1000(5, 6) + VAR_ID_PAD_1000(5, 7) + VAR_ID_PAD_1000(5, 8) + VAR_ID_PAD_1000(5, 9) + +/datum/byond_var_id_padding/part_6 + VAR_ID_PAD_1000(6, 0) + VAR_ID_PAD_1000(6, 1) + VAR_ID_PAD_1000(6, 2) + VAR_ID_PAD_1000(6, 3) + VAR_ID_PAD_1000(6, 4) + VAR_ID_PAD_1000(6, 5) + VAR_ID_PAD_1000(6, 6) + VAR_ID_PAD_1000(6, 7) + VAR_ID_PAD_1000(6, 8) + VAR_ID_PAD_1000(6, 9) + +#undef VAR_ID_PAD_1000 +#undef VAR_ID_PAD_100 +#undef VAR_ID_PAD_10 +#undef VAR_ID_PAD_1 diff --git a/code/game/objects/items.dm b/code/game/objects/items.dm index ac6cc0eb7f9..ee2256eb09b 100644 --- a/code/game/objects/items.dm +++ b/code/game/objects/items.dm @@ -664,7 +664,7 @@ owner.visible_message(span_danger("[owner] blocks [attack_text] with [src]!")) var/owner_turf = get_turf(owner) new block_effect(owner_turf, COLOR_YELLOW) - playsound(src, block_sound, BLOCK_SOUND_VOLUME, vary = TRUE) + playsound(src, hitby?.blocked_sound_for(src) || block_sound, BLOCK_SOUND_VOLUME, vary = TRUE) // VOIDCREW EDIT CHANGE - plasma_weapons: a bolt can carry its own shield block sound return TRUE /** diff --git a/code/modules/surgery/bodyparts/dismemberment.dm b/code/modules/surgery/bodyparts/dismemberment.dm index 0e25e1b981f..2f3abb73e0e 100644 --- a/code/modules/surgery/bodyparts/dismemberment.dm +++ b/code/modules/surgery/bodyparts/dismemberment.dm @@ -17,7 +17,8 @@ if(!silent) limb_owner.visible_message(span_danger("[limb_owner]'s [name] is violently dismembered!")) INVOKE_ASYNC(limb_owner, TYPE_PROC_REF(/mob, emote), "scream") - playsound(get_turf(limb_owner), 'sound/effects/dismember.ogg', 80, TRUE) + if(!plasma_melt_in_progress(limb_owner)) // VOIDCREW EDIT CHANGE - plasma_weapons: a plasma melt plays its own sound + playsound(get_turf(limb_owner), 'sound/effects/dismember.ogg', 80, TRUE) limb_owner.add_mood_event("dismembered_[body_zone]", /datum/mood_event/dismembered, src) limb_owner.add_mob_memory(/datum/memory/was_dismembered, lost_limb = src) diff --git a/sound/items/weapons/attributions.txt b/sound/items/weapons/attributions.txt index 6a7d58874db..a7e4c78883b 100644 --- a/sound/items/weapons/attributions.txt +++ b/sound/items/weapons/attributions.txt @@ -2,3 +2,5 @@ rust_sower_armbomb.ogg - EnterTheJake rust_sower_explode.ogg - EnterTheJake } + +laser.ogg and laser2.ogg are e-fire.ogg and heavy_laser.ogg from Shiptest (https://github.com/shiptest-ss13/Shiptest), CC BY-SA 3.0. diff --git a/sound/items/weapons/laser.ogg b/sound/items/weapons/laser.ogg index 5e4d3143097..bb8242352f2 100644 Binary files a/sound/items/weapons/laser.ogg and b/sound/items/weapons/laser.ogg differ diff --git a/sound/items/weapons/laser2.ogg b/sound/items/weapons/laser2.ogg index 30715495771..1b81326da3c 100644 Binary files a/sound/items/weapons/laser2.ogg and b/sound/items/weapons/laser2.ogg differ diff --git a/strings/wounds/plasma_scar_desc.json b/strings/wounds/plasma_scar_desc.json new file mode 100644 index 00000000000..ee96c39fd74 --- /dev/null +++ b/strings/wounds/plasma_scar_desc.json @@ -0,0 +1,18 @@ +{ + "plasmamoderate": [ + "a scatter of small, glossy pockmarks", + "a spray of pale pinprick scars" + ], + + "plasmasevere": [ + "a cluster of smooth, glassy pits", + "round scars with a faint green sheen", + "dimpled skin flecked with black glass" + ], + + "plasmacritical": [ + "a sunken, glass-smooth crater of scar tissue", + "a puckered scar the width of a fist, shot through with green-black glass", + "a knot of shiny scar tissue where something burned clean through" + ] +} diff --git a/tgstation.dme b/tgstation.dme index cf7dcde4fe3..5578b62f069 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -18,6 +18,7 @@ // END_PREFERENCES // BEGIN_INCLUDE +#include "__byond_var_id_padding.dm" #include "__odlint.dm" #include "_maps\_basemap.dm" #include "code\__byond_version_compat.dm" @@ -34,6 +35,7 @@ #include "code\__DEFINES\_tick.dm" #include "voidcrew\_DEFINES\core_integration.dm" // VOIDCREW EDIT: shared hooks must precede upstream macro use. #include "voidcrew\_DEFINES\modular_hooks.dm" // VOIDCREW EDIT: shared hooks must precede upstream macro use. +#include "voidcrew\_DEFINES\plasma_weapons.dm" // VOIDCREW EDIT: the plasma_weapons unit tests read these defines, and tests compile before voidcrew\_DEFINES. #include "code\__DEFINES\access.dm" #include "code\__DEFINES\achievements.dm" #include "code\__DEFINES\acid.dm" @@ -6925,6 +6927,7 @@ #include "voidcrew\edits\modules\power\supermatter_anomalies.dm" #include "voidcrew\edits\modules\power\turbine\turbine.dm" #include "voidcrew\edits\modules\projectiles\boxes_magazines\_box_magazine.dm" +#include "voidcrew\edits\modules\projectiles\projectile\beams.dm" #include "voidcrew\edits\modules\research\designs.dm" #include "voidcrew\edits\modules\research\designs\comp_board_designs.dm" #include "voidcrew\edits\modules\research\designs\machine_designs.dm" @@ -7468,6 +7471,23 @@ #include "voidcrew\modules\overmap\code\modules\overmap\zones\zone_teleport.dm" #include "voidcrew\modules\overmap\code\modules\overmap\zones\zone_weather.dm" #include "voidcrew\modules\paperwork\faxes.dm" +#include "voidcrew\modules\plasma_weapons\burn_wound_looks.dm" +#include "voidcrew\modules\plasma_weapons\frontier_lasers.dm" +#include "voidcrew\modules\plasma_weapons\plasma_ash.dm" +#include "voidcrew\modules\plasma_weapons\plasma_bolt.dm" +#include "voidcrew\modules\plasma_weapons\plasma_chamber.dm" +#include "voidcrew\modules\plasma_weapons\plasma_effects.dm" +#include "voidcrew\modules\plasma_weapons\plasma_flask.dm" +#include "voidcrew\modules\plasma_weapons\plasma_guns.dm" +#include "voidcrew\modules\plasma_weapons\plasma_heat.dm" +#include "voidcrew\modules\plasma_weapons\plasma_melt.dm" +#include "voidcrew\modules\plasma_weapons\plasma_pinch.dm" +#include "voidcrew\modules\plasma_weapons\plasma_pirates.dm" +#include "voidcrew\modules\plasma_weapons\plasma_shop.dm" +#include "voidcrew\modules\plasma_weapons\plasma_types.dm" +#include "voidcrew\modules\plasma_weapons\plasma_wound_care.dm" +#include "voidcrew\modules\plasma_weapons\plasma_wound_looks.dm" +#include "voidcrew\modules\plasma_weapons\plasma_wounds.dm" #include "voidcrew\modules\player_outposts\outpost_admin.dm" #include "voidcrew\modules\player_outposts\outpost_admin_bays.dm" #include "voidcrew\modules\player_outposts\outpost_admin_checkpoints.dm" diff --git a/tools/gear_catalog/ratings/guns.json b/tools/gear_catalog/ratings/guns.json index 079fdd4cbed..abac8235d5b 100644 --- a/tools/gear_catalog/ratings/guns.json +++ b/tools/gear_catalog/ratings/guns.json @@ -1183,6 +1183,26 @@ "reason": "Only affects plants.", "tier": "none" }, + "/obj/item/gun/energy/frontier_laser/pistol": { + "confidence": "medium", + "group": "beam weapons", + "hash": "", + "name": "Kestrel laser pistol", + "rank": 16, + "reason": "A weaker laser gun: 20 a shot, 20 shots, fits a belt.", + "score": 28, + "tier": "green" + }, + "/obj/item/gun/energy/frontier_laser/smg": { + "confidence": "medium", + "group": "beam weapons", + "hash": "", + "name": "Hornet laser SMG", + "rank": 9, + "reason": "The laser gun's beam on a held trigger: a laser carbine's damage per second in bigger hits.", + "score": 55, + "tier": "yellow" + }, "/obj/item/gun/energy/gravity_gun": { "confidence": "low", "group": "launchers and exotics", @@ -1465,6 +1485,66 @@ }, "tier": "yellow" }, + "/obj/item/gun/energy/plasma_weapon/pistol": { + "confidence": "low", + "group": "sidearms", + "hash": "", + "name": "Cinder plasma pistol", + "rank": 10, + "reason": "A plasma sidearm: slow bolts that burn, fed by hand from sheets or ore.", + "score": 55, + "score_inputs": { + "cell_fed": { + "value": false, + "why": "Fed by hand with plasma sheets or ore; no recharger." + }, + "points": { + "value": 15, + "why": "Bolts can't be reflected, break windows and doors faster, leave deep burns and can melt off a hurt limb." + } + }, + "tier": "yellow" + }, + "/obj/item/gun/energy/plasma_weapon/rifle": { + "confidence": "low", + "group": "rifles", + "hash": "", + "name": "Furnace plasma rifle", + "rank": 5, + "reason": "Close-range plasma rifle that overheats, fed by hand from sheets or ore.", + "score": 113, + "score_inputs": { + "cell_fed": { + "value": false, + "why": "Fed by hand with plasma sheets or ore; no recharger." + }, + "points": { + "value": 75, + "why": "Beats ablative armour and mirrors, breaks windows and doors twice as fast, lights fuel, leaves deep burns and melts off limbs that can't be reattached. Its bolts can be dodged at range." + } + }, + "tier": "red" + }, + "/obj/item/gun/energy/plasma_weapon/rifle/old_furnace": { + "confidence": "low", + "group": "rifles", + "hash": "", + "name": "Old Furnace", + "rank": 3, + "reason": "A Furnace that runs cooler and shoots straighter.", + "score": 123, + "score_inputs": { + "cell_fed": { + "value": false, + "why": "Fed by hand with plasma sheets or ore; no recharger." + }, + "points": { + "value": 85, + "why": "A Furnace that runs cooler and shoots straighter." + } + }, + "tier": "red" + }, "/obj/item/gun/energy/plasmacutter": { "confidence": "medium", "group": "tools and support", diff --git a/voidcrew/_DEFINES/plasma_weapons.dm b/voidcrew/_DEFINES/plasma_weapons.dm new file mode 100644 index 00000000000..ba6ebaa47b9 --- /dev/null +++ b/voidcrew/_DEFINES/plasma_weapons.dm @@ -0,0 +1,160 @@ +// Plasma and frontier laser weapons (voidcrew/modules/plasma_weapons/). +// Package: P0 seams. Values are final; packages read them and never redefine them. + +// ===== Flask, chamber and feeding ===== + +/// Mol of plasma a flask (and a gun's sealed chamber) holds: three sheets +#define PLASMA_FLASK_CAPACITY 60 +/// Mol one plasma sheet gives (a sheet grinds to 20 u, 1 u boils to 1 mol) +#define PLASMA_MOL_PER_SHEET 20 +/// Mol one plasma ore gives (half a sheet) +#define PLASMA_MOL_PER_ORE 10 +/// do_after per sheet or ore fed into a flask or gun +#define PLASMA_FEED_DELAY (0.5 SECONDS) +/// do_after to seat a flask in a pistol +#define PLASMA_LOAD_DELAY_PISTOL (1 SECONDS) +/// do_after to seat a flask in a rifle +#define PLASMA_LOAD_DELAY_RIFLE (1.5 SECONDS) +/// Mol a pistol bolt costs (casing e_cost) +#define PLASMA_SHOT_MOL_PISTOL 4 +/// Mol a rifle bolt costs (casing e_cost) +#define PLASMA_SHOT_MOL_RIFLE 5 + +// ===== Bolt: ignition, fire and ash ===== + +/// Temperature a passing bolt exposes its turf at (hotspot_expose) +#define PLASMA_BOLT_EXPOSE_TEMP 1000 +/// Volume a passing bolt exposes its turf with (hotspot_expose) +#define PLASMA_BOLT_EXPOSE_VOLUME 10 +/// A pistol bolt melts an arm or leg left within this much of its max damage +#define PLASMA_ASH_MARGIN_PISTOL 10 +/// A rifle bolt melts an arm or leg left within this much of its max damage +#define PLASMA_ASH_MARGIN_RIFLE 20 +/// The margin only counts on a limb that already had this much damage before the bolt, so no bolt melts +/// an unhurt arm or leg in one hit +#define PLASMA_ASH_PRIOR_DAMAGE 10 +/// Structure and door damage multiplier (demolition_mod) for a pistol bolt +#define PLASMA_DEMOLITION_PISTOL 1.5 +/// Structure and door damage multiplier (demolition_mod) for a rifle bolt +#define PLASMA_DEMOLITION_RIFLE 2 + +// ===== Heat and venting (Furnace and Old Furnace) ===== + +/// Heat a Furnace shot adds +#define PLASMA_HEAT_PER_SHOT 13.5 +/// Heat an Old Furnace shot adds +#define PLASMA_HEAT_PER_SHOT_UNIQUE 10 +/// After a shot at this heat or more, the gun boils +#define PLASMA_HEAT_BOIL 100 +/// Heat at which the coil overlay reaches step 1, 2 and 3 +#define PLASMA_HEAT_STEP_1 34 +#define PLASMA_HEAT_STEP_2 67 +#define PLASMA_HEAT_STEP_3 100 +/// Hot surroundings: heat gained per second is min(GAIN_MAX, (K - PLASMA_COOL_HOT_TEMP) / GAIN_SPAN) +#define PLASMA_HEAT_GAIN_MAX 10 +#define PLASMA_HEAT_GAIN_SPAN 50 + +/// Mol a boil takes from the chamber and releases on the holder's turf +#define PLASMA_BOIL_MOL 5 +/// Heat a boil removes +#define PLASMA_BOIL_COOL 15 +/// Integrity a boil costs the gun +#define PLASMA_BOIL_INTEGRITY 10 + +/// Mol a manual vent releases +#define PLASMA_VENT_MOL 5 +/// Heat a manual vent removes +#define PLASMA_VENT_COOL 50 +/// Time between manual vents +#define PLASMA_VENT_COOLDOWN (4 SECONDS) +/// A vent blocks firing for this long +#define PLASMA_VENT_LOCK (1 SECONDS) +/// Vented gas leaves at T20C + heat * this, in kelvin +#define PLASMA_VENT_TEMP_PER_HEAT 4 + +/// Heat lost per second, always +#define PLASMA_COOL_BASE 2.5 +/// Heat lost per second to the turf air, times the pressure and temperature factors below +#define PLASMA_COOL_CONVECT 8.5 +/// Pressure factor: min(kPa / PLASMA_COOL_PRESSURE_REF, PLASMA_COOL_PRESSURE_CAP) +#define PLASMA_COOL_PRESSURE_REF 101.3 +#define PLASMA_COOL_PRESSURE_CAP 1.5 +/// Temperature factor: clamp((PLASMA_COOL_HOT_TEMP - K) / PLASMA_COOL_TEMP_SPAN, 0, PLASMA_COOL_TEMP_CAP) +#define PLASMA_COOL_HOT_TEMP 573 +#define PLASMA_COOL_TEMP_SPAN 280 +#define PLASMA_COOL_TEMP_CAP 1.6 + +// ===== Effects ===== + +/// Wall glows allowed at once on one z-level; new ones over the cap are skipped +#define PLASMA_GLOW_CAP_PER_Z 40 +/// Soot decals allowed in the world; the oldest goes when a new one is made over the cap +#define PLASMA_SOOT_GLOBAL_CAP 300 +/// How long a wall glow (or a soot decal's glow) takes to cool to nothing +#define PLASMA_GLOW_DURATION (8 SECONDS) + +/// Light colour of a plasma bolt and of its splash and gutter-out +#define PLASMA_LIGHT_COLOR "#74FFA0" + +/// Furthest a bolt's sprite slides across its path onto a held shield on its last step, in pixels +#define PLASMA_BOLT_MAX_SLIDE 6 + +// ===== Sounds ===== +// Slots for the plasma weapons' own sounds. Each holds a stock sound for now; drop a new file in here to replace it. + +/// A bolt passing close by someone's head +#define PLASMA_SOUND_NEAR_MISS 'sound/items/weapons/plasma_cutter.ogg' +/// A bolt hitting a wall, floor, machine or anything else hard +#define PLASMA_SOUND_IMPACT_WALL 'sound/items/weapons/effects/searwall.ogg' +/// A bolt hitting a window or windoor +#define PLASMA_SOUND_IMPACT_GLASS 'sound/effects/glass/glasshit.ogg' +/// A held shield stopping a bolt. null plays the shield's own block sound. +#define PLASMA_SOUND_SHIELD_BLOCK null +/// The Furnace venting its heat +#define PLASMA_SOUND_FURNACE_VENT 'sound/machines/hiss.ogg' +/// The Furnace boiling its flask over +#define PLASMA_SOUND_FURNACE_BOIL 'sound/effects/gas_hissing.ogg' +/// A sheet fed into a flask, and the click after it +#define PLASMA_SOUND_FEED_SHEET 'sound/items/tools/welder.ogg' +#define PLASMA_SOUND_FEED_CLICK 'sound/machines/click.ogg' + +// ===== Wounds ===== + +/// Slag in a fresh plasma burn, by severity +#define PLASMA_WOUND_SLAG_MODERATE 3 +#define PLASMA_WOUND_SLAG_SEVERE 6 +#define PLASMA_WOUND_SLAG_CRITICAL 12 +/// Seconds a fresh plasma burn stays hot, by severity +#define PLASMA_WOUND_HEAT_MODERATE 10 +#define PLASMA_WOUND_HEAT_SEVERE 20 +#define PLASMA_WOUND_HEAT_CRITICAL 30 +/// Burn a second a hot plasma burn deals, by severity +#define PLASMA_WOUND_COOK_SEVERE 0.3 +#define PLASMA_WOUND_COOK_CRITICAL 0.5 +/// Slag a moderate plasma burn sheds a second once cool +#define PLASMA_WOUND_SHED_RATE 0.1 +/// Heat decay multipliers for a cold body and for cold air +#define PLASMA_WOUND_COLD_BODY_FACTOR 4 +#define PLASMA_WOUND_COLD_AIR_FACTOR 2 +/// kPa the cold air needs to count +#define PLASMA_WOUND_COLD_AIR_PRESSURE 20 +/// Time for one field pick +#define PLASMA_WOUND_PICK_TIME (2.5 SECONDS) +/// Slag taken out per field pick and per surgery step +#define PLASMA_WOUND_PICK_SLAG 3 +#define PLASMA_WOUND_SURGERY_SLAG 6 +/// Brute dealt per pick, with a proper tool and with an improvised one +#define PLASMA_WOUND_PICK_BRUTE 3 +#define PLASMA_WOUND_PICK_BRUTE_IMPROVISED 6 +/// Pick time multiplier for wirecutters or a scalpel +#define PLASMA_WOUND_IMPROVISED_TIME 1.25 +/// Units of plasma a fresh critical tops the blood up to +#define PLASMA_WOUND_BLOOD_PLASMA 4 +/// Critical with slag: limp slowdown, limp chance, fumble multiplier +#define PLASMA_WOUND_LIMP_SLOWDOWN 3 +#define PLASMA_WOUND_LIMP_CHANCE 40 +#define PLASMA_WOUND_FUMBLE 1.25 +/// Blood flow a bolt takes off each bleeding wound on the limb it hits +#define PLASMA_SEAR_FLOW 1.6 +/// Scar text file for plasma burns +#define PLASMA_SCAR_FILE "wounds/plasma_scar_desc.json" diff --git a/voidcrew/edits/modules/projectiles/projectile/beams.dm b/voidcrew/edits/modules/projectiles/projectile/beams.dm new file mode 100644 index 00000000000..53cdac8986d --- /dev/null +++ b/voidcrew/edits/modules/projectiles/projectile/beams.dm @@ -0,0 +1,55 @@ +// Voidcrew: the standard laser family is hitscan. A laser lands the moment it is fired and leaves +// tg's beam tracer along its path, instead of flying as a bolt. Same damage as upstream; the instant +// hit is the change. Reflection, blocking, glass and every on-hit signal run exactly as for a bolt, +// because tg's hitscan walks the same per-tile movement and hit code (projectile.dm process_hitscan). +// +// Stays a travelling bolt: disablers and other stun beams, X-ray, pulse, emitters, the ship defense +// turret, lasertag, CTF, mech lasers (below) and the two lasers whose look is the projectile itself. + +/obj/projectile/beam/laser + hitscan = TRUE + +/// Its ball grows as it flies (reduce_range), which a hitscan beam never shows. +/obj/projectile/beam/laser/accelerator + hitscan = FALSE + +/// Genetics laser eyes keep their own eye-beam sprite. +/obj/projectile/beam/laser/laser_eyes + hitscan = FALSE + +// Lasers outside /beam/laser that laser guns and turrets fire. Upstream gives them no tracer. + +/// Laser scattershot and scatter laser shells. +/obj/projectile/beam/scatter + hitscan = TRUE + tracer_type = /obj/effect/projectile/tracer/laser + muzzle_type = /obj/effect/projectile/muzzle/laser + impact_type = /obj/effect/projectile/impact/laser + +/// Laser gatling and some turrets. +/obj/projectile/beam/weak + hitscan = TRUE + tracer_type = /obj/effect/projectile/tracer/laser + muzzle_type = /obj/effect/projectile/muzzle/laser + impact_type = /obj/effect/projectile/impact/laser + +/// Practice laser guns. +/obj/projectile/beam/practice + hitscan = TRUE + tracer_type = /obj/effect/projectile/tracer/laser + muzzle_type = /obj/effect/projectile/muzzle/laser + impact_type = /obj/effect/projectile/impact/laser + +// Mech lasers stay bolts: exosuit weapons are balanced on their own, apart from hand guns. + +/obj/projectile/beam/laser/mech + hitscan = FALSE + +/obj/projectile/beam/laser/heavylaser/mech + hitscan = FALSE + +/obj/item/mecha_parts/mecha_equipment/weapon/energy/laser + projectile = /obj/projectile/beam/laser/mech + +/obj/item/mecha_parts/mecha_equipment/weapon/energy/laser/heavy + projectile = /obj/projectile/beam/laser/heavylaser/mech diff --git a/voidcrew/modules/loot/loot_guns.dm b/voidcrew/modules/loot/loot_guns.dm index da7a7b2fced..421e50bcace 100644 --- a/voidcrew/modules/loot/loot_guns.dm +++ b/voidcrew/modules/loot/loot_guns.dm @@ -47,11 +47,14 @@ GLOBAL_LIST_INIT(loot_gun_pools, list( LOOT_GUN_POOL_ARMORY = list( LOOT_WEAPON_GREEN = list( /obj/item/gun/ballistic/rifle/boltaction/surplus = 10, + /obj/item/gun/energy/frontier_laser/pistol = 10, ), LOOT_WEAPON_YELLOW = list( /obj/item/gun/energy/laser = 10, /obj/item/gun/energy/e_gun = 10, /obj/item/gun/ballistic/automatic/wt550 = 10, + /obj/item/gun/energy/frontier_laser/smg = 10, + /obj/item/gun/energy/plasma_weapon/pistol/loot = 10, ), LOOT_WEAPON_RED = list( /obj/item/gun/energy/laser/carbine = 10, @@ -82,11 +85,14 @@ GLOBAL_LIST_INIT(loot_gun_pools, list( ), LOOT_WEAPON_YELLOW = list( /obj/item/gun/ballistic/revolver = 10, + /obj/item/gun/energy/plasma_weapon/pistol/loot = 10, ), LOOT_WEAPON_RED = list( /obj/item/gun/ballistic/shotgun/bulldog/unrestricted = 10, /obj/item/gun/energy/laser/hellgun = 10, /obj/item/gun/ballistic/automatic/tommygun = 10, + /obj/item/gun/energy/plasma_weapon/rifle/loot = 10, + /obj/item/gun/energy/plasma_weapon/rifle/old_furnace = 1, ), ), LOOT_GUN_POOL_FREIGHT = list( @@ -95,6 +101,7 @@ GLOBAL_LIST_INIT(loot_gun_pools, list( /obj/item/gun/ballistic/automatic/pistol = 10, /obj/item/gun/ballistic/revolver/c38/detective = 10, /obj/item/gun/ballistic/shotgun/doublebarrel = 10, + /obj/item/gun/energy/frontier_laser/pistol = 10, ), LOOT_WEAPON_YELLOW = list( /obj/item/gun/ballistic/shotgun/riot = 10, diff --git a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_bosses.dm b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_bosses.dm index b2bc13a4dd1..7a53b6101ee 100644 --- a/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_bosses.dm +++ b/voidcrew/modules/npc_ships/code/faction_pirates/faction_pirate_bosses.dm @@ -170,6 +170,7 @@ r_hand = /obj/item/reagent_containers/hypospray/combat l_hand = /obj/item/gun/syringe/syndicate loot_pool = list(/obj/item/reagent_containers/hypospray/combat, /obj/item/gun/syringe/syndicate, /obj/item/grenade/gluon) + rare_loot_pool = list(/obj/item/gun/energy/plasma_weapon/rifle/old_furnace) plunder_credits = 3000 ai_controller = /datum/ai_controller/basic_controller/trooper/ranged var/projectiletype = /obj/projectile/beam/laser @@ -186,6 +187,8 @@ cooldown_time = ranged_cooldown,\ burst_shots = burst_shots,\ ) + // In a red zone it carries a Furnace instead (plasma_weapons module) + arm_plasma_rifle() // ==================== CHIEF AUDITOR (IRS) ==================== @@ -205,6 +208,7 @@ corpse = /obj/effect/mob_spawn/corpse/human/pirate/faction/irs/chief r_hand = /obj/item/gun/energy/e_gun/lethal loot_pool = list(/obj/item/gun/energy/laser, /obj/item/storage/bag/money/dutchmen) + rare_loot_pool = list(/obj/item/gun/energy/plasma_weapon/rifle/old_furnace) plunder_credits = 3000 ai_controller = /datum/ai_controller/basic_controller/trooper/ranged var/projectiletype = /obj/projectile/beam/laser @@ -221,6 +225,8 @@ cooldown_time = ranged_cooldown,\ burst_shots = burst_shots,\ ) + // In a red zone it carries a Furnace instead (plasma_weapons module) + arm_plasma_rifle() // ==================== THE BLACK KNIGHT (Medieval) ==================== diff --git a/voidcrew/modules/npc_ships/code/pirate_guns.dm b/voidcrew/modules/npc_ships/code/pirate_guns.dm index 9c98550f815..0467c08ee6a 100644 --- a/voidcrew/modules/npc_ships/code/pirate_guns.dm +++ b/voidcrew/modules/npc_ships/code/pirate_guns.dm @@ -155,16 +155,20 @@ GLOBAL_LIST_INIT(pirate_gun_default_weights, list( // ===== ARMING ===== -/// The loadout weights for `gunner`'s faction: type => weight, or null to keep its own gun +/// The loadout weights for `gunner`'s faction: type => weight, or null to keep its own gun. Yellow- and +/// red-zone gunners can also roll plasma guns (plasma_pirate_gun_weights(), plasma_weapons module). /proc/pirate_gun_weights_for(mob/living/basic/gunner) + var/list/weights var/mob/living/basic/trooper/pirate/faction/faction_pirate = gunner if(istype(faction_pirate)) if(faction_pirate.crew_keeps_gun) return null - return faction_pirate.crew_gun_weights || GLOB.pirate_gun_default_weights - if(istype(gunner, /mob/living/basic/trooper/pirate)) - return GLOB.pirate_gun_default_weights - return null + weights = faction_pirate.crew_gun_weights || GLOB.pirate_gun_default_weights + else if(istype(gunner, /mob/living/basic/trooper/pirate)) + weights = GLOB.pirate_gun_default_weights + if(!weights) + return null + return plasma_pirate_gun_weights(gunner, weights) /** * Hands `gunner` a rolled pirate gun in place of its stock ranged attack: the gun shows in its hand diff --git a/voidcrew/modules/plasma_weapons/burn_wound_looks.dm b/voidcrew/modules/plasma_weapons/burn_wound_looks.dm new file mode 100644 index 00000000000..ce446eeee6e --- /dev/null +++ b/voidcrew/modules/plasma_weapons/burn_wound_looks.dm @@ -0,0 +1 @@ +// Package: P10 burn wound looks (wave 2). Per-zone burn overlays for every flesh burn wound. diff --git a/voidcrew/modules/plasma_weapons/frontier_lasers.dm b/voidcrew/modules/plasma_weapons/frontier_lasers.dm new file mode 100644 index 00000000000..995b1ae546e --- /dev/null +++ b/voidcrew/modules/plasma_weapons/frontier_lasers.dm @@ -0,0 +1,62 @@ +// Package: P4 frontier lasers. The Kestrel laser pistol and Hornet laser SMG, their casings and the +// Kestrel's projectile. +// +// Both are plain cell lasers that take a recharger. Neither subtypes /obj/item/gun/energy/laser, so the +// laser export and the laser slapcraft skip them. The Hornet fires the stock /obj/projectile/beam/laser. + +/obj/item/gun/energy/frontier_laser + vary_fire_sound = FALSE + // The sprites have a full and an _empty state and no charge overlay states + automatic_charge_overlays = FALSE + +/obj/item/gun/energy/frontier_laser/update_icon_state() + var/obj/item/ammo_casing/energy/shot = length(ammo_type) ? ammo_type[select] : null + var/loaded = cell && istype(shot) && cell.charge >= shot.e_cost + icon_state = "[initial(icon_state)][loaded ? "" : "_empty"]" + return ..() + +/obj/item/gun/energy/frontier_laser/shoot_live_shot(mob/living/user, pointblank = FALSE, atom/pbtarget = null, message = TRUE) + . = ..() + if(!suppressed) + plasma_weapons_far_sound(src, far_fire_sound) + +// ===== Kestrel laser pistol ===== + +/obj/item/gun/energy/frontier_laser/pistol + w_class = WEIGHT_CLASS_NORMAL + slot_flags = ITEM_SLOT_BELT + weapon_weight = WEAPON_LIGHT + force = 10 + spread = 0 + recoil = 0 + far_fire_sound = 'voidcrew/modules/plasma_weapons/sound/kestrel_far.ogg' + +/// Energy guns take fire_delay from the casing's delay (update_ammo_types), so the 0.5 s lives here. +/obj/item/ammo_casing/energy/frontier_laser + e_cost = LASER_SHOTS(20, STANDARD_CELL_CHARGE) + delay = 5 + select_name = "kill" + fire_sound = 'voidcrew/modules/plasma_weapons/sound/kestrel_fire.ogg' + +/obj/projectile/beam/laser/frontier_pistol + damage = 20 + +// ===== Hornet laser SMG ===== + +/obj/item/gun/energy/frontier_laser/smg + w_class = WEIGHT_CLASS_BULKY + slot_flags = ITEM_SLOT_BACK + weapon_weight = WEAPON_MEDIUM + force = 12 + spread = 8 + recoil = 0 + far_fire_sound = 'voidcrew/modules/plasma_weapons/sound/hornet_far.ogg' + +/obj/item/gun/energy/frontier_laser/smg/Initialize(mapload) + . = ..() + AddComponent(/datum/component/automatic_fire, 0.35 SECONDS, allow_akimbo = FALSE) + +/// Fires the stock laser (owner decision 3); only the cost and sound differ from the laser gun's casing. +/obj/item/ammo_casing/energy/lasergun/frontier_smg + e_cost = LASER_SHOTS(20, STANDARD_CELL_CHARGE) + fire_sound = 'voidcrew/modules/plasma_weapons/sound/hornet_fire.ogg' diff --git a/voidcrew/modules/plasma_weapons/icons/attributions.txt b/voidcrew/modules/plasma_weapons/icons/attributions.txt new file mode 100644 index 00000000000..a2ac36629d7 --- /dev/null +++ b/voidcrew/modules/plasma_weapons/icons/attributions.txt @@ -0,0 +1,31 @@ +Mojave Sun (https://github.com/Mojave-Sun/mojave-sun-13), commit 1302c0c1f01, licensed CC BY-NC-SA 3.0 (Mojave Sun also licenses its DMI files under AGPL v3). +States are renamed from the originals listed below. + +guns.dmi + kestrel, kestrel_empty - mojave/icons/objects/guns/guns_world.dmi stanlas_pistol, stanlas_pistol_empty + hornet, hornet_empty - mojave/icons/objects/guns/guns_world.dmi rcw, rcw_empty + cinder - mojave/icons/objects/guns/guns_world.dmi plasmapistol + cinder_empty - mojave/icons/objects/guns/guns_world.dmi plasmapistol_empty, modified (plasma glass darkened) + furnace, furnace_empty - mojave/icons/objects/guns/guns_world.dmi enclaveplasma, enclaveplasma_empty + old_furnace, old_furnace_empty - mojave/icons/objects/guns/guns_world.dmi enclaveplasma, enclaveplasma_empty, modified (recoloured) + furnace_glow_1, furnace_glow_2, furnace_glow_3 - masks traced from mojave/icons/objects/guns/guns_world.dmi enclaveplasma, modified + +guns_lefthand.dmi, guns_righthand.dmi + kestrel - mojave/icons/mob/inhands/weapons/guns_inhand_{left,right}.dmi stanlas_pistol + cinder - mojave/icons/mob/inhands/weapons/guns_inhand_{left,right}.dmi plasmapistol + hornet_wielded - mojave/icons/mob/inhands/weapons/guns_inhand_{left,right}.dmi rcw_wielded + furnace_wielded - mojave/icons/mob/inhands/weapons/guns_inhand_{left,right}.dmi enclaveplasma_wielded + furnace_glow_1, furnace_glow_2, furnace_glow_3 - masks traced from mojave/icons/mob/inhands/weapons/guns_inhand_{left,right}.dmi enclaveplasma_wielded, modified + +guns_back.dmi + hornet - mojave/icons/mob/worn_guns.dmi rcw + furnace - mojave/icons/mob/worn_guns.dmi enclaveplasma + +flask.dmi + flask_0 - mojave/icons/objects/ammo/ammo_world.dmi plasma_empty + flask_3 - mojave/icons/objects/ammo/ammo_world.dmi plasma + flask_1, flask_2 - drawn from plasma and plasma_empty, modified + +projectiles.dmi, effects.dmi and the plasma_melt*.dmi files are original Voidcrew art (plasma_melt*.dmi: new sprites). + +plasma_wounds.dmi is new Voidcrew art (new sprites). diff --git a/voidcrew/modules/plasma_weapons/icons/burst_a.dmi b/voidcrew/modules/plasma_weapons/icons/burst_a.dmi new file mode 100644 index 00000000000..3a99bfcf09c Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/burst_a.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/burst_a_body.dmi b/voidcrew/modules/plasma_weapons/icons/burst_a_body.dmi new file mode 100644 index 00000000000..658547fde04 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/burst_a_body.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/burst_a_small.dmi b/voidcrew/modules/plasma_weapons/icons/burst_a_small.dmi new file mode 100644 index 00000000000..8979aa7c40d Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/burst_a_small.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/burst_b.dmi b/voidcrew/modules/plasma_weapons/icons/burst_b.dmi new file mode 100644 index 00000000000..8b7a28c5a21 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/burst_b.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/burst_b_body.dmi b/voidcrew/modules/plasma_weapons/icons/burst_b_body.dmi new file mode 100644 index 00000000000..cb84f94f13d Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/burst_b_body.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/burst_b_small.dmi b/voidcrew/modules/plasma_weapons/icons/burst_b_small.dmi new file mode 100644 index 00000000000..d13e4a0b464 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/burst_b_small.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/burst_c.dmi b/voidcrew/modules/plasma_weapons/icons/burst_c.dmi new file mode 100644 index 00000000000..8b81cdce090 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/burst_c.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/burst_c_body.dmi b/voidcrew/modules/plasma_weapons/icons/burst_c_body.dmi new file mode 100644 index 00000000000..004c7b7b55f Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/burst_c_body.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/burst_c_small.dmi b/voidcrew/modules/plasma_weapons/icons/burst_c_small.dmi new file mode 100644 index 00000000000..68548376095 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/burst_c_small.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/effects.dmi b/voidcrew/modules/plasma_weapons/icons/effects.dmi new file mode 100644 index 00000000000..8d55fa0b16a Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/effects.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/flask.dmi b/voidcrew/modules/plasma_weapons/icons/flask.dmi new file mode 100644 index 00000000000..49093f2a367 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/flask.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/guns.dmi b/voidcrew/modules/plasma_weapons/icons/guns.dmi new file mode 100644 index 00000000000..4954184d8b2 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/guns.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/guns_back.dmi b/voidcrew/modules/plasma_weapons/icons/guns_back.dmi new file mode 100644 index 00000000000..980c0532f20 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/guns_back.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/guns_lefthand.dmi b/voidcrew/modules/plasma_weapons/icons/guns_lefthand.dmi new file mode 100644 index 00000000000..522b6cfbb56 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/guns_lefthand.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/guns_righthand.dmi b/voidcrew/modules/plasma_weapons/icons/guns_righthand.dmi new file mode 100644 index 00000000000..14db3e3dd15 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/guns_righthand.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/plasma_melt.dmi b/voidcrew/modules/plasma_weapons/icons/plasma_melt.dmi new file mode 100644 index 00000000000..36ed79d3172 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/plasma_melt.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/plasma_melt_l_arm.dmi b/voidcrew/modules/plasma_weapons/icons/plasma_melt_l_arm.dmi new file mode 100644 index 00000000000..85c91d20609 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/plasma_melt_l_arm.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/plasma_melt_l_leg.dmi b/voidcrew/modules/plasma_weapons/icons/plasma_melt_l_leg.dmi new file mode 100644 index 00000000000..233b69ebc5d Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/plasma_melt_l_leg.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/plasma_melt_r_arm.dmi b/voidcrew/modules/plasma_weapons/icons/plasma_melt_r_arm.dmi new file mode 100644 index 00000000000..015bd31c229 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/plasma_melt_r_arm.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/plasma_melt_r_leg.dmi b/voidcrew/modules/plasma_weapons/icons/plasma_melt_r_leg.dmi new file mode 100644 index 00000000000..1bdccba792a Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/plasma_melt_r_leg.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/plasma_shield.dmi b/voidcrew/modules/plasma_weapons/icons/plasma_shield.dmi new file mode 100644 index 00000000000..918f8229e2a Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/plasma_shield.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/plasma_shield_coat.dmi b/voidcrew/modules/plasma_weapons/icons/plasma_shield_coat.dmi new file mode 100644 index 00000000000..0b5236e7dd8 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/plasma_shield_coat.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/plasma_wounds.dmi b/voidcrew/modules/plasma_weapons/icons/plasma_wounds.dmi new file mode 100644 index 00000000000..22e50e9652e Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/plasma_wounds.dmi differ diff --git a/voidcrew/modules/plasma_weapons/icons/projectiles.dmi b/voidcrew/modules/plasma_weapons/icons/projectiles.dmi new file mode 100644 index 00000000000..38050064021 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/icons/projectiles.dmi differ diff --git a/voidcrew/modules/plasma_weapons/plasma_ash.dm b/voidcrew/modules/plasma_weapons/plasma_ash.dm new file mode 100644 index 00000000000..454a1148eac --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_ash.dm @@ -0,0 +1,31 @@ +// Package: P1 bolt. Melted limbs: the COMSIG_MOB_AFTER_APPLY_DAMAGE handler that burns away an arm or leg +// through tg's own burn dismemberment. + +/// Registered on a carbon this bolt hits; runs once, after the bolt's damage lands. First sears the bleeding +/// cuts on the hit limb, any zone. Then the arm or leg melts +/// through tg's WOUND_BURN loss (no spray, ash decal) when it was already hurt before this bolt +/// (PLASMA_ASH_PRIOR_DAMAGE) and is now within ash_margin of its max damage, or, for a rifle bolt, when +/// it already carried a plasma burn-through with slag still in it. No bolt melts an unhurt limb in one hit. +/// Heads and chests never melt. +/obj/projectile/energy/plasma_bolt/proc/plasma_check_ash(mob/living/carbon/source, damage_dealt, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item) + SIGNAL_HANDLER + UnregisterSignal(source, COMSIG_MOB_AFTER_APPLY_DAMAGE) + if(attacking_item != src || !iscarbon(source)) + return + var/obj/item/bodypart/limb = isbodypart(def_zone) ? def_zone : source.get_bodypart(check_zone(def_zone)) + if(!limb || limb.owner != source || limb.body_zone != ash_zone) + return + plasma_sear_cuts(limb) + if(!(limb.body_zone in GLOB.limb_zones)) + return + if(!limb.can_dismember() || (limb.bodypart_flags & BODYPART_UNREMOVABLE)) + return + var/burned_through = ash_critical_burns && ash_prior_critical + var/worn_through = ash_prior_damage >= PLASMA_ASH_PRIOR_DAMAGE && limb.get_damage() >= limb.max_damage - ash_margin + if(!burned_through && !worn_through) + return + if(!plasma_melt_limb(limb, attack_direction)) // tg's WOUND_BURN loss, plus the melt picture (plasma_melt.dm) + return + // A melt is a burn-through: the burst on this hit plays C + var/obj/effect/temp_visual/plasma_burst/burst = burst_ref?.resolve() + burst?.limb_melted() diff --git a/voidcrew/modules/plasma_weapons/plasma_bolt.dm b/voidcrew/modules/plasma_weapons/plasma_bolt.dm new file mode 100644 index 00000000000..e929a885c94 --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_bolt.dm @@ -0,0 +1,198 @@ +// Package: P1 bolt. The plasma bolts and their casings: stats, fuel ignition in Moved(), structure +// damage, melt registration in on_hit(), and the calls out to the P8 effects. A bolt never sets a +// person alight (design decision 6); its burns come through tg's own burn wounds. + +/obj/projectile/energy/plasma_bolt + damage = 32 + damage_type = BURN + armor_flag = ENERGY + armour_penetration = 10 + // Burn wounds through tg's roll (wounds.dm check_wounding); see plasma_sim.py section 9. + wound_bonus = 10 + exposed_wound_bonus = 25 + // Scoped to structures and doors by get_demolition_modifier() below. + demolition_mod = PLASMA_DEMOLITION_PISTOL + speed = 1 + range = 20 + // Plasma is matter: no mirror turns it, and glass and grilles stop it (pass_flags stays PASSTABLE). + reflectable = NONE + dismemberment = 0 + hitsound = 'voidcrew/modules/plasma_weapons/sound/plasma_hit_pistol.ogg' + hitsound_wall = PLASMA_SOUND_IMPACT_WALL + impact_effect_type = null + light_system = OVERLAY_LIGHT + light_range = 2 + light_power = 1.2 + light_color = PLASMA_LIGHT_COLOR + +/obj/projectile/energy/plasma_bolt/heavy + damage = 45 + hitsound = 'voidcrew/modules/plasma_weapons/sound/plasma_hit_rifle.ogg' + armour_penetration = 20 + wound_bonus = 20 + exposed_wound_bonus = 30 + demolition_mod = PLASMA_DEMOLITION_RIFLE + range = 25 + light_range = 2.5 + light_power = 1.6 + +/obj/projectile/energy/plasma_bolt + /// Where a person hit is struck, in pixels from the centre of their turf, set by the burst + var/list/impact_point + /// The turf impact_point is measured on + var/turf/impact_turf + /// Whether the sprite also slides sideways onto impact_point on its last step, as onto a held shield + var/impact_slide = FALSE + /// Played when it hits a window or windoor + var/hitsound_glass = PLASMA_SOUND_IMPACT_GLASS + /// Played to someone it passes close by + var/near_miss_sound = PLASMA_SOUND_NEAR_MISS + /// Played instead of a held shield's own block sound when one stops it; null keeps the shield's + var/shield_block_sound = PLASMA_SOUND_SHIELD_BLOCK + +/obj/projectile/energy/plasma_bolt/Destroy() + hissed_mobs = null + impact_turf = null + return ..() + +/// The last stretch to an impact: the sprite stops with its nose where the burst or splash draws, then +/// goes out, so it never slides over the flash or into the body +/obj/projectile/energy/plasma_bolt/move_animate(animate_x, animate_y, animate_time = world.tick_lag, deleting = FALSE) + if(!deleting || deletion_queued != PROJECTILE_IMPACT_DELETE || !movement_vector) + return FALSE + var/dx = movement_vector.pixel_x + var/dy = movement_vector.pixel_y + // Pixels from the sprite's centre to the front of its glow: 7 on the pistol bolt, 13 on the rifle's + var/offset = istype(src, /obj/projectile/energy/plasma_bolt/heavy) ? 13 : 7 + var/full = (animate_x - pixel_x) * dx + (animate_y - pixel_y) * dy + var/stop = full - offset + if(impact_point && impact_turf == loc) + stop = (impact_point[1] - pixel_x) * dx + (impact_point[2] - pixel_y) * dy - offset + stop = clamp(stop, 0, max(full, 0) + ICON_SIZE_ALL / 2) + // Onto a held shield it slides a few pixels across its path, so it meets the shield and not the air beside it + var/slide_x = 0 + var/slide_y = 0 + if(impact_slide && impact_point && impact_turf == loc) + var/across = clamp((impact_point[1] - pixel_x) * dy - (impact_point[2] - pixel_y) * dx, -PLASMA_BOLT_MAX_SLIDE, PLASMA_BOLT_MAX_SLIDE) + slide_x = dy * across + slide_y = -dx * across + var/anim_time = full > 0 ? animate_time * stop / full : 0 + // Drop this tick's earlier steps first: a step taken before the bolt crossed into the target's turf is in + // the last turf's pixels, and chained after it this one would overshoot the stop by up to a tile + animate(src) + if(impact_slide && impact_turf == loc) + // On the holder's own screen row, so its layer alone puts it in front of the holder or behind + pixel_z += pixel_y + pixel_y = 0 + animate(src, pixel_x = pixel_x + dx * stop + slide_x, pixel_z = pixel_z + dy * stop + slide_y, time = anim_time, flags = ANIMATION_PARALLEL) + else + animate(src, pixel_x = pixel_x + dx * stop + slide_x, pixel_y = pixel_y + dy * stop + slide_y, time = anim_time, flags = ANIMATION_PARALLEL) + // A person's burst shows a tick after the hit (a splash shows at once): a bolt that stops early, as on the + // near edge of a body lying down, waits at its stop until the burst is up instead of leaving empty frames. + // The bolt is deleted by then anyway, so it never sits over the burst for more than a frame. + if(impact_point && impact_turf == loc && anim_time < world.tick_lag) + animate(alpha = 255, time = 2 * world.tick_lag - anim_time, flags = ANIMATION_CONTINUE) + animate(alpha = 0, time = 0, flags = ANIMATION_CONTINUE) + return TRUE + +/obj/projectile/energy/plasma_bolt/Moved(atom/old_loc, movement_dir, forced, list/old_locs, momentum_change = TRUE) + . = ..() + if(!fired) + return + plasma_expose_turf(loc) + +/// Lights real fuel gas on an open turf. Only hotspot_expose: clean air and vacuum never catch. +/obj/projectile/energy/plasma_bolt/proc/plasma_expose_turf(turf/open/where) + if(!isopenturf(where)) + return + where.hotspot_expose(PLASMA_BOLT_EXPOSE_TEMP, PLASMA_BOLT_EXPOSE_VOLUME) + +/obj/projectile/energy/plasma_bolt/on_hit(atom/target, blocked = 0, pierce_hit) + // One sound per hit. tg plays the person sound on anything that isn't a wall; here it is kept for a person the bolt + // reaches, a block plays only the blocker's own sound, and anything else gets its surface sound below. + var/person_sound = hitsound + if(!isliving(target) || blocked >= 100) + hitsound = null + . = ..() + hitsound = person_sound + plasma_surface_sound(target) + plasma_impact_visuals(target, blocked) + plasma_expose_turf(get_turf(target)) + if(. != BULLET_ACT_HIT || !iscarbon(target) || blocked >= 100) + return + var/mob/living/carbon/victim = target + // on_hit runs before the damage lands: note the limb's state now, then sear its cuts and check for a melt after it. + var/obj/item/bodypart/limb = victim.get_bodypart(check_zone(def_zone)) + if(!limb) + return + ash_zone = limb.body_zone + ash_prior_damage = limb.get_damage() + ash_prior_critical = plasma_burn_through_ready(limb) + RegisterSignal(victim, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(plasma_check_ash), override = TRUE) + +/// The sound of a bolt hitting something that isn't alive. tg already plays hitsound_wall on a turf. +/obj/projectile/energy/plasma_bolt/proc/plasma_surface_sound(atom/target) + if(isturf(target) || isliving(target)) + return + var/turf/target_turf = get_turf(target) + if(!target_turf) + return + if(istype(target, /obj/structure/window) || istype(target, /obj/machinery/door/window)) + playsound(target_turf, hitsound_glass, 50, TRUE, -1) + return + playsound(target_turf, hitsound_wall, clamp(vol_by_damage() + (suppressed ? 0 : 20), 0, 100), TRUE, -1) + +/// The sound a held item plays when it blocks this, in place of its own block_sound; null keeps the item's. +/// Called from tg's /obj/item/proc/hit_reaction (VOIDCREW EDIT). +/atom/movable/proc/blocked_sound_for(obj/item/blocker) + return null + +/// A held shield that stops a bolt plays the bolt's shield sound instead of its own, when it has one +/obj/projectile/energy/plasma_bolt/blocked_sound_for(obj/item/blocker) + if(istype(blocker, /obj/item/shield)) + return shield_block_sound + return null + +/// Plasma breaks structures and doors faster; mechs, turrets and other machines take normal damage. +/obj/projectile/energy/plasma_bolt/get_demolition_modifier(obj/target) + if(isstructure(target) || istype(target, /obj/machinery/door)) + return ..() + return 1 + +/obj/projectile/energy/plasma_bolt/on_range() + plasma_gutter_visual() + return ..() + +/obj/projectile/energy/plasma_bolt/scan_moved_turf() + . = ..() + plasma_hissby_check() + +// ===== Casings ===== + +/obj/item/ammo_casing/energy/plasma_bolt + e_cost = PLASMA_SHOT_MOL_PISTOL + select_name = "plasma" + fire_sound = 'voidcrew/modules/plasma_weapons/sound/cinder_fire.ogg' + // The barrel pinch (P9) is the muzzle effect; tg's red laser flash would clash with it. + firing_effect_type = null + +/obj/item/ammo_casing/energy/plasma_bolt/heavy + e_cost = PLASMA_SHOT_MOL_RIFLE + fire_sound = 'voidcrew/modules/plasma_weapons/sound/furnace_fire.ogg' + +/// Sears the bleeding cuts on a limb a bolt just hit: each bleeding wound loses PLASMA_SEAR_FLOW blood flow, +/// and tg's own slash and pierce code closes or demotes it. One line if any cut closed or dropped a tier. +/proc/plasma_sear_cuts(obj/item/bodypart/limb) + var/mob/living/carbon/victim = limb?.owner + if(!victim || !LAZYLEN(limb.wounds)) + return + var/seared = FALSE + for(var/datum/wound/cut as anything in limb.wounds.Copy()) + if(cut.blood_flow <= 0) + continue + cut.adjust_blood_flow(-PLASMA_SEAR_FLOW) + // A cut that closed is deleted; one that dropped a tier was replaced and left the limb + if(QDELETED(cut) || !(cut in limb.wounds)) + seared = TRUE + if(seared) + victim.visible_message(span_warning("The cuts on [victim]'s [limb.plaintext_zone] sear shut!"), span_warning("The cuts on your [limb.plaintext_zone] sear shut!")) diff --git a/voidcrew/modules/plasma_weapons/plasma_chamber.dm b/voidcrew/modules/plasma_weapons/plasma_chamber.dm new file mode 100644 index 00000000000..2872fbb99aa --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_chamber.dm @@ -0,0 +1,42 @@ +// Package: P2 guns and flask. The sealed plasma chamber cell: refuses outside charge, magic charge, +// corruption, explosion and reagents. +// +// Its charge is mol of plasma, and plasma only comes in from a flask or by feeding (plasma_guns.dm). +// Everything that would charge a normal cell is refused here. The gun also keeps the chamber out of +// its contents (plasma_weapon/Initialize), because some tg code writes `charge` straight onto every +// power_store in a mob's contents (battery plants, yellow regenerative extracts, the experimentor), +// and no override can catch a raw var write. + +/obj/item/stock_parts/power_store/cell/plasma_chamber + desc = "A sealed glass chamber." + maxcharge = PLASMA_FLASK_CAPACITY + empty = TRUE + ratingdesc = FALSE + charge_light_type = null + connector_type = null + +/obj/item/stock_parts/power_store/cell/plasma_chamber/give(amount) + return 0 + +/obj/item/stock_parts/power_store/cell/plasma_chamber/change(amount) + return 0 + +/obj/item/stock_parts/power_store/cell/plasma_chamber/on_magic_charge(datum/source, datum/action/cooldown/spell/charge/spell, mob/living/caster) + SIGNAL_HANDLER + return NONE + +/obj/item/stock_parts/power_store/cell/plasma_chamber/corrupt(force) + return + +/obj/item/stock_parts/power_store/cell/plasma_chamber/explode() + return + +/// No reagent holder, so nothing can rig it +/obj/item/stock_parts/power_store/cell/plasma_chamber/create_reagents(max_vol, flags) + return + +/// Plasma isn't charge; an EMP leaves it alone +/obj/item/stock_parts/power_store/cell/plasma_chamber/emp_act(severity) + var/held = charge + . = ..() + charge = held diff --git a/voidcrew/modules/plasma_weapons/plasma_effects.dm b/voidcrew/modules/plasma_weapons/plasma_effects.dm new file mode 100644 index 00000000000..ce7ff70e4cc --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_effects.dm @@ -0,0 +1,929 @@ +// Package: P8 effects. Impact splash, wall glow, floor soot, gutter-out, vent puff, the near-miss +// hiss and the distant firing sound. +// +// Every effect here hangs off a real event (a hit, a bolt running out of range, a vent) and changes no +// game state. Lights are OVERLAY_LIGHT only, steam uses the shared particle pool, wall glows are capped +// per z-level and soot is capped globally. + +/// Furthest a distant firing sound carries, in tiles +#define PLASMA_FAR_SOUND_RANGE 40 +/// Distant sound volume at the near edge of its band +#define PLASMA_FAR_SOUND_VOL_NEAR 60 +/// Distant sound volume at PLASMA_FAR_SOUND_RANGE +#define PLASMA_FAR_SOUND_VOL_FAR 30 +/// A bolt must be this many tiles from where it was fired before it hisses past anyone +#define PLASMA_HISSBY_MIN_TRAVEL 3 +/// Shared particle pool for the flesh splash's steam, kept apart because its holders take the splash's layer +#define PLASMA_SPLASH_STEAM_KEY "plasma_splash_steam" +/// Person-hit burst looks: molten splatter, flash and ring, burn-through +#define PLASMA_BURST_SPLATTER "A" +#define PLASMA_BURST_FLASH "B" +#define PLASMA_BURST_THROUGH "C" +/// Longest a burst's frames run, floor slag included +#define PLASMA_BURST_DURATION (2.7 SECONDS) +/// How long a burst waits after the hit before it shows: the bolt's sprite still has this far to fly on +/// the client, and the flash belongs where it stops +#define PLASMA_BURST_DELAY (1 TICKS) +/// How long a shield block's splash and slag last, the last floor slag included +#define PLASMA_SHIELD_DURATION (2.2 SECONDS) + +/// Live wall and soot glows per z-level, keyed by "[z]" +GLOBAL_LIST_EMPTY(plasma_wall_glows_by_z) + +// ===== Bolt core ===== + +/obj/projectile/energy/plasma_bolt + /// Emissive mask for the bolt's white core, so it stays bright in the dark + var/core_state = "plasma_core" + +/obj/projectile/energy/plasma_bolt/heavy + core_state = "plasma_core_heavy" + +/obj/projectile/energy/plasma_bolt/Initialize(mapload) + . = ..() + update_appearance(UPDATE_OVERLAYS) + +/obj/projectile/energy/plasma_bolt/update_overlays() + . = ..() + . += emissive_appearance(icon, core_state, src) + +// ===== Calls from the bolt (P1) ===== + +/// Splash, wall glow and soot for a bolt hitting `target` +/obj/projectile/energy/plasma_bolt/proc/plasma_impact_visuals(atom/target, blocked) + var/turf/target_turf = get_turf(target) + if(!target_turf) + return + + var/splash_state = "splash_wall" + var/turn_angle = angle + var/behind_body = FALSE + var/splash_x = impact_x + var/splash_y = impact_y + if(ishuman(target)) + var/mob/living/carbon/human/victim = target + var/side = REVERSE_DIR(angle2dir_cardinal(angle)) + if(blocked < 100) + burst_ref = WEAKREF(new /obj/effect/temp_visual/plasma_burst(target_turf, victim, src, side)) + return + plasma_take_back_block_star(target_turf) + // A held shield takes it on its own pixels: the bolt bursts on it, coats it and drips off as slag + var/obj/item/shield/shield = locate() in victim.held_items + if(shield) + var/key = plasma_shield_key(victim, shield, side) + var/list/anchor = GLOB.plasma_shield_anchors[key] + var/under = anchor[3] || !!victim.lying_angle + impact_point = list(anchor[1] - ICON_SIZE_X / 2 + victim.pixel_x, anchor[2] - ICON_SIZE_Y / 2 + victim.pixel_y) + // Lying down, the shield lies with the body: the bolt ends on the body's middle + if(victim.lying_angle) + var/list/hit = plasma_burst_hit(victim, side, "c") + impact_point = list(hit[4] - ICON_SIZE_X / 2, hit[5] - ICON_SIZE_Y / 2) + impact_turf = target_turf + impact_slide = TRUE + // A body between the shooter and the shield hides the bolt's end as well as the splash + layer = victim.layer + (under ? -0.02 : 0.02) + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(plasma_shield_splash), target_turf, WEAKREF(victim), WEAKREF(shield), key, victim.layer, !!victim.lying_angle), PLASMA_BURST_DELAY) + return + // Anything else that blocks it (a blade) throws an armour splash off the body + behind_body = (side == NORTH && !victim.lying_angle) + impact_point = list(splash_x, behind_body ? ICON_SIZE_Y / 2 : splash_y) + impact_turf = target_turf + addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(plasma_block_splash), target_turf, splash_x, splash_y, turn_angle, behind_body), PLASMA_BURST_DELAY) + return + else if(isliving(target)) + if(blocked >= 100) + splash_state = "splash_armour" + else + splash_state = "splash_flesh" + turn_angle = 0 // steam rises; the flash has no direction + // A bolt travelling south strikes the far side of the body, which the body hides + behind_body = (angle > 135 && angle < 225) + else if(istype(target, /obj/structure/window) || istype(target, /obj/machinery/door/window)) + splash_state = "splash_glass" + + new /obj/effect/temp_visual/plasma_splash(target_turf, splash_x, splash_y, splash_state, turn_angle, behind_body) + + if(!iswallturf(target)) + if(isopenturf(target)) + plasma_soot_at(target, impact_x, impact_y) + return + + // One hit, one mark: the glow on the wall face, nothing on the floor in front of it + plasma_wall_glow_at(target, impact_x, impact_y) + +/// The splash of a bolt a person blocked, shown once the bolt's sprite has reached it +/proc/plasma_block_splash(turf/where, px, py, turn_angle, behind_body) + if(!where) + return + new /obj/effect/temp_visual/plasma_splash(where, px, py, "splash_armour", turn_angle, behind_body) + +// ===== Shield block ===== +// Splash and slag (design decision 15), baked from the approved draft into plasma_shield.dmi and +// plasma_shield_coat.dmi, one state per shield, hand, holder facing and side the shot came from. The bolt +// bursts flat on the held shield; molten coats the shield's own pixels, runs down, beads on the lower rim and +// drips to the floor as cooling slag. A shot the shield meets in view draws over the holder; a shot the body +// stands in front of (from behind, or on the far side of the body) draws under the holder, and so does the bolt. + +/// Where a blocked bolt lands, by "[shield]_[hand][holder facing][side the shot came from]": list(x, y in pixels +/// from the tile's bottom left, whether it draws under the holder) +GLOBAL_LIST_INIT(plasma_shield_anchors, list( + "riot_LSS" = list(22, 14, 0), "riot_LSN" = list(16, 17, 1), "riot_LSE" = list(26, 16, 0), "riot_LSW" = list(7, 16, 1), + "riot_LNS" = list(16, 17, 1), "riot_LNN" = list(16, 17, 1), "riot_LNE" = list(24, 16, 1), "riot_LNW" = list(5, 16, 0), + "riot_LES" = list(19, 13.9, 0), "riot_LEN" = list(16, 17, 1), "riot_LEE" = list(21, 16, 0), "riot_LEW" = list(11, 16, 1), + "riot_LWS" = list(13, 13.9, 0), "riot_LWN" = list(16, 17, 1), "riot_LWE" = list(21, 16, 1), "riot_LWW" = list(11, 16, 0), + "riot_RSS" = list(10, 14, 0), "riot_RSN" = list(16, 17, 1), "riot_RSE" = list(24, 16, 1), "riot_RSW" = list(6, 16, 0), + "riot_RNS" = list(16, 17, 1), "riot_RNN" = list(16, 17, 1), "riot_RNE" = list(27, 16, 0), "riot_RNW" = list(7, 16, 1), + "riot_RES" = list(17, 13.9, 0), "riot_REN" = list(16, 17, 1), "riot_REE" = list(19, 16, 0), "riot_REW" = list(11, 16, 1), + "riot_RWS" = list(15, 13.9, 0), "riot_RWN" = list(16, 17, 1), "riot_RWE" = list(21, 16, 1), "riot_RWW" = list(13, 16, 0), + "buckler_LSS" = list(22, 13.5, 0), "buckler_LSN" = list(16, 17, 1), "buckler_LSE" = list(27, 16, 0), "buckler_LSW" = list(7, 16, 1), + "buckler_LNS" = list(16, 17, 1), "buckler_LNN" = list(16, 17, 1), "buckler_LNE" = list(24, 16, 1), "buckler_LNW" = list(3, 16, 0), + "buckler_LES" = list(22.3, 16, 0), "buckler_LEN" = list(16, 17, 1), "buckler_LEE" = list(24, 16, 0), "buckler_LEW" = list(11, 16, 1), + "buckler_LWS" = list(14.7, 15, 0), "buckler_LWN" = list(16, 17, 1), "buckler_LWE" = list(21, 16, 1), "buckler_LWW" = list(13, 16, 0), + "buckler_RSS" = list(10, 13.5, 0), "buckler_RSN" = list(16, 17, 1), "buckler_RSE" = list(24, 16, 1), "buckler_RSW" = list(5, 16, 0), + "buckler_RNS" = list(16, 17, 1), "buckler_RNN" = list(16, 17, 1), "buckler_RNE" = list(28, 16, 0), "buckler_RNW" = list(7, 16, 1), + "buckler_RES" = list(17.3, 15, 0), "buckler_REN" = list(16, 17, 1), "buckler_REE" = list(19, 16, 0), "buckler_REW" = list(11, 16, 1), + "buckler_RWS" = list(9.7, 16, 0), "buckler_RWN" = list(16, 17, 1), "buckler_RWE" = list(21, 16, 1), "buckler_RWW" = list(8, 16, 0), + "eshield_LSS" = list(22.5, 14, 0), "eshield_LSN" = list(16, 17, 1), "eshield_LSE" = list(31, 16, 0), "eshield_LSW" = list(7, 16, 1), + "eshield_LNS" = list(16, 17, 1), "eshield_LNN" = list(16, 17, 1), "eshield_LNE" = list(24, 16, 1), "eshield_LNW" = list(1, 16, 0), + "eshield_LES" = list(20.1, 14, 0), "eshield_LEN" = list(16, 17, 1), "eshield_LEE" = list(21, 16, 0), "eshield_LEW" = list(11, 16, 1), + "eshield_LWS" = list(11.9, 14, 0), "eshield_LWN" = list(16, 17, 1), "eshield_LWE" = list(21, 16, 1), "eshield_LWW" = list(11, 16, 0), + "eshield_RSS" = list(9.5, 14, 0), "eshield_RSN" = list(16, 17, 1), "eshield_RSE" = list(24, 16, 1), "eshield_RSW" = list(1, 16, 0), + "eshield_RNS" = list(16, 17, 1), "eshield_RNN" = list(16, 17, 1), "eshield_RNE" = list(31, 16, 0), "eshield_RNW" = list(7, 16, 1), + "eshield_RES" = list(18.1, 14, 0), "eshield_REN" = list(16, 17, 1), "eshield_REE" = list(19, 16, 0), "eshield_REW" = list(11, 16, 1), + "eshield_RWS" = list(13.9, 14, 0), "eshield_RWN" = list(16, 17, 1), "eshield_RWE" = list(21, 16, 1), "eshield_RWW" = list(13, 16, 0) +)) + +/// The baked look a held shield goes by: riot, buckler or eshield +/proc/plasma_shield_look(obj/item/shield/shield) + var/inhand = "[shield.inhand_icon_state]" + if(findtext(inhand, "eshield")) + return "eshield" + if(findtext(inhand, "buckler")) + return "buckler" + return "riot" + +/// The anchor table key for `shield` held by `holder`, hit by a shot from `side` +/proc/plasma_shield_key(mob/living/carbon/human/holder, obj/item/shield/shield, side) + var/hand = IS_LEFT_INDEX(holder.get_held_index_of_item(shield)) ? "L" : "R" + return "[plasma_shield_look(shield)]_[hand][plasma_burst_dir_letter(holder.dir)][plasma_burst_dir_letter(side)]" + +/// The splash and slag of a bolt a held shield blocked, shown once the bolt's sprite has reached the shield +/proc/plasma_shield_splash(turf/where, datum/weakref/holder_ref, datum/weakref/shield_ref, key, holder_layer, lying) + if(!where) + return + var/list/anchor = GLOB.plasma_shield_anchors[key] + if(!anchor) + return + var/mob/living/carbon/human/holder = holder_ref?.resolve() + var/obj/item/shield/shield = shield_ref?.resolve() + var/under = anchor[3] || lying + var/shift_x = holder ? holder.pixel_x : 0 + var/shift_y = holder ? holder.pixel_y : 0 + var/anchor_x = round(anchor[1]) + shift_x + var/anchor_y = round(anchor[2]) + shift_y + + // Floor slag, and all of it when the body is in the way + var/obj/effect/temp_visual/plasma_shield_splash/low = new(where) + low.layer = holder_layer - 0.01 + plasma_burst_place(low, "l_[key]", shift_x - ICON_SIZE_X / 2, shift_y - ICON_SIZE_Y / 2, FALSE) + // The soft glow: behind the body it only backlights it + var/obj/effect/temp_visual/plasma_shield_splash/bloom = new(where) + bloom.blend_mode = BLEND_ADD + bloom.layer = holder_layer + (under ? -0.02 : 0.02) + plasma_burst_place(bloom, under ? "bu" : "bo", anchor_x - ICON_SIZE_X, anchor_y - ICON_SIZE_Y - (under ? 2 : 3), FALSE, overlay_state = under ? "bu" : "bo") + + if(!under) + // The flash and the spatter flying off the shield's face + var/obj/effect/temp_visual/plasma_shield_splash/high = new(where) + high.layer = holder_layer + 0.01 + plasma_burst_place(high, "h_[key]", shift_x - ICON_SIZE_X / 2, shift_y - ICON_SIZE_Y / 2, FALSE) + // The molten coat rides the holder; it drops off if the shield or the holder moves out of that pose + if(holder && shield && !QDELETED(holder) && !QDELETED(shield) && (shield in holder.held_items) && !holder.lying_angle) + new /obj/effect/temp_visual/plasma_shield_coat(null, holder, shield, "c_[key]") + + var/obj/effect/temp_visual/plasma_burst_light/shield/glow = new(where) + glow.pixel_w = anchor_x - ICON_SIZE_X / 2 + glow.pixel_z = anchor_y - ICON_SIZE_Y / 2 + // Bright for a second, then it dims away as the coat goes dark + for(var/steps in 1 to 4) + addtimer(CALLBACK(glow, TYPE_PROC_REF(/atom, set_light_power), glow.light_power * (1 - steps * 0.2)), 0.4 SECONDS + (steps - 1) * 0.1 SECONDS) + addtimer(CALLBACK(glow, TYPE_PROC_REF(/atom, set_light_on), FALSE), 0.8 SECONDS) + +/// A shield block's light, which lasts while the coat is bright +/obj/effect/temp_visual/plasma_burst_light/shield + duration = 1 SECONDS + +/// One layer of a shield block's splash and slag +/obj/effect/temp_visual/plasma_shield_splash + icon = 'voidcrew/modules/plasma_weapons/icons/plasma_shield.dmi' + icon_state = null + duration = PLASMA_SHIELD_DURATION + randomdir = FALSE + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + +/// The molten coat on the held shield; it rides the holder's vis_contents, drawn over the shield in hand +/obj/effect/temp_visual/plasma_shield_coat + icon = 'voidcrew/modules/plasma_weapons/icons/plasma_shield_coat.dmi' + icon_state = null + duration = PLASMA_SHIELD_DURATION + randomdir = FALSE + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + vis_flags = VIS_INHERIT_PLANE | VIS_INHERIT_ID + /// Who holds the shield + var/mob/living/carbon/human/holder + /// The shield it coats + var/obj/item/shield/coated + +/obj/effect/temp_visual/plasma_shield_coat/Initialize(mapload, mob/living/carbon/human/new_holder, obj/item/shield/shield, state) + . = ..() + if(!new_holder || !shield) + return INITIALIZE_HINT_QDEL + holder = new_holder + coated = shield + icon_state = state + // Over the shield in hand, which draws on the holder's own layer + layer = holder.layer + 0.01 + add_overlay(emissive_appearance(icon, "[state]_e", holder)) + holder.vis_contents += src + RegisterSignals(holder, list(COMSIG_QDELETING, COMSIG_ATOM_DIR_CHANGE, COMSIG_LIVING_SET_BODY_POSITION), PROC_REF(on_pose_changed)) + RegisterSignals(coated, list(COMSIG_QDELETING, COMSIG_ITEM_DROPPED, COMSIG_ITEM_EQUIPPED), PROC_REF(on_pose_changed)) + +/obj/effect/temp_visual/plasma_shield_coat/Destroy() + if(holder) + UnregisterSignal(holder, list(COMSIG_QDELETING, COMSIG_ATOM_DIR_CHANGE, COMSIG_LIVING_SET_BODY_POSITION)) + holder.vis_contents -= src + holder = null + if(coated) + UnregisterSignal(coated, list(COMSIG_QDELETING, COMSIG_ITEM_DROPPED, COMSIG_ITEM_EQUIPPED)) + coated = null + return ..() + +/// The coat is drawn for one pose of the shield in hand; once the holder turns, lies down or lets go of it, it is gone +/obj/effect/temp_visual/plasma_shield_coat/proc/on_pose_changed(datum/source, old_dir, new_dir) + SIGNAL_HANDLER + if(source == holder && !isnull(new_dir) && old_dir == new_dir) + return + qdel(src) + +/// Removes tg's block star a held item made for this bolt in this tick: a plasma block shows only its splash +/proc/plasma_take_back_block_star(turf/where) + for(var/obj/effect/temp_visual/block/star in where) + if(star.creation_time == world.time) + qdel(star) + +/// The gutter-out where a bolt runs out of range +/obj/projectile/energy/plasma_bolt/proc/plasma_gutter_visual() + var/turf/here = get_turf(src) + if(!here) + return + new /obj/effect/temp_visual/plasma_gutter(here, src) + +/// The hiss a bolt makes passing close by a mob, once per mob +/obj/projectile/energy/plasma_bolt/proc/plasma_hissby_check() + if(QDELETED(src) || deletion_queued) + return + var/turf/here = get_turf(src) + if(!here || !starting || get_dist(starting, here) < PLASMA_HISSBY_MIN_TRAVEL) + return + for(var/mob/living/listener in range(1, here)) + if(!listener.client || listener == firer) + continue + // Refs, not mobs, so a deleted listener never leaves a hard reference here + var/listener_ref = REF(listener) + if(LAZYFIND(hissed_mobs, listener_ref)) + continue + LAZYADD(hissed_mobs, listener_ref) + listener.playsound_local(here, near_miss_sound, 25, TRUE) + +// ===== Procs called by the guns (P2, P3, P4, P6) ===== + +/// A puff of vented plasma at `where` +/proc/plasma_vent_puff(turf/where) + where = get_turf(where) + if(!where) + return + return new /obj/effect/temp_visual/plasma_vent_puff(where) + +/// Plays `far_file` to listeners too far away to hear `source`'s normal firing sound +/proc/plasma_weapons_far_sound(atom/source, far_file) + if(!far_file) + return + var/turf/source_turf = get_turf(source) + if(!source_turf || !islist(SSmobs.clients_by_zlevel) || source_turf.z > length(SSmobs.clients_by_zlevel)) + return + var/list/candidates = SSmobs.clients_by_zlevel[source_turf.z] | SSmobs.dead_players_by_zlevel[source_turf.z] + var/list/heard = plasma_far_sound_pick(source_turf, candidates) + for(var/mob/listener as anything in heard) + var/dist = max(get_dist(get_turf(listener), source_turf), 1) + // The position only gives the direction; the volume comes from the band + listener.playsound_local(source_turf, far_file, heard[listener], FALSE, pressure_affected = FALSE, distance_multiplier = 1 / dist) + +/// Of `candidates`, the client mobs that hear a distant shot from `source_turf`, each with its volume +/proc/plasma_far_sound_pick(turf/source_turf, list/candidates) + . = list() + var/datum/gas_mixture/source_air = source_turf.return_air_readonly() + var/source_pressure = source_air ? source_air.return_pressure() : 0 + for(var/mob/listener as anything in candidates) + if(!listener) + continue + var/turf/listener_turf = get_turf(listener) + if(!listener_turf || listener_turf.z != source_turf.z) + continue + var/volume = plasma_far_sound_volume(listener_turf, source_turf) + if(!volume) + continue + // Thin air carries sound worse, by the same rule playsound_local uses + var/datum/gas_mixture/listener_air = listener_turf.return_air_readonly() + var/pressure = min(source_pressure, listener_air ? listener_air.return_pressure() : 0) + if(pressure < ONE_ATMOSPHERE) + volume *= max((pressure - SOUND_MINIMUM_PRESSURE) / (ONE_ATMOSPHERE - SOUND_MINIMUM_PRESSURE), 0) + if(volume < SOUND_AUDIBLE_VOLUME_MIN) + continue + .[listener] = volume + +/// Distant-shot volume at `listener_turf`, or 0 inside normal hearing range or past the far range +/proc/plasma_far_sound_volume(turf/listener_turf, turf/source_turf) + var/dist = get_dist(listener_turf, source_turf) + if(dist <= SOUND_RANGE || dist > PLASMA_FAR_SOUND_RANGE) + return 0 + var/near_edge = SOUND_RANGE + 1 + return PLASMA_FAR_SOUND_VOL_NEAR - (PLASMA_FAR_SOUND_VOL_NEAR - PLASMA_FAR_SOUND_VOL_FAR) * (dist - near_edge) / (PLASMA_FAR_SOUND_RANGE - near_edge) + +// ===== Placement helpers (capped) ===== + +/// A cooling glow on a wall at the impact offset; skipped when the z-level already has `cap` glows +/proc/plasma_wall_glow_at(turf/where, px = 0, py = 0, cap = PLASMA_GLOW_CAP_PER_Z) + if(!where || !plasma_glow_allowed(where.z, cap)) + return null + var/obj/effect/temp_visual/plasma_wall_glow/glow = new(where, where.z) + glow.pixel_x = px + glow.pixel_y = py + return glow + +/// Whether one more glow fits on z-level `z` +/proc/plasma_glow_allowed(z, cap = PLASMA_GLOW_CAP_PER_Z) + return GLOB.plasma_wall_glows_by_z["[z]"] < cap + +/// Soot on `where`: refreshes the glow of soot already there, otherwise makes new soot, dropping the oldest over `cap` +/proc/plasma_soot_at(turf/open/where, px = 0, py = 0, cap = PLASMA_SOOT_GLOBAL_CAP) + if(!isopenturf(where)) + return null + var/obj/effect/decal/cleanable/plasma_soot/soot = locate() in where + if(soot) + soot.restart_glow() + return soot + while(length(GLOB.plasma_soot_decals) >= max(cap, 1)) + var/obj/effect/decal/cleanable/plasma_soot/oldest = GLOB.plasma_soot_decals[1] + GLOB.plasma_soot_decals -= oldest + qdel(oldest) + soot = new(where) + if(QDELETED(soot)) + return null + soot.pixel_x = clamp(px, -12, 12) + soot.pixel_y = clamp(py, -12, 12) + return soot + +// ===== Splash ===== + +/obj/effect/temp_visual/plasma_splash + // Every splash state is six 0.1 s frames + duration = 0.6 SECONDS + randomdir = FALSE + light_system = OVERLAY_LIGHT + light_range = 1.5 + light_power = 1 + light_color = PLASMA_LIGHT_COLOR + light_on = TRUE + /// Whether this splash carries the steam pool, so Destroy gives it back + var/steaming = FALSE + +/obj/effect/temp_visual/plasma_splash/Initialize(mapload, px = 0, py = 0, splash_state = "splash_wall", turn_angle = 0, behind_body = FALSE) + icon_state = splash_state + . = ..() + pixel_x = px + pixel_y = py + if(turn_angle) + transform = matrix().Turn(turn_angle) + // Walls and glass sit behind whoever stands in front of them; a body hit from behind hides its splash + if(behind_body || splash_state == "splash_wall" || splash_state == "splash_glass") + layer = BELOW_MOB_LAYER + add_overlay(emissive_appearance(icon, icon_state, src)) + if(splash_state == "splash_flesh") + steaming = TRUE + // The shared holder draws above every mob unless it takes our layer; this pool's holders take it + var/obj/effect/abstract/shared_particle_holder/steam = add_shared_particles(/particles/smoke/steam/mild, PLASMA_SPLASH_STEAM_KEY) + steam?.vis_flags |= VIS_INHERIT_LAYER + addtimer(CALLBACK(src, TYPE_PROC_REF(/atom, set_light_on), FALSE), 0.3 SECONDS) + +/obj/effect/temp_visual/plasma_splash/Destroy() + if(steaming) + remove_shared_particles(PLASMA_SPLASH_STEAM_KEY) + return ..() + +// ===== Person-hit burst ===== +// Three looks, baked from the approved drafts into one icon set per look: burst_.dmi (rifle), +// burst__small.dmi (pistol, 0.6x) and burst__body.dmi. A: molten splatter. B: flash and +// ring. Ordinary hits alternate A and B on each victim. C, the burn-through, plays only when the hit +// gives the limb a critical burn or kills. +// Each burst is four images: the floor slag and anything behind the body below the mob, the flash and +// front globs above it, a soft additive bloom, and what sticks to the body in the mob's vis_contents, so it +// moves and turns with the body. It shows a tick after the hit, when the bolt's sprite reaches the body. + +/// Where a hit lands on a standing human, in sprite pixels from the bottom left, by +/// "[body facing][side the shot came from][band]"; bands are h(ead), c(hest) and l(egs) +GLOBAL_LIST_INIT(plasma_burst_hits, list( + "SSh" = list(15, 25), "SNh" = list(15, 25), "SEh" = list(19, 25), "SWh" = list(11, 25), + "NSh" = list(15, 25), "NNh" = list(15, 25), "NEh" = list(19, 25), "NWh" = list(11, 25), + "ESh" = list(16, 25), "ENh" = list(16, 25), "EEh" = list(19, 25), "EWh" = list(12, 25), + "WSh" = list(16, 25), "WNh" = list(16, 25), "WEh" = list(19, 25), "WWh" = list(12, 25), + "SSc" = list(15, 17), "SNc" = list(15, 17), "SEc" = list(22, 17), "SWc" = list(8, 17), + "NSc" = list(15, 17), "NNc" = list(15, 17), "NEc" = list(22, 17), "NWc" = list(8, 17), + "ESc" = list(15, 17), "ENc" = list(15, 17), "EEc" = list(19, 17), "EWc" = list(11, 17), + "WSc" = list(16, 17), "WNc" = list(16, 17), "WEc" = list(20, 17), "WWc" = list(12, 17), + "SSl" = list(15, 5), "SNl" = list(15, 5), "SEl" = list(19, 5), "SWl" = list(11, 5), + "NSl" = list(15, 5), "NNl" = list(15, 5), "NEl" = list(19, 5), "NWl" = list(11, 5), + "ESl" = list(15, 5), "ENl" = list(15, 5), "EEl" = list(17, 5), "EWl" = list(13, 5), + "WSl" = list(16, 5), "WNl" = list(16, 5), "WEl" = list(18, 5), "WWl" = list(14, 5) +)) + +/mob/living/carbon/human + /// The look of the last ordinary plasma burst on this body, so the next one differs + var/plasma_last_burst + +/// The letter a cardinal dir goes by in the burst states and hit table +/proc/plasma_burst_dir_letter(dir) + switch(dir) + if(NORTH) + return "N" + if(EAST) + return "E" + if(WEST) + return "W" + return "S" + +/// The band a hit zone draws in +/proc/plasma_burst_band(zone) + switch(check_zone(zone)) + if(BODY_ZONE_HEAD) + return "h" + if(BODY_ZONE_L_LEG, BODY_ZONE_R_LEG) + return "l" + return "c" + +/// Where a bolt from `side` lands on `victim` in `band`: list(sprite x, sprite y, side in the body's own +/// frame, on-screen x, on-screen y). Sprite pixels are the body's own, before it is turned or scaled; +/// on-screen pixels are from the bottom left of the victim's turf. +/proc/plasma_burst_hit(mob/living/victim, side, band) + var/local_side = side + // A body lying down is turned by lying_angle; the side it was hit on turns with it + if(victim.lying_angle) + local_side = turn(side, victim.lying_angle) + var/list/hit = GLOB.plasma_burst_hits["[plasma_burst_dir_letter(victim.dir)][plasma_burst_dir_letter(local_side)][band]"] + // Transforms turn and scale about the icon's centre + var/local_x = hit[1] + 0.5 - ICON_SIZE_X / 2 + var/local_y = hit[2] + 0.5 - ICON_SIZE_Y / 2 + var/matrix/turned = victim.transform + var/screen_x = turned.a * local_x + turned.b * local_y + turned.c + var/screen_y = turned.d * local_x + turned.e * local_y + turned.f + return list(hit[1], hit[2], local_side, round(screen_x + ICON_SIZE_X / 2 - 0.5) + victim.pixel_x, round(screen_y + ICON_SIZE_Y / 2 - 0.5) + victim.pixel_y) + +/// The icon for one part of a look: "spray" (rifle), "small" (pistol spray) or "body" +/proc/plasma_burst_icon(look, part) + var/static/list/icons = list( + "Aspray" = 'voidcrew/modules/plasma_weapons/icons/burst_a.dmi', + "Asmall" = 'voidcrew/modules/plasma_weapons/icons/burst_a_small.dmi', + "Abody" = 'voidcrew/modules/plasma_weapons/icons/burst_a_body.dmi', + "Bspray" = 'voidcrew/modules/plasma_weapons/icons/burst_b.dmi', + "Bsmall" = 'voidcrew/modules/plasma_weapons/icons/burst_b_small.dmi', + "Bbody" = 'voidcrew/modules/plasma_weapons/icons/burst_b_body.dmi', + "Cspray" = 'voidcrew/modules/plasma_weapons/icons/burst_c.dmi', + "Csmall" = 'voidcrew/modules/plasma_weapons/icons/burst_c_small.dmi', + "Cbody" = 'voidcrew/modules/plasma_weapons/icons/burst_c_body.dmi', + ) + return icons["[look][part]"] + +/// An ordinary burst for `victim`: A or B, never the one it had last +/proc/plasma_burst_pick(mob/living/carbon/human/victim) + var/look + switch(victim.plasma_last_burst) + if(PLASMA_BURST_SPLATTER) + look = PLASMA_BURST_FLASH + if(PLASMA_BURST_FLASH) + look = PLASMA_BURST_SPLATTER + else + look = pick(PLASMA_BURST_SPLATTER, PLASMA_BURST_FLASH) + victim.plasma_last_burst = look + return look + +/// The front of a burst: flash, ring and globs flying toward the shooter, drawn over the body. It waits +/// for the bolt's damage to land, in the same tick, to know whether the bolt burned through. +/obj/effect/temp_visual/plasma_burst + icon = 'voidcrew/modules/plasma_weapons/icons/burst_a.dmi' + icon_state = null + duration = PLASMA_BURST_DURATION + randomdir = FALSE + /// Who was hit, until the look is picked + var/mob/living/carbon/human/victim + /// The limb hit + var/obj/item/bodypart/limb + /// Whether the limb already had a critical burn + var/had_critical = FALSE + /// Whether the victim was alive before the hit + var/was_alive = FALSE + /// The side the bolt came from + var/side + /// h, c or l + var/band + /// Pistol bolts make the 0.6x burst + var/small = FALSE + /// Whether the look has been picked and drawn + var/drawn = FALSE + /// Where the bolt landed, from plasma_burst_hit(), taken before the damage: a killing hit lays the body + /// down in the same call, and the burst belongs where the bolt struck + var/list/hit + /// Whether the body was lying down, its layer and its facing when the bolt landed + var/lying = FALSE + var/body_layer = MOB_LAYER + var/facing = SOUTH + /// The look picked in the tick of the hit, shown PLASMA_BURST_DELAY later + var/look + /// Whether the bolt melted the hit limb: that counts as a burn-through + var/melted = FALSE + +/obj/effect/temp_visual/plasma_burst/Initialize(mapload, mob/living/carbon/human/struck, obj/projectile/energy/plasma_bolt/bolt, from_side) + . = ..() + if(!struck || !bolt) + return INITIALIZE_HINT_QDEL + victim = struck + side = from_side + band = plasma_burst_band(bolt.def_zone) + small = !istype(bolt, /obj/projectile/energy/plasma_bolt/heavy) + was_alive = victim.stat != DEAD + limb = victim.get_bodypart(check_zone(bolt.def_zone)) + had_critical = plasma_limb_burned_through(limb) + hit = plasma_burst_hit(victim, side, band) + // The bolt's sprite stops where the burst draws; on a standing body's back it goes out behind the head + // instead of running over it + bolt.impact_point = list(hit[4] - ICON_SIZE_X / 2, side == NORTH && !victim.lying_angle ? ICON_SIZE_Y / 2 : hit[5] - ICON_SIZE_Y / 2) + bolt.impact_turf = get_turf(victim) + lying = !!victim.lying_angle + body_layer = victim.layer + facing = victim.dir + // on_hit runs just before the damage lands, in the same tick + RegisterSignal(victim, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_victim_damaged)) + RegisterSignal(victim, COMSIG_QDELETING, PROC_REF(on_victim_deleted)) + // No burn damage at all: an ordinary burst on the next tick + addtimer(CALLBACK(src, PROC_REF(draw), FALSE), 0) + +/obj/effect/temp_visual/plasma_burst/Destroy() + forget_victim() + limb = null + return ..() + +/obj/effect/temp_visual/plasma_burst/proc/forget_victim() + if(!victim) + return + UnregisterSignal(victim, list(COMSIG_MOB_AFTER_APPLY_DAMAGE, COMSIG_QDELETING)) + victim = null + +/obj/effect/temp_visual/plasma_burst/proc/on_victim_deleted(datum/source) + SIGNAL_HANDLER + forget_victim() + if(!drawn) + qdel(src) + +/obj/effect/temp_visual/plasma_burst/proc/on_victim_damaged(mob/living/carbon/human/source, damage, damagetype) + SIGNAL_HANDLER + if(damagetype != BURN) + return + var/killed = was_alive && source.stat == DEAD + var/burned = !had_critical && limb?.owner == source && plasma_limb_burned_through(limb) + draw(killed || burned) + +/// Picks the look in the tick of the hit, and shows it once the bolt's sprite has reached the body +/obj/effect/temp_visual/plasma_burst/proc/draw(burned_through) + if(drawn || QDELETED(src)) + return + drawn = TRUE + if(!victim) + qdel(src) + return + var/mob/living/carbon/human/hit_body = victim + forget_victim() + burned_through = burned_through || melted + look = burned_through ? PLASMA_BURST_THROUGH : plasma_burst_pick(hit_body) + // The burn-through sizzles on top of the hit + if(burned_through) + plasma_burst_sizzle(hit_body) + addtimer(CALLBACK(src, PROC_REF(show), WEAKREF(hit_body)), PLASMA_BURST_DELAY) + +/// The burn-through's sizzle +/proc/plasma_burst_sizzle(atom/where) + playsound(where, pick('voidcrew/modules/plasma_weapons/sound/plasma_burnthrough_1.ogg', 'voidcrew/modules/plasma_weapons/sound/plasma_burnthrough_2.ogg'), 60, TRUE) + +/// The bolt melted the hit limb (plasma_check_ash). A melt is a burn-through: before the look is picked this +/// sets it up; after, it swaps a picked look for the burn-through, as long as nothing is on screen yet. +/obj/effect/temp_visual/plasma_burst/proc/limb_melted() + if(QDELETED(src)) + return + melted = TRUE + if(!drawn || icon_state || look == PLASMA_BURST_THROUGH) + return + look = PLASMA_BURST_THROUGH + plasma_burst_sizzle(src) + +/// Draws the spray, the bloom and what sticks to the body +/obj/effect/temp_visual/plasma_burst/proc/show(datum/weakref/body_ref) + // icon_state stays null until the look is on screen + if(icon_state || QDELETED(src)) + return + var/mob/living/carbon/human/hit_body = body_ref?.resolve() + // The spray falls to the floor; a body lying down is already near it + var/spray_band = lying ? "l" : band + var/flipped = side == WEST + var/spray_state = "sp_[look][small ? "p" : "r"]_[spray_band]_[plasma_burst_dir_letter(flipped ? EAST : side)]" + var/canvas_icon = plasma_burst_icon(look, small ? "small" : "spray") + // The hit point sits in the centre column, one row below the centre, of the spray canvas + var/half = small ? 32 : 48 + var/spray_x = hit[4] - half + (flipped ? 1 : 0) + var/spray_y = hit[5] - (half - 1) + + icon = canvas_icon + layer = body_layer + 0.01 + plasma_burst_place(src, "[spray_state]_hi", spray_x, spray_y, flipped) + var/obj/effect/temp_visual/plasma_burst_low/low = new(loc) + low.icon = canvas_icon + low.layer = body_layer - 0.01 + plasma_burst_place(low, "[spray_state]_lo", spray_x, spray_y, flipped) + + // The soft bloom on the flash; a hit on a standing body's back lights only the rim around it + var/obj/effect/temp_visual/plasma_burst_bloom/bloom = new(loc) + bloom.icon = canvas_icon + bloom.layer = (side == NORTH && !lying) ? body_layer - 0.02 : body_layer + 0.02 + plasma_burst_place(bloom, "bl_[look][small ? "p" : "r"]", spray_x, spray_y, flipped, overlay_state = "bl_[look][small ? "p" : "r"]") + + // What sticks rides the body and turns with it; its frames for a side the viewer can't see are empty + if(hit_body && !QDELETED(hit_body)) + var/body_side = hit[3] + if(body_side == NORTH && lying) + body_side = SOUTH + new /obj/effect/temp_visual/plasma_burst_body(null, hit_body, plasma_burst_icon(look, "body"), "bd_[look][small ? "p" : "r"]_[plasma_burst_dir_letter(plasma_burst_body_side(body_side, facing))][band]") + + // The light sits on the hit itself: on the spray it would ride the canvas offset a tile or two off the body + var/obj/effect/temp_visual/plasma_burst_light/glow = new(loc) + glow.pixel_w = hit[4] - ICON_SIZE_X / 2 + glow.pixel_z = hit[5] - ICON_SIZE_Y / 2 + // It dims over its last few ticks instead of snapping off + var/fade_time = look == PLASMA_BURST_SPLATTER ? 0.2 SECONDS : 0.25 SECONDS + for(var/steps in 1 to 4) + addtimer(CALLBACK(glow, TYPE_PROC_REF(/atom, set_light_power), glow.light_power * (1 - steps * 0.2)), fade_time + (steps - 1) * world.tick_lag) + addtimer(CALLBACK(glow, TYPE_PROC_REF(/atom, set_light_on), FALSE), fade_time + 4 * world.tick_lag) + +/// The side `side` of a body facing `facing`, named as the same side of a body facing south: the body +/// states are named by it and hold one dir per facing +/proc/plasma_burst_body_side(side, facing) + return turn(side, dir2angle(facing) - 180) + +/// Shows `state` and its glow on `burst_part` at the given offset, mirrored for a shot from the west. +/// The offset goes in pixel_w/pixel_z: pixel_y would move it to another screen row and sort it past the body. +/proc/plasma_burst_place(obj/effect/burst_part, state, px, py, flipped, overlay_state = "[state]_e") + burst_part.icon_state = state + burst_part.pixel_w = px + burst_part.pixel_z = py + if(flipped) + burst_part.transform = matrix(-1, 0, 0, 0, 1, 0) + burst_part.add_overlay(emissive_appearance(burst_part.icon, overlay_state, burst_part)) + +/// The burst's light, on the hit point +/obj/effect/temp_visual/plasma_burst_light + icon = null + icon_state = null + duration = 0.6 SECONDS + randomdir = FALSE + light_system = OVERLAY_LIGHT + light_range = 2 + light_power = 1.5 + light_color = PLASMA_LIGHT_COLOR + light_on = TRUE + +/// Floor slag and anything the body hides +/obj/effect/temp_visual/plasma_burst_low + icon = 'voidcrew/modules/plasma_weapons/icons/burst_a.dmi' + icon_state = null + duration = PLASMA_BURST_DURATION + randomdir = FALSE + +/// The soft additive bloom on the flash, as the drafts' glow; its emissive keeps it bright in the dark +/obj/effect/temp_visual/plasma_burst_bloom + icon = 'voidcrew/modules/plasma_weapons/icons/burst_a.dmi' + icon_state = null + duration = 1.5 SECONDS + randomdir = FALSE + blend_mode = BLEND_ADD + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + +/// What sticks to the body; it rides in the body's vis_contents, so it moves, turns and lies down with it +/obj/effect/temp_visual/plasma_burst_body + icon = 'voidcrew/modules/plasma_weapons/icons/burst_a_body.dmi' + icon_state = null + duration = PLASMA_BURST_DURATION + randomdir = FALSE + layer = FLOAT_LAYER + plane = FLOAT_PLANE + // Each state holds one dir per facing of the body, so it follows the body turning + vis_flags = VIS_INHERIT_PLANE | VIS_INHERIT_ID | VIS_INHERIT_DIR + /// The body it is stuck to + var/mob/living/carbon/human/stuck_to + +/obj/effect/temp_visual/plasma_burst_body/Initialize(mapload, mob/living/carbon/human/body, look_icon, state) + . = ..() + if(!body) + return INITIALIZE_HINT_QDEL + stuck_to = body + icon = look_icon + icon_state = state + dir = body.dir + add_overlay(emissive_appearance(icon, "[state]_e", body)) + stuck_to.vis_contents += src + RegisterSignal(stuck_to, COMSIG_QDELETING, PROC_REF(on_body_deleted)) + +/obj/effect/temp_visual/plasma_burst_body/Destroy() + if(stuck_to) + UnregisterSignal(stuck_to, COMSIG_QDELETING) + stuck_to.vis_contents -= src + stuck_to = null + return ..() + +/obj/effect/temp_visual/plasma_burst_body/proc/on_body_deleted(datum/source) + SIGNAL_HANDLER + qdel(src) + +// ===== tg's block star ===== + +/obj/effect/temp_visual/block + /// The tick it appeared, so a blocked plasma bolt can take back its own star + var/creation_time + +/obj/effect/temp_visual/block/Initialize(mapload, set_color) + creation_time = world.time + return ..() + +// ===== Wall glow ===== + +/obj/effect/temp_visual/plasma_wall_glow + duration = PLASMA_GLOW_DURATION + randomdir = FALSE + layer = BELOW_MOB_LAYER + color = COLOR_WHITE + /// The z-level key this glow counts against + var/glow_z_key + +/// `z_level` is the level the glow counts against; `spokesman` is the atom it draws with, for emissive planes +/obj/effect/temp_visual/plasma_wall_glow/Initialize(mapload, z_level, atom/spokesman) + . = ..() + glow_z_key = "[z_level || z]" + GLOB.plasma_wall_glows_by_z[glow_z_key] += 1 + // The emissive keeps the ember lit in the dark; it fades with our alpha and keeps its own colour + add_overlay(emissive_appearance(icon, icon_state, spokesman || src)) + // It cools down the plasma's own ramp: green, deep green, then char + animate(src, color = "#33FF88", time = 0.5 SECONDS) + animate(color = "#1E873E", time = 1.5 SECONDS) + animate(color = "#2C3427", time = 2 SECONDS) + animate(alpha = 0, time = PLASMA_GLOW_DURATION - 4 SECONDS) + +/obj/effect/temp_visual/plasma_wall_glow/Destroy() + if(glow_z_key) + GLOB.plasma_wall_glows_by_z[glow_z_key] -= 1 + if(GLOB.plasma_wall_glows_by_z[glow_z_key] <= 0) + GLOB.plasma_wall_glows_by_z -= glow_z_key + glow_z_key = null + return ..() + +/// The glow inside a soot decal's vis_contents; it lives in nullspace and draws with the decal +/obj/effect/temp_visual/plasma_wall_glow/soot + layer = FLOAT_LAYER + plane = FLOAT_PLANE + vis_flags = VIS_INHERIT_PLANE | VIS_INHERIT_LAYER | VIS_INHERIT_ID + +// ===== Soot ===== + +/obj/effect/decal/cleanable/plasma_soot + desc = "A black scorch mark." + /// The cooling glow over the soot, while it still burns + var/obj/effect/temp_visual/plasma_wall_glow/soot/glow + +/obj/effect/decal/cleanable/plasma_soot/Initialize(mapload, list/datum/disease/diseases) + . = ..() + if(. == INITIALIZE_HINT_QDEL) + return + GLOB.plasma_soot_decals += src + restart_glow() + +/obj/effect/decal/cleanable/plasma_soot/Destroy() + GLOB.plasma_soot_decals -= src + clear_glow() + return ..() + +/// Starts the cooling glow over, if the z-level has room for one +/obj/effect/decal/cleanable/plasma_soot/proc/restart_glow() + clear_glow() + var/turf/here = get_turf(src) + if(!here || !plasma_glow_allowed(here.z)) + return + glow = new(null, here.z, src) + vis_contents += glow + RegisterSignal(glow, COMSIG_QDELETING, PROC_REF(on_glow_deleted)) + +/// Drops the current glow, if any +/obj/effect/decal/cleanable/plasma_soot/proc/clear_glow() + if(!glow) + return + var/obj/effect/temp_visual/plasma_wall_glow/soot/old_glow = glow + UnregisterSignal(old_glow, COMSIG_QDELETING) + vis_contents -= old_glow + glow = null + qdel(old_glow) + +/obj/effect/decal/cleanable/plasma_soot/proc/on_glow_deleted(datum/source) + SIGNAL_HANDLER + vis_contents -= source + glow = null + +// ===== Gutter-out ===== + +/obj/effect/temp_visual/plasma_gutter + duration = 0.5 SECONDS + randomdir = FALSE + light_system = OVERLAY_LIGHT + light_range = 1.5 + light_power = 1 + light_color = PLASMA_LIGHT_COLOR + light_on = TRUE + +/// Takes the look of `bolt`, shrinks with two flickers over 0.3 s, then curls into a wisp +/obj/effect/temp_visual/plasma_gutter/Initialize(mapload, obj/projectile/energy/plasma_bolt/bolt) + . = ..() + var/matrix/base = matrix() + if(bolt) + icon = bolt.icon + icon_state = bolt.icon_state + // tg's range-out hop (delete_distance in projectile.dm) leaves the bolt a tile less twice its + // overshoot short of where it ran out, often behind where it was last drawn; start from that point + var/shortfall = clamp(ICON_SIZE_ALL - 2 * bolt.pixels_moved_last_tile, 0, ICON_SIZE_ALL) + pixel_x = bolt.pixel_x + round(bolt.movement_vector?.pixel_x * shortfall) + pixel_y = bolt.pixel_y + round(bolt.movement_vector?.pixel_y * shortfall) + layer = bolt.layer + plane = bolt.plane + base = matrix(bolt.transform) + set_light_range(bolt.light_range) + add_overlay(emissive_appearance(bolt.icon, bolt.core_state, src)) + transform = base + animate(src, transform = scaled_copy(base, 0.85), alpha = 90, time = 0.5) + animate(transform = scaled_copy(base, 0.7), alpha = 255, time = 0.5) + animate(transform = scaled_copy(base, 0.55), alpha = 90, time = 0.5) + animate(transform = scaled_copy(base, 0.45), alpha = 255, time = 0.5) + animate(transform = scaled_copy(base, 0.3), alpha = 200, time = 1) + addtimer(CALLBACK(src, PROC_REF(snuff)), 0.3 SECONDS) + +/obj/effect/temp_visual/plasma_gutter/proc/scaled_copy(matrix/base, scale) + var/matrix/scaled = matrix(base) + scaled.Scale(scale) + return scaled + +/// The core and its light go out and what is left curls into a wisp, all in one update: turning the +/// sprite upright any earlier would flash the core's emissive upright for a frame +/obj/effect/temp_visual/plasma_gutter/proc/snuff() + cut_overlays() + set_light_on(FALSE) + // The wisp is drawn upright, whichever way the bolt flew, and fades out in its own frames + // The bolt's look came from the projectile icon; the wisp is in ours + icon = initial(icon) + icon_state = "wisp" + // A new animation replaces the shrink + animate(src, transform = matrix(), alpha = 255, time = 0) + +// ===== Vent puff ===== + +/obj/effect/temp_visual/plasma_vent_puff + duration = 0.6 SECONDS + randomdir = FALSE + +/obj/effect/temp_visual/plasma_vent_puff/Initialize(mapload) + . = ..() + add_shared_particles(/particles/smoke/steam/mild) + +/obj/effect/temp_visual/plasma_vent_puff/Destroy() + remove_shared_particles(/particles/smoke/steam/mild) + return ..() + +#undef PLASMA_FAR_SOUND_RANGE +#undef PLASMA_FAR_SOUND_VOL_NEAR +#undef PLASMA_FAR_SOUND_VOL_FAR +#undef PLASMA_HISSBY_MIN_TRAVEL +#undef PLASMA_SPLASH_STEAM_KEY +#undef PLASMA_BURST_SPLATTER +#undef PLASMA_BURST_FLASH +#undef PLASMA_BURST_THROUGH +#undef PLASMA_BURST_DURATION +#undef PLASMA_BURST_DELAY +#undef PLASMA_SHIELD_DURATION diff --git a/voidcrew/modules/plasma_weapons/plasma_flask.dm b/voidcrew/modules/plasma_weapons/plasma_flask.dm new file mode 100644 index 00000000000..534103c64f8 --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_flask.dm @@ -0,0 +1,67 @@ +// Package: P2 guns and flask. The plasma flask: feeding, fill sprite steps and examine line. + +/obj/item/plasma_flask + w_class = WEIGHT_CLASS_SMALL + slot_flags = ITEM_SLOT_BELT + +/// Mol one unit of `fuel` gives, or 0 if it isn't plasma a flask takes (sheets and ore only) +/proc/plasma_fuel_mol_per_unit(obj/item/stack/fuel) + if(istype(fuel, /obj/item/stack/sheet/mineral/plasma)) + return PLASMA_MOL_PER_SHEET + if(istype(fuel, /obj/item/stack/ore/plasma)) + return PLASMA_MOL_PER_ORE + return 0 + +/// Feeds a plasma sheet or ore stack into the flask, one unit per do_after. Returns mol added. +/// A unit goes in only when there is room for all of it. With no user there is no delay. +/obj/item/plasma_flask/proc/feed(obj/item/stack/fuel, mob/user) + var/per_unit = plasma_fuel_mol_per_unit(fuel) + if(!per_unit) + return 0 + var/added = 0 + while(!QDELETED(fuel) && fuel.get_amount() > 0) + if(stored + per_unit > PLASMA_FLASK_CAPACITY) + if(!added && user) + balloon_alert(user, "it's full") + break + if(user && !do_after(user, PLASMA_FEED_DELAY, target = src)) + break + if(QDELETED(fuel) || stored + per_unit > PLASMA_FLASK_CAPACITY || !fuel.use(1)) + break + stored += per_unit + added += per_unit + playsound(src, PLASMA_SOUND_FEED_SHEET, 20, TRUE) + update_appearance() + if(added) + playsound(src, PLASMA_SOUND_FEED_CLICK, 30, TRUE) + return added + +/obj/item/plasma_flask/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + if(istype(tool, /obj/item/stack/sheet/mineral/plasma) || istype(tool, /obj/item/stack/ore/plasma)) + feed(tool, user) + return ITEM_INTERACT_SUCCESS + return ..() + +/// Fill step 0 (empty) to 3 (full), for the sprite and the examine line +/obj/item/plasma_flask/proc/fill_step() + if(stored <= 0) + return 0 + if(stored >= PLASMA_FLASK_CAPACITY) + return 3 + return stored < PLASMA_FLASK_CAPACITY / 2 ? 1 : 2 + +/obj/item/plasma_flask/update_icon_state() + icon_state = "flask_[fill_step()]" + return ..() + +/obj/item/plasma_flask/examine(mob/user) + . = ..() + switch(fill_step()) + if(0) + . += "It is empty." + if(1) + . += "It is about a third full." + if(2) + . += "It is about two thirds full." + if(3) + . += "It is full." diff --git a/voidcrew/modules/plasma_weapons/plasma_guns.dm b/voidcrew/modules/plasma_weapons/plasma_guns.dm new file mode 100644 index 00000000000..9734992c698 --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_guns.dm @@ -0,0 +1,230 @@ +// Package: P2 guns and flask. The Cinder, Furnace and Old Furnace: loading, unloading and feeding, +// chamber access, spawn fills, EMP, sprite state, and the shot hooks into heat, sound and pinch. +// +// The chamber (`cell`) holds the seated flask's plasma while it is in the gun; the flask itself reads +// empty until it comes out, when the chamber is written back into it. So the plasma a gun and its flask +// hold together is always chamber_mol() + flask.stored, and only feeding raises it. + +/obj/item/gun/energy/plasma_weapon + automatic_charge_overlays = FALSE + vary_fire_sound = FALSE + // A turret or emitter fires a gun's bolts off the power grid; plasma bolts only come from plasma + gun_flags = TURRET_INCOMPATIBLE + /// do_after to seat a flask + var/flask_load_delay = PLASMA_LOAD_DELAY_PISTOL + /// Spawn with a flask seated: full, or (spawn_fill_random) 0-50% full. Never set FALSE on a sold gun. + var/spawn_with_flask = TRUE + /// Loot guns spawn with a part-filled flask + var/spawn_fill_random = FALSE + +/obj/item/gun/energy/plasma_weapon/pistol + fire_sound = 'voidcrew/modules/plasma_weapons/sound/cinder_fire.ogg' + far_fire_sound = 'voidcrew/modules/plasma_weapons/sound/cinder_far.ogg' + fire_delay = 5 + spread = 5 + recoil = 0.5 + force = 10 + weapon_weight = WEAPON_LIGHT + w_class = WEIGHT_CLASS_NORMAL + slot_flags = ITEM_SLOT_BELT + +/// A Cinder found in the field: its flask is anywhere from empty to half full +/obj/item/gun/energy/plasma_weapon/pistol/loot + spawn_fill_random = TRUE + +/obj/item/gun/energy/plasma_weapon/rifle + fire_sound = 'voidcrew/modules/plasma_weapons/sound/furnace_fire.ogg' + far_fire_sound = 'voidcrew/modules/plasma_weapons/sound/furnace_far.ogg' + fire_delay = 7 + spread = 2 + recoil = 0.75 + force = 15 + weapon_weight = WEAPON_HEAVY + w_class = WEIGHT_CLASS_BULKY + slot_flags = ITEM_SLOT_BACK + flask_load_delay = PLASMA_LOAD_DELAY_RIFLE + +/// A Furnace found in the field: its flask is anywhere from empty to half full +/obj/item/gun/energy/plasma_weapon/rifle/loot + spawn_fill_random = TRUE + +/obj/item/gun/energy/plasma_weapon/rifle/old_furnace + spread = 0 + +/obj/item/gun/energy/plasma_weapon/Initialize(mapload) + . = ..() + // Keep the sealed chamber out of contents; see plasma_chamber.dm. Exited() keeps `cell` set. + cell?.moveToNullspace() + if(spawn_with_flask) + var/obj/item/plasma_flask/spawned = new(src) + spawned.stored = spawn_fill_random ? rand(0, round(PLASMA_FLASK_CAPACITY / 2)) : PLASMA_FLASK_CAPACITY + seat_flask(spawned) + +/obj/item/gun/energy/plasma_weapon/Destroy() + // Drop the flask with the chamber's plasma written back (Exited does the write-back) + if(flask) + var/atom/drop_at = drop_location() + if(drop_at) + flask.forceMove(drop_at) + else + QDEL_NULL(flask) + return ..() + +/obj/item/gun/energy/plasma_weapon/Exited(atom/movable/gone, direction) + if(gone == cell) + // The chamber leaves contents on purpose in Initialize and never leaves the gun's `cell` + var/obj/item/stock_parts/power_store/kept = cell + . = ..() + cell = kept + return + . = ..() + if(gone == flask) + flask = null + var/obj/item/plasma_flask/left = gone + if(cell) + left.stored = min(left.stored + cell.charge, PLASMA_FLASK_CAPACITY) + cell.charge = 0 + left.update_appearance() + chamber_changed() + +/// The energy gun's own pick of fire sound and delay comes from the casing; these guns set their own +/obj/item/gun/energy/plasma_weapon/update_ammo_types() + . = ..() + if(initial(fire_sound)) + fire_sound = initial(fire_sound) + fire_delay = initial(fire_delay) + +/// Re-chambers after the plasma in the chamber changed outside of firing +/obj/item/gun/energy/plasma_weapon/proc/chamber_changed() + chambered = null + recharge_newshot(TRUE) + update_appearance() + +/// Puts `new_flask` (already inside the gun) in the seat and moves its plasma into the chamber +/obj/item/gun/energy/plasma_weapon/proc/seat_flask(obj/item/plasma_flask/new_flask) + flask = new_flask + if(cell) + cell.charge = clamp(new_flask.stored, 0, cell.maxcharge) + new_flask.stored = 0 + chamber_changed() + +/// Seats `flask` and moves its plasma into the chamber. TRUE on success. +/obj/item/gun/energy/plasma_weapon/proc/load_flask(obj/item/plasma_flask/new_flask, mob/user) + if(QDELETED(new_flask) || new_flask == flask || !cell) + return FALSE + if(user) + if(!do_after(user, flask_load_delay, target = src)) + return FALSE + if(QDELETED(new_flask) || new_flask.loc != user) + return FALSE + if(flask) + unload_flask(user) + if(user) + if(!user.transferItemToLoc(new_flask, src)) + return FALSE + else + new_flask.forceMove(src) + seat_flask(new_flask) + playsound(src, 'voidcrew/modules/plasma_weapons/sound/flask_load.ogg', 50, TRUE) + return TRUE + +/// Writes the chamber back into the seated flask and hands it to `user`. Returns the flask. +/obj/item/gun/energy/plasma_weapon/proc/unload_flask(mob/user) + if(!flask) + return null + var/obj/item/plasma_flask/out = flask + // Exited() writes the chamber back as it leaves + if(user) + user.put_in_hands(out) + if(out.loc == src) + out.forceMove(drop_location()) + playsound(src, 'voidcrew/modules/plasma_weapons/sound/flask_unload.ogg', 50, TRUE) + return out + +/// Feeds a plasma sheet or ore stack into the chamber, one unit per do_after. Returns mol added. +/// Needs a seated flask. A unit goes in only when there is room for all of it. No user, no delay. +/obj/item/gun/energy/plasma_weapon/proc/feed_plasma(obj/item/stack/fuel, mob/user) + var/per_unit = plasma_fuel_mol_per_unit(fuel) + if(!per_unit || !cell) + return 0 + if(!flask) + if(user) + balloon_alert(user, "no flask") + return 0 + var/added = 0 + while(!QDELETED(fuel) && fuel.get_amount() > 0) + if(!flask || cell.charge + per_unit > cell.maxcharge) + if(!added && user && flask) + balloon_alert(user, "it's full") + break + if(user && !do_after(user, PLASMA_FEED_DELAY, target = src)) + break + if(QDELETED(fuel) || !flask || cell.charge + per_unit > cell.maxcharge || !fuel.use(1)) + break + cell.charge += per_unit + added += per_unit + playsound(src, PLASMA_SOUND_FEED_SHEET, 20, TRUE) + if(added) + playsound(src, PLASMA_SOUND_FEED_CLICK, 30, TRUE) + chamber_changed() + return added + +/// Mol of plasma in the chamber +/obj/item/gun/energy/plasma_weapon/proc/chamber_mol() + return cell?.charge || 0 + +/// Removes up to `amount` mol from the chamber. Returns what it took. +/obj/item/gun/energy/plasma_weapon/proc/take_mol(amount) + if(!cell || amount <= 0) + return 0 + var/took = min(amount, cell.charge) + if(took <= 0) + return 0 + cell.charge -= took + chamber_changed() + return took + +/// Plasma isn't charge: skip the energy gun's drain and keep what the chamber holds +/obj/item/gun/energy/plasma_weapon/emp_act(severity) + var/held = cell?.charge + . = ..() + if(cell && !isnull(held)) + cell.charge = held + chamber_changed() + +/// Powerups and the like refill an energy gun's cell outright; this one only takes plasma +/obj/item/gun/energy/plasma_weapon/instant_recharge() + SIGNAL_HANDLER + return + +/obj/item/gun/energy/plasma_weapon/update_icon_state() + var/obj/item/ammo_casing/energy/shot = length(ammo_type) ? ammo_type[select] : null + var/loaded = cell && istype(shot) && cell.charge >= shot.e_cost + icon_state = "[initial(icon_state)][loaded ? "" : "_empty"]" + return ..() + +/obj/item/gun/energy/plasma_weapon/shoot_live_shot(mob/living/user, pointblank = FALSE, atom/pbtarget = null, message = TRUE) + . = ..() + if(uses_heat) + add_heat(heat_per_shot) + plasma_weapons_far_sound(src, far_fire_sound) + // tg hands the shot's target in as pbtarget on every shot, point blank or not (process_fire, process_burst) + plasma_pinch(user, pbtarget) + +/obj/item/gun/energy/plasma_weapon/can_shoot() + if(!can_fire_heat()) + return FALSE + return ..() + +/obj/item/gun/energy/plasma_weapon/item_interaction(mob/living/user, obj/item/tool, list/modifiers) + if(istype(tool, /obj/item/plasma_flask)) + return load_flask(tool, user) ? ITEM_INTERACT_SUCCESS : ITEM_INTERACT_BLOCKING + if(istype(tool, /obj/item/stack/sheet/mineral/plasma) || istype(tool, /obj/item/stack/ore/plasma)) + feed_plasma(tool, user) + return ITEM_INTERACT_SUCCESS + return ..() + +/obj/item/gun/energy/plasma_weapon/click_alt(mob/user) + if(!user.is_holding(src)) + return CLICK_ACTION_BLOCKING + return unload_flask(user) ? CLICK_ACTION_SUCCESS : CLICK_ACTION_BLOCKING diff --git a/voidcrew/modules/plasma_weapons/plasma_heat.dm b/voidcrew/modules/plasma_weapons/plasma_heat.dm new file mode 100644 index 00000000000..791d23ca43d --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_heat.dm @@ -0,0 +1,118 @@ +// Package: P3 heat. Furnace heat: gain per shot, cooling from turf air in process(), boiling, the +// manual vent, the post-vent firing lock, boil damage and the coil overlay steps. +// Only guns with uses_heat gain heat, and a gun processes on SSobj only while barrel_heat is above 0. + +/// Raises heat by `amount`, boils if due, and starts cooling. +/obj/item/gun/energy/plasma_weapon/proc/add_heat(amount) + if(!uses_heat || QDELETED(src)) + return + barrel_heat = max(barrel_heat + amount, 0) + if(barrel_heat >= PLASMA_HEAT_BOIL) + boil() + if(QDELETED(src)) + return + if(barrel_heat > 0) + START_PROCESSING(SSobj, src) + update_heat_step() + +/// Too hot after a shot: the chamber spits plasma and the gun takes damage, whether or not there was plasma to spit. +/obj/item/gun/energy/plasma_weapon/proc/boil() + var/turf/where = get_turf(src) + vent_plasma(PLASMA_BOIL_MOL, null, TRUE) + barrel_heat = max(barrel_heat - PLASMA_BOIL_COOL, 0) + if(where) + where.visible_message(span_warning("[src] hisses and spits gas!")) + playsound(where, PLASMA_SOUND_FURNACE_BOIL, 50, TRUE) + take_damage(PLASMA_BOIL_INTEGRITY, sound_effect = FALSE) + +/// Takes up to `mol` from the chamber and releases it on the turf at the barrel's temperature. Returns mol released. +/// Only what left the chamber is released, so a short chamber vents what it has. +/obj/item/gun/energy/plasma_weapon/proc/vent_plasma(mol, mob/user, boil = FALSE) + var/turf/open/where = get_turf(src) + if(!istype(where) || mol <= 0) + return 0 + var/taken = take_mol(mol) + if(taken <= 0) + return 0 + var/gas_temp = T20C + barrel_heat * PLASMA_VENT_TEMP_PER_HEAT + where.atmos_spawn_air("[GAS_PLASMA]=[taken];[TURF_TEMPERATURE(gas_temp)]") + plasma_vent_puff(where) + return taken + +/// FALSE while a vent blocks firing +/obj/item/gun/energy/plasma_weapon/proc/can_fire_heat() + return world.time >= vent_lock_until + +/// Updates the coil overlay step and the inhand +/obj/item/gun/energy/plasma_weapon/proc/update_heat_step() + var/new_step = 0 + if(barrel_heat >= PLASMA_HEAT_STEP_3) + new_step = 3 + else if(barrel_heat >= PLASMA_HEAT_STEP_2) + new_step = 2 + else if(barrel_heat >= PLASMA_HEAT_STEP_1) + new_step = 1 + if(new_step == heat_step) + return + heat_step = new_step + update_appearance(UPDATE_OVERLAYS) + update_inhand_icon() + +/obj/item/gun/energy/plasma_weapon/process(seconds_per_tick) + if(!uses_heat || barrel_heat <= 0) + barrel_heat = 0 + update_heat_step() + STOP_PROCESSING(SSobj, src) + return + var/rate = heat_cool_rate() + var/new_heat = barrel_heat - rate * seconds_per_tick + // Hot surroundings bring the barrel up to the boil point, never past it; only a shot boils. + if(rate < 0) + new_heat = min(new_heat, max(barrel_heat, PLASMA_HEAT_BOIL)) + barrel_heat = max(new_heat, 0) + update_heat_step() + if(barrel_heat <= 0) + STOP_PROCESSING(SSobj, src) + +/// Heat lost per second where the gun is now, from its turf's air. Negative in hot surroundings. +/obj/item/gun/energy/plasma_weapon/proc/heat_cool_rate() + . = PLASMA_COOL_BASE + var/turf/where = get_turf(src) + var/datum/gas_mixture/air = where?.return_air() + if(!air) + return + var/kelvin = air.return_temperature() + var/pressure_factor = min(air.return_pressure() / PLASMA_COOL_PRESSURE_REF, PLASMA_COOL_PRESSURE_CAP) + var/temp_factor = clamp((PLASMA_COOL_HOT_TEMP - kelvin) / PLASMA_COOL_TEMP_SPAN, 0, PLASMA_COOL_TEMP_CAP) + . += PLASMA_COOL_CONVECT * pressure_factor * temp_factor + if(kelvin > PLASMA_COOL_HOT_TEMP) + . -= min(PLASMA_HEAT_GAIN_MAX, (kelvin - PLASMA_COOL_HOT_TEMP) / PLASMA_HEAT_GAIN_SPAN) + +/obj/item/gun/energy/plasma_weapon/attack_self(mob/living/user) + . = ..() + if(.) + return + if(!uses_heat || world.time < next_vent_at) + return + if(!vent_plasma(PLASMA_VENT_MOL, user)) + return + next_vent_at = world.time + PLASMA_VENT_COOLDOWN + vent_lock_until = world.time + PLASMA_VENT_LOCK + barrel_heat = max(barrel_heat - PLASMA_VENT_COOL, 0) + playsound(src, PLASMA_SOUND_FURNACE_VENT, 50, TRUE) + update_heat_step() + return TRUE + +/obj/item/gun/energy/plasma_weapon/update_overlays() + . = ..() + if(!uses_heat || !heat_step) + return + . += mutable_appearance(icon, "furnace_glow_[heat_step]") + . += emissive_appearance(icon, "furnace_glow_[heat_step]", src) + +/obj/item/gun/energy/plasma_weapon/worn_overlays(mutable_appearance/standing, isinhands = FALSE, icon_file) + . = ..() + if(!isinhands || !uses_heat || !heat_step) + return + . += mutable_appearance(icon_file, "furnace_glow_[heat_step]") + . += emissive_appearance(icon_file, "furnace_glow_[heat_step]", src) diff --git a/voidcrew/modules/plasma_weapons/plasma_melt.dm b/voidcrew/modules/plasma_weapons/plasma_melt.dm new file mode 100644 index 00000000000..4cec06ffbb7 --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_melt.dm @@ -0,0 +1,865 @@ +// Package: P1 bolt. The melt-off picture. The arm or leg comes off at once through tg's WOUND_BURN loss (state, +// damage and blood loss all as tg does them); the picture lags behind it. The limb as it was drawn heats up on the +// body, then melts in place from the hit joint: arms on a standing body hang and run off in gobbets that splash +// into a pool in front of the feet, legs and arms on a lying body slump into the pool. A molten stump bubbles, +// chars and stays on the body until the limb is put back or regrown. The pool cools into a lasting slag decal. +// tg's ash pile and its dismember blood splatter are not shown; the slag stands in for both. +// Every melt frame lives in a plasma_melt_.dmi in the body's own sprite space, so it turns with a lying body. +// One file per limb: a client loads a whole icon file the first time it draws from it, and all four limbs in one file +// stalled it for a few hundred ms when the first melt began. The pool, drops and slag share plasma_melt.dmi. +// Each body state comes as a pair: "" holds the near facings and draws in front of the body, +// "_far" holds only the facing where the limb is on the far side and draws behind it. + +#define PLASMA_MELT_ICON 'voidcrew/modules/plasma_weapons/icons/plasma_melt.dmi' +/// The body frames, stumps and sleeve mask of one limb +#define PLASMA_MELT_ZONE_ICON(zone) (zone == BODY_ZONE_R_ARM ? 'voidcrew/modules/plasma_weapons/icons/plasma_melt_r_arm.dmi' : (zone == BODY_ZONE_L_ARM ? 'voidcrew/modules/plasma_weapons/icons/plasma_melt_l_arm.dmi' : (zone == BODY_ZONE_R_LEG ? 'voidcrew/modules/plasma_weapons/icons/plasma_melt_r_leg.dmi' : 'voidcrew/modules/plasma_weapons/icons/plasma_melt_l_leg.dmi'))) + +/// Key moments, from the hit +#define PLASMA_MELT_HEAT_END (0.35 SECONDS) +#define PLASMA_MELT_GONE (1.6 SECONDS) +/// The hand has run off: a held item falls out of it (storyboard: the third gobbet, 1.14 s) +#define PLASMA_MELT_LET_GO (1.15 SECONDS) +#define PLASMA_MELT_STUMP_COOL (2.4 SECONDS) +#define PLASMA_MELT_POOL_COOL (2.2 SECONDS) +#define PLASMA_MELT_COOLED (4.5 SECONDS) + +/// Pool growth steps in the DMI (pool_1 .. pool_N) +#define PLASMA_MELT_POOL_STEPS 8 +/// Feet row of a standing body, from the top of its 32x32 sprite (0-based) +#define PLASMA_MELT_FLOOR_ROW 30 +/// How far below the feet the pool sits on a standing body, so it reads in front of them +#define PLASMA_MELT_POOL_FRONT 4 +/// The same for an arm on a lying body: how far out past where the arm lay, so the torso doesn't cover the pool. +/// A leg's pool stays on the leg's line at the foot end. +#define PLASMA_MELT_POOL_FRONT_LYING 2 +/// Row and column of the pool's centre in its own 32x32 tile (pool, splat and slag states) +#define PLASMA_MELT_POOL_TILE_X 15 +#define PLASMA_MELT_POOL_TILE_Y 21 +/// Row and column of a falling drop's lowest pixel in its own tile +#define PLASMA_MELT_DROP_TILE 16 +/// Rows (from the sprite top) where a standing body's hand and foot are, for a dropped item's fall +#define PLASMA_MELT_HAND_ROW 22 +#define PLASMA_MELT_FOOT_ROW 30 +/// Where a dropped item's middle comes to rest, below the sprite centre: beside the feet standing, under a lying body +#define PLASMA_MELT_ITEM_FLOOR -13 +#define PLASMA_MELT_ITEM_FLOOR_LYING -16 +/// How far to the side of a standing body's middle a dropped item comes to rest, clear of the other foot +#define PLASMA_MELT_ITEM_SIDE 11 +/// Cooldown key: limbs melting off one body together make one melt sound +#define COOLDOWN_PLASMA_MELT_SOUND "plasma_melt_sound" +/// While the body slid onto its tile this recently, drips fall straight to the floor under it +#define PLASMA_MELT_GLIDE_WAIT (0.4 SECONDS) + +/// Most slag decals in the world at once; the oldest goes first +#define PLASMA_SLAG_GLOBAL_CAP 150 + +/// Per limb and facing, from the approved storyboard (melt_v4.py), in the body sprite's pixels from its top left: +/// list(pool column standing, limb middle x lying, limb middle y lying, list(drops: list(release time cs, size, x, start row))) +/// Drops only fall from arms on a standing body; a far arm has no late stump drip. +GLOBAL_LIST_INIT(plasma_melt_geometry, list( + BODY_ZONE_R_ARM = list("2" = list(7, 8.5, 20.0, list(list(55, 1, 9, 22), list(78, 2, 9, 25), list(98, 2, 9, 24), list(114, 2, 9, 22), list(128, 2, 9, 19), list(140, 2, 9, 16), list(155, 3, 9, 14), list(195, 1, 9, 16))), "1" = list(23, 21.5, 20.0, list(list(55, 1, 21, 22), list(78, 2, 21, 25), list(98, 2, 21, 24), list(114, 2, 21, 22), list(128, 2, 21, 19), list(140, 2, 21, 16), list(155, 3, 21, 14), list(195, 1, 21, 16))), "4" = list(13, 13.5, 21.0, list(list(55, 1, 13, 22), list(78, 2, 13, 25), list(98, 2, 13, 24), list(114, 2, 13, 22), list(128, 2, 13, 20), list(140, 2, 13, 18), list(155, 3, 13, 16), list(195, 1, 13, 18))), "8" = list(11, 11.0, 21.0, list(list(55, 1, 11, 20), list(78, 2, 11, 21), list(98, 2, 11, 21), list(114, 2, 11, 20), list(128, 2, 11, 19), list(140, 2, 11, 18), list(155, 3, 11, 17)))), + BODY_ZONE_L_ARM = list("2" = list(23, 21.5, 20.0, list(list(55, 1, 21, 22), list(78, 2, 21, 25), list(98, 2, 21, 24), list(114, 2, 21, 22), list(128, 2, 21, 19), list(140, 2, 21, 16), list(155, 3, 21, 14), list(195, 1, 21, 16))), "1" = list(7, 8.5, 20.0, list(list(55, 1, 9, 22), list(78, 2, 9, 25), list(98, 2, 9, 24), list(114, 2, 9, 22), list(128, 2, 9, 19), list(140, 2, 9, 16), list(155, 3, 9, 14), list(195, 1, 9, 16))), "4" = list(20, 20.0, 21.0, list(list(55, 1, 20, 20), list(78, 2, 20, 21), list(98, 2, 20, 21), list(114, 2, 20, 20), list(128, 2, 20, 19), list(140, 2, 20, 18), list(155, 3, 20, 17))), "8" = list(19, 17.5, 21.0, list(list(55, 1, 18, 22), list(78, 2, 18, 25), list(98, 2, 18, 24), list(114, 2, 19, 22), list(128, 2, 19, 20), list(140, 2, 19, 18), list(155, 3, 19, 16), list(195, 1, 19, 18)))), + BODY_ZONE_R_LEG = list("2" = list(12, 11.5, 30.5, list()), "1" = list(19, 18.5, 30.5, list()), "4" = list(16, 15.5, 30.0, list()), "8" = list(12, 12.0, 30.0, list())), + BODY_ZONE_L_LEG = list("2" = list(19, 18.5, 30.5, list()), "1" = list(12, 11.5, 30.5, list()), "4" = list(19, 19.0, 30.0, list()), "8" = list(16, 15.5, 30.0, list())), +)) + +/// Every slag decal, oldest first +GLOBAL_LIST_EMPTY(plasma_slag_decals) +/// The carbon whose limb is melting off right now, while tg's dismember runs: its blood loss stays, its splatter doesn't. +/// A weakref, so a runtime inside the dismember can't leave a hard ref to the body behind. +GLOBAL_DATUM(plasma_melt_no_splatter, /datum/weakref) + +/// The plasma melts on this body by zone; each keeps its stump drawn until that limb comes back +/mob/living/carbon/var/list/plasma_melts + +/// Melts `limb` off its owner through tg's burn loss and plays the melt over the body. Returns TRUE if it came off. +/proc/plasma_melt_limb(obj/item/bodypart/limb, attack_direction) + var/mob/living/carbon/victim = limb?.owner + if(!victim) + return FALSE + var/zone = limb.body_zone + var/list/drawn = plasma_melt_capture(victim, limb) + // tg drops what this limb wore or held the moment it comes off; keep their look on the body while it melts + var/list/worn_look = list() + var/mutable_appearance/shoes_look + var/list/may_drop = list() + if(istype(limb, /obj/item/bodypart/arm)) + var/obj/item/bodypart/arm/arm = limb + var/obj/item/held = victim.get_item_for_held_index(arm.held_index) + if(held) + var/icon_file = IS_RIGHT_INDEX(arm.held_index) ? held.righthand_file : held.lefthand_file + worn_look += held.build_worn_icon(default_layer = HANDS_LAYER, default_icon_file = icon_file, isinhands = TRUE) + may_drop[held] = "held" + if(istype(victim.overlays_standing[GLOVES_LAYER], /mutable_appearance)) + worn_look += new /mutable_appearance(victim.overlays_standing[GLOVES_LAYER]) + if(victim.gloves) + may_drop[victim.gloves] = "worn" + else if(victim.shoes) + if(istype(victim.overlays_standing[SHOES_LAYER], /mutable_appearance)) + shoes_look = new /mutable_appearance(victim.overlays_standing[SHOES_LAYER]) + may_drop[victim.shoes] = "worn" + var/turf/where = get_turf(victim) + var/list/old_ash = list() + for(var/obj/effect/decal/cleanable/ash/pile in where) + old_ash += pile + var/datum/wound/loss/melt = new + // The melt sound below is the only one: no tg dismember sound (see plasma_melt_in_progress()) + melt.sound_effect = null + GLOB.plasma_melt_no_splatter = WEAKREF(victim) + melt.apply_dismember(limb, WOUND_BURN, FALSE, attack_direction) + GLOB.plasma_melt_no_splatter = null + // tg leaves an ash pile from the burnt limb; the slag is the remains here + for(var/obj/effect/decal/cleanable/ash/pile in where) + if(!(pile in old_ash)) + qdel(pile) + if(QDELETED(victim) || victim.get_bodypart(zone) == limb) + return FALSE + var/list/dropped = list() + for(var/obj/item/thing as anything in may_drop) + if(!QDELETED(thing) && thing.loc != victim) + dropped[thing] = may_drop[thing] + new /datum/plasma_melt(victim, zone, drawn, worn_look, shoes_look, dropped) + if(!TIMER_COOLDOWN_RUNNING(victim, COOLDOWN_PLASMA_MELT_SOUND)) + TIMER_COOLDOWN_START(victim, COOLDOWN_PLASMA_MELT_SOUND, 0.5 SECONDS) + playsound(victim, 'voidcrew/modules/plasma_weapons/sound/plasma_melt.ogg', 60, TRUE) + return TRUE + +/// TRUE while a plasma melt is taking this body's limb. tg's dismember() checks it to skip its own sound (VOIDCREW EDIT). +/proc/plasma_melt_in_progress(mob/living/body) + return GLOB.plasma_melt_no_splatter && GLOB.plasma_melt_no_splatter.resolve() == body + +/// tg's dismember bleeds the body; on a plasma melt the blood is lost but no splatter lands (the slag stands in) +/mob/living/add_splatter_floor(turf/splatter_turf, small_drip = FALSE) + if(GLOB.plasma_melt_no_splatter && GLOB.plasma_melt_no_splatter.resolve() == src) + return + return ..() + +/// The limb's overlays exactly as the body draws them now (leg layer masks included, emissive blockers left out) +/proc/plasma_melt_capture(mob/living/carbon/victim, obj/item/bodypart/limb) + var/list/source = victim.limb_icon_cache?[victim.icon_render_keys?[limb.body_zone]] + if(!length(source)) + source = limb.get_limb_icon(dropped = FALSE, update_on = victim) + . = list() + for(var/image/piece in source) + if(piece.plane != FLOAT_PLANE) + continue + . += new /mutable_appearance(piece) + +/datum/plasma_melt + var/mob/living/carbon/victim + var/zone + /// The limb as it was drawn, captured before it came off + var/list/drawn + /// What this melt has on the body right now + var/list/shown = list() + /// TRUE once the jumpsuit and coat sleeves at this (arm) zone have burned away + var/sleeve_burned = FALSE + /// Facing and posture when the melt began running, which pick the melt frames, drops and pool spot + var/melt_dir + var/melt_lying = FALSE + /// Pool offsets on the victim's turf + var/pool_x = 0 + var/pool_y = 0 + /// Pool growth step shown (0 = none yet) + var/pool_step = 0 + var/obj/effect/plasma_melt_pool/pool + var/obj/effect/plasma_melt_light/limb_light + /// Bumped whenever a new sequence starts, so the old one's pending frames stop + var/play_id = 0 + /// Where the melt started: the pool and slag stay here even if the body moves on + var/turf/start_turf + /// Gobbets still falling (each with its column), so a body that moves doesn't leave one hanging in the air + var/list/falling = list() + /// What the limb wore or held, drawn with it while it heats (gloves and the held item's in-hand look) + var/list/worn_look + /// The shoes as worn; tg drops both, so the other foot keeps this look (masked off the melting leg) until the snap + var/mutable_appearance/shoes_look + var/mutable_appearance/shoes_masked + /// Items tg dropped when the limb came off, hidden until they fall out of the melt: item = "held" or "worn" + var/list/hidden_items = list() + /// world.time the body last changed tiles, the way it went and how long its slide takes + var/last_moved = -INFINITY + var/last_move_dir = 0 + var/glide_time = 0 + +/datum/plasma_melt/New(mob/living/carbon/victim, zone, list/drawn, list/worn_look, mutable_appearance/shoes_look, list/dropped) + src.victim = victim + src.zone = zone + src.drawn = drawn + src.worn_look = worn_look || list() + src.shoes_look = shoes_look + for(var/obj/item/thing as anything in dropped) + hide_item(thing, dropped[thing]) + var/datum/plasma_melt/old = LAZYACCESS(victim.plasma_melts, zone) + if(old) + qdel(old) + LAZYSET(victim.plasma_melts, zone, src) + RegisterSignal(victim, COMSIG_CARBON_POST_ATTACH_LIMB, PROC_REF(on_attach)) + RegisterSignal(victim, COMSIG_QDELETING, PROC_REF(on_victim_deleted)) + RegisterSignal(victim, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + start_turf = get_turf(victim) + pose_heat() + addtimer(CALLBACK(src, PROC_REF(pose_melt)), PLASMA_MELT_HEAT_END) + addtimer(CALLBACK(src, PROC_REF(pose_stump), "stump_bubble", TRUE), PLASMA_MELT_GONE) + addtimer(CALLBACK(src, PROC_REF(pose_stump), "stump_cool", FALSE), PLASMA_MELT_STUMP_COOL) + addtimer(CALLBACK(src, PROC_REF(pose_stump), "stump", FALSE), PLASMA_MELT_COOLED) + addtimer(CALLBACK(src, PROC_REF(pool_cool)), PLASMA_MELT_POOL_COOL) + addtimer(CALLBACK(src, PROC_REF(pool_set)), PLASMA_MELT_COOLED) + var/turf/here = get_turf(victim) + if(here) + limb_light = new(here) + limb_light.set_light_power(is_far() ? 0.3 : 0.5) + +/datum/plasma_melt/Destroy() + show(list()) + if(victim) + if(LAZYACCESS(victim.plasma_melts, zone) == src) + LAZYREMOVE(victim.plasma_melts, zone) + UnregisterSignal(victim, list(COMSIG_CARBON_POST_ATTACH_LIMB, COMSIG_QDELETING, COMSIG_MOVABLE_MOVED)) + if(sleeve_burned && ishuman(victim) && !QDELETED(victim)) + sleeve_burned = FALSE + var/mob/living/carbon/human/wearer = victim + wearer.update_worn_undersuit() + wearer.update_worn_oversuit() + victim = null + start_turf = null + for(var/obj/item/thing as anything in hidden_items) + if(hidden_items[thing] != "landed") + on_hidden_item_moved(thing) + continue + // an item still lying where it fell stays put as a plain floor item: its drop goes into pixel_y + UnregisterSignal(thing, list(COMSIG_MOVABLE_MOVED, COMSIG_QDELETING)) + animate(thing) + thing.pixel_y += thing.pixel_z - thing.base_pixel_z + thing.pixel_z = thing.base_pixel_z + thing.transform = matrix(thing.transform).Turn(thing.icon_angle) + hidden_items.Cut() + falling.Cut() + QDEL_NULL(limb_light) + if(!QDELETED(pool)) // a pool still hot when the melt ends sets where it is + pool_set() + pool = null + return ..() + +/datum/plasma_melt/proc/on_attach(mob/living/carbon/source, obj/item/bodypart/limb) + SIGNAL_HANDLER + if(limb.body_zone == zone) + qdel(src) + +/datum/plasma_melt/proc/on_victim_deleted(datum/source) + SIGNAL_HANDLER + qdel(src) + +/// The limb, stump and sleeve ride on the body as overlays and the limb's light follows it. A gobbet still in the air +/// when the body moves would hang where the body was, so it drops onto the floor under the body right away. +/datum/plasma_melt/proc/on_moved(atom/movable/source, atom/old_loc, dir, forced, list/old_locs) + SIGNAL_HANDLER + var/turf/here = get_turf(victim) + if(!here) + return + last_moved = world.time + last_move_dir = get_dir(old_loc, here) + glide_time = victim.glide_size > 0 ? (ICON_SIZE_X / victim.glide_size) * world.tick_lag : 0 + if(get_dist(old_loc, here) > 1) + glide_time = 0 // a jump, not a slide + if(limb_light) + limb_light.set_glide_size(victim.glide_size) + limb_light.forceMove(here) + for(var/obj/effect/temp_visual/plasma_melt_drop/drop as anything in falling) + drip_trail(falling[drop][1], falling[drop][2], PLASMA_MELT_POOL_TILE_Y - (PLASMA_MELT_FLOOR_ROW + PLASMA_MELT_POOL_FRONT)) + qdel(drop) + falling.Cut() + +/// Whether the body changed tiles so recently that its sprite may still be sliding there +/datum/plasma_melt/proc/moving() + return world.time - last_moved < PLASMA_MELT_GLIDE_WAIT + +/// Whether the body is still where the melt started (so drips feed the pool) +/datum/plasma_melt/proc/at_start() + return victim && get_turf(victim) == start_turf + +/datum/plasma_melt/proc/is_arm() + return zone == BODY_ZONE_R_ARM || zone == BODY_ZONE_L_ARM + +/// The limb is on the side of the body away from the viewer +/datum/plasma_melt/proc/is_far() + var/left = (zone == BODY_ZONE_L_ARM || zone == BODY_ZONE_L_LEG) + return (victim.dir == EAST && left) || (victim.dir == WEST && !left) + +/// Swaps what the melt draws on the body +/datum/plasma_melt/proc/show(list/pieces) + if(victim) + victim.cut_overlay(shown) + victim.add_overlay(pieces) + shown = pieces + +/// One body frame as its near/far pair (and their glow while hot) +/datum/plasma_melt/proc/pair(near_state, far_state, hot) + . = list( + mutable_appearance(PLASMA_MELT_ZONE_ICON(zone), near_state, -BODY_FRONT_LAYER), + mutable_appearance(PLASMA_MELT_ZONE_ICON(zone), far_state, -BODY_BEHIND_LAYER), + ) + if(hot) + . += emissive_appearance(PLASMA_MELT_ZONE_ICON(zone), near_state, victim, -BODY_FRONT_LAYER) + . += emissive_appearance(PLASMA_MELT_ZONE_ICON(zone), far_state, victim, -BODY_BEHIND_LAYER) + +/// Steps the body through `frames` single-frame states "_" (and "_far_"), `delay` apart. +/// An overlay runs on its parent's animation clock, so a one-shot DMI animation on a body would show only its last +/// frame; stepping states on timers plays it from the start. `heat` frames go over the limb as it was drawn. +/datum/plasma_melt/proc/play(base, frames, delay, hot = TRUE, heat = FALSE) + play_id++ + play_frame(play_id, base, 1, frames, delay, hot, heat) + +/datum/plasma_melt/proc/play_frame(id, base, frame, frames, delay, hot, heat) + if(id != play_id || !victim) + return + var/list/pieces + if(heat) + pieces = list() + for(var/mutable_appearance/source as anything in drawn) + pieces += new /mutable_appearance(source) + pieces += worn_look + if(shoes_look) + pieces += shoes_look + pieces += mutable_appearance(PLASMA_MELT_ZONE_ICON(zone), "[base]_[frame]", -BODY_FRONT_LAYER) + pieces += emissive_appearance(PLASMA_MELT_ZONE_ICON(zone), "[base]_[frame]", victim, -BODY_FRONT_LAYER) + else + pieces = pair("[base]_[frame]", "[base]_far_[frame]", hot) + if(shoes_masked) + pieces += shoes_masked + // the held item stays in the slumping hand until it lets go (its in-hand look is first in worn_look) + for(var/obj/item/thing as anything in hidden_items) + if(hidden_items[thing] == "held") + pieces += worn_look[1] + break + show(pieces) + if(frame < frames) + addtimer(CALLBACK(src, PROC_REF(play_frame), id, base, frame + 1, frames, delay, hot, heat), delay) + +/// 0 - 0.35 s: the limb as it was, heating from the joint outward +/datum/plasma_melt/proc/pose_heat() + play("[zone]_heat", 7, 0.05 SECONDS, heat = TRUE) + +/// 0.35 - 1.6 s: the limb loses its shape and runs off. Picks the frames, drops and pool spot for how the body lies now. +/datum/plasma_melt/proc/pose_melt() + if(!victim) + return + if(shoes_look) + shoes_masked = mask_off_limb(shoes_look) + melt_dir = victim.dir + melt_lying = victim.body_position == LYING_DOWN + play((is_arm() && melt_lying) ? "[zone]_melt_floor" : "[zone]_melt", 25, 0.05 SECONDS) + if(is_arm()) + burn_sleeve() + // a held item falls out once the hand has run off + addtimer(CALLBACK(src, PROC_REF(reveal_items), "held"), PLASMA_MELT_LET_GO - PLASMA_MELT_HEAT_END) + find_pool_spot() + var/list/geometry = GLOB.plasma_melt_geometry[zone]?["[melt_dir]"] + var/list/drop_list = (is_arm() && !melt_lying && geometry) ? geometry[4] : list() + if(length(drop_list)) + var/total = 0 + for(var/list/drop as anything in drop_list) + total += drop[2] + var/landed = 0 + var/behind = is_far() + for(var/list/drop as anything in drop_list) + var/release = drop[1] * 0.01 SECONDS + var/fall_time = plasma_melt_fall_time(pool_top_row() - drop[4]) + landed += drop[2] + var/grow_to = clamp(CEILING(landed / total * PLASMA_MELT_POOL_STEPS, 1), 1, PLASMA_MELT_POOL_STEPS) + addtimer(CALLBACK(src, PROC_REF(release_drop), drop[2], drop[3], drop[4], fall_time, grow_to, behind), max(release - PLASMA_MELT_HEAT_END, 1)) + return + // legs, and any limb on a lying body: the pool grows with the melt (storyboard pool_growth) + for(var/grow_to in 1 to PLASMA_MELT_POOL_STEPS) + var/when = plasma_melt_growth_time(grow_to / PLASMA_MELT_POOL_STEPS, melt_lying) + addtimer(CALLBACK(src, PROC_REF(grow_pool), grow_to), max(when - PLASMA_MELT_HEAT_END, 1)) + +/// 1.6 s on: the stump bubbles, cools, then stays charred until the limb comes back +/datum/plasma_melt/proc/pose_stump(suffix, hot) + if(!victim) + return + switch(suffix) + if("stump_bubble") + // the last of the limb lets go: what it wore lands on the floor + shoes_masked = null + reveal_items() + play("[zone]_stump_bubble", 16, 0.05 SECONDS) + if("stump_cool") + play("[zone]_stump_cool", 10, 0.2 SECONDS, hot = FALSE) + else + play_id++ + show(pair("[zone]_stump", "[zone]_stump_far", FALSE)) + if(suffix == "stump_bubble") + limb_light?.set_light_power(is_far() ? 0.2 : 0.3) + else if(suffix == "stump_cool") + QDEL_NULL(limb_light) + +/// A copy of `look` with this limb's pixels cut out of it, in every facing +/datum/plasma_melt/proc/mask_off_limb(mutable_appearance/look) + var/mutable_appearance/masked = new(look) + var/static/atom/movable/limb_mask + if(isnull(limb_mask)) + limb_mask = new(null) + limb_mask.icon = PLASMA_MELT_ZONE_ICON(zone) + limb_mask.icon_state = "[zone]_sleeve_mask" + limb_mask.render_target = "*PlasmaMeltWorn [REF(victim)] [zone]" + masked.add_overlay(limb_mask) + masked.filters += filter(type = "alpha", render_source = limb_mask.render_target, flags = MASK_INVERSE) + return masked + +/// Hides an item tg dropped off the melting limb until it falls out of the melt. Only its look changes; anyone who +/// picks it up or moves it gets it back at once. +/datum/plasma_melt/proc/hide_item(obj/item/thing, kind) + hidden_items[thing] = kind + thing.alpha = 0 + RegisterSignals(thing, list(COMSIG_MOVABLE_MOVED, COMSIG_QDELETING), PROC_REF(on_hidden_item_moved)) + +/// Shows a hidden item as it always looks. One that fell out of the melt lies drawn lower and turned flat on its +/// tile; once anyone moves it, it sits at its tile's middle with its own look. +/datum/plasma_melt/proc/on_hidden_item_moved(obj/item/source) + SIGNAL_HANDLER + UnregisterSignal(source, list(COMSIG_MOVABLE_MOVED, COMSIG_QDELETING)) + if(hidden_items[source] == "landed") + animate(source) + source.pixel_x = source.base_pixel_x + source.pixel_y = source.base_pixel_y + source.pixel_z = source.base_pixel_z + source.transform = matrix(source.transform).Turn(source.icon_angle) + hidden_items -= source + source.alpha = initial(source.alpha) + +/// Shows a hidden item falling out of the melt, from the hand or foot as the body stands or lies now, to the floor +/// beside the feet (standing) or under the body (lying). tg's own drop animation played while it was hidden, so this +/// stands in for it. The item stays on the body's row and drops through pixel_z, so it draws under the body the +/// whole way, as a dropped item does. +/datum/plasma_melt/proc/reveal_item(obj/item/thing) + var/kind = hidden_items[thing] + var/list/geometry = victim && GLOB.plasma_melt_geometry[zone]?["[victim.dir]"] + if(!geometry || QDELETED(thing) || thing.loc != get_turf(victim) || moving()) + on_hidden_item_moved(thing) + return + hidden_items[thing] = "landed" + thing.alpha = initial(thing.alpha) + var/lying = victim.body_position == LYING_DOWN + // the hand or foot, from the sprite centre (y up) + var/u = geometry[1] - 15.5 + var/v = 15.5 - (is_arm() ? PLASMA_MELT_HAND_ROW : PLASMA_MELT_FOOT_ROW) + if(lying) + var/matrix/turn = victim.transform + var/screen_x = geometry[2] - 15.5 + var/screen_y = 15.5 - geometry[3] + u = turn.a * screen_x + turn.b * screen_y + turn.c + v = turn.d * screen_x + turn.e * screen_y + turn.f + else if(kind == "held" && length(geometry[4])) + var/list/drop = geometry[4][1] + u = drop[3] - 15.5 + var/body_x = victim.pixel_x + victim.pixel_w + // beside the feet on the limb's side facing N or S, behind the heels facing E or W + var/side = u < 0 ? -1 : 1 + if(victim.dir == EAST) + side = -1 + else if(victim.dir == WEST) + side = 1 + var/from_x = thing.base_pixel_x + round(u + body_x) + var/from_y = thing.base_pixel_z + round(v + victim.pixel_z) + var/to_x = thing.base_pixel_x + (lying ? clamp(round(u + body_x) + rand(-2, 2), -10, 10) : round(body_x) + side * (PLASMA_MELT_ITEM_SIDE + rand(0, 2))) + var/to_y = thing.base_pixel_z + victim.pixel_z + (lying ? PLASMA_MELT_ITEM_FLOOR_LYING : PLASMA_MELT_ITEM_FLOOR) + rand(-1, 1) + to_y = min(to_y, from_y - 1) + // starts the size and angle it had in the hand (or on the foot) and ends flat on the floor + var/matrix/old_transform = thing.transform + var/matrix/animation_matrix = matrix(old_transform) + animation_matrix.Scale(kind == "held" ? 0.6 : 0.85) + animation_matrix.Turn(pick(-25, 25)) + animate(thing) + thing.pixel_y = thing.base_pixel_y + victim.pixel_y + thing.pixel_x = from_x + thing.pixel_z = from_y + thing.transform = animation_matrix + old_transform = matrix(old_transform).Turn(-thing.icon_angle) + var/fall_time = plasma_melt_fall_time(from_y - to_y) + if(from_y - to_y < 6) + // a short drop (shoes off a foot already at the floor) tips up and over to the side instead + fall_time = 0.1 SECONDS + animate(thing, pixel_x = (from_x + to_x) / 2, pixel_z = from_y + 3, transform = matrix(animation_matrix).Turn(pick(-15, 15)), time = fall_time, easing = QUAD_EASING | EASE_OUT) + animate(pixel_x = to_x, pixel_z = to_y, transform = old_transform, time = fall_time + 0.05 SECONDS, easing = QUAD_EASING | EASE_IN) + else + // never quicker than 0.2 s, so it is seen shrinking out of the hand and turning flat as it drops + animate(thing, pixel_x = to_x, pixel_z = to_y, transform = old_transform, time = max(fall_time, 0.2 SECONDS), easing = QUAD_EASING | EASE_IN) + animate(pixel_z = to_y + 1, time = 0.05 SECONDS) + animate(pixel_z = to_y, time = 0.05 SECONDS) + +/// Lets the hidden items of one `kind`, or all of them, fall out of the melt +/datum/plasma_melt/proc/reveal_items(kind) + for(var/obj/item/thing as anything in hidden_items) + if(hidden_items[thing] != "landed" && (!kind || hidden_items[thing] == kind)) + reveal_item(thing) + +/// Burns the jumpsuit and coat sleeves away at this missing arm (masked on the worn overlays, see plasma_mask_burned_sleeves). +/// The sleeve mask leaves out every pixel over the chest: where the arm hangs across the torso (the near arm facing +/// east or west) the cloth under it is the shirt, and cutting it would bare the chest in the shape of the lost arm. +/datum/plasma_melt/proc/burn_sleeve() + if(sleeve_burned || !ishuman(victim)) + return + sleeve_burned = TRUE + var/mob/living/carbon/human/wearer = victim + wearer.update_worn_undersuit() + wearer.update_worn_oversuit() + +/// Where the pool lies on the victim's turf: in front of the feet standing, under the limb's place lying +/datum/plasma_melt/proc/find_pool_spot() + var/list/geometry = GLOB.plasma_melt_geometry[zone]?["[melt_dir]"] + if(!geometry) + return + var/body_x = victim.pixel_x + victim.pixel_w + var/body_y = victim.pixel_y + victim.pixel_z + if(!melt_lying) + pool_x = round(geometry[1] - PLASMA_MELT_POOL_TILE_X + body_x) + pool_y = round(PLASMA_MELT_POOL_TILE_Y - (PLASMA_MELT_FLOOR_ROW + PLASMA_MELT_POOL_FRONT) + body_y) + return + // the limb's middle, from the sprite centre (y up), through the body's lying turn + var/u = geometry[2] - 15.5 + var/v = 15.5 - geometry[3] + var/matrix/turn = victim.transform + var/screen_x = turn.a * u + turn.b * v + turn.c + var/screen_y = turn.d * u + turn.e * v + turn.f + pool_x = round(screen_x + 0.5 + body_x) + pool_y = round(screen_y + 5.5 + plasma_melt_lying_side(turn, u, v) * lying_front() + body_y) + +/// How far a lying body's pool sits out past the limb's place: arms clear the torso, legs stay on the leg's line +/datum/plasma_melt/proc/lying_front() + return is_arm() ? PLASMA_MELT_POOL_FRONT_LYING : 0 + +/// Row (sprite pixels from the top) where a drop lands: the top of the pool's middle +/datum/plasma_melt/proc/pool_top_row() + return PLASMA_MELT_FLOOR_ROW + PLASMA_MELT_POOL_FRONT - 1 + +/// One gobbet leaves the hanging limb, falls and lands in the pool +/datum/plasma_melt/proc/release_drop(size, x, start_row, fall_time, grow_to, behind) + if(!victim) + return + var/turf/here = get_turf(victim) + if(!here) + return + if(moving()) + // a body on the move drips onto the floor under wherever it is when the drip lands + addtimer(CALLBACK(src, PROC_REF(drip_trail), size, x - PLASMA_MELT_POOL_TILE_X, PLASMA_MELT_POOL_TILE_Y - (PLASMA_MELT_FLOOR_ROW + PLASMA_MELT_POOL_FRONT)), fall_time) + return + var/body_x = victim.pixel_x + victim.pixel_w + var/body_y = victim.pixel_y + victim.pixel_z + var/obj/effect/temp_visual/plasma_melt_drop/drop = new(here, size, x - PLASMA_MELT_DROP_TILE + body_x, PLASMA_MELT_DROP_TILE - start_row + body_y, PLASMA_MELT_DROP_TILE - pool_top_row() + body_y, fall_time, behind) + falling[drop] = list(size, x - PLASMA_MELT_POOL_TILE_X) + addtimer(CALLBACK(src, PROC_REF(drop_landed), size, grow_to, drop, x), fall_time) + +/datum/plasma_melt/proc/drop_landed(size, grow_to, obj/effect/temp_visual/plasma_melt_drop/drop, x) + if(!(drop in falling)) // the body moved while it fell; it already landed under the body + return + falling -= drop + if(!at_start()) + // the body moved on: the gobbet lands under it as a small splash that cools away + if(!QDELETED(drop)) + qdel(drop) + drip_trail(size, x - PLASMA_MELT_POOL_TILE_X, PLASMA_MELT_POOL_TILE_Y - (PLASMA_MELT_FLOOR_ROW + PLASMA_MELT_POOL_FRONT)) + return + if(!pool) + make_pool() + if(!pool) + return + if(size > 1) + new /obj/effect/temp_visual/plasma_melt_splat(pool.loc, pool.pixel_x, pool.pixel_y) + grow_pool(grow_to) + +/// A drip that missed the pool because the body moved: a splash and a small cooling spot under the body, gone in a +/// few seconds (the pool on the start tile holds the lasting slag). A body still sliding onto its tile is drawn +/// part of the way back, so the drip lands under where the body is seen, not where its tile is. +/datum/plasma_melt/proc/drip_trail(size, px, py) + if(!victim) + return + var/turf/here = get_turf(victim) + if(!isopenturf(here)) + return + px += victim.pixel_x + victim.pixel_w + py += victim.pixel_y + victim.pixel_z + var/left = glide_time - (world.time - last_moved) + if(left > 0 && last_move_dir) + var/behind = ICON_SIZE_X * left / glide_time + if(last_move_dir & EAST) + px -= behind + if(last_move_dir & WEST) + px += behind + if(last_move_dir & NORTH) + py -= behind + if(last_move_dir & SOUTH) + py += behind + px = round(px) + py = round(py) + if(size > 1) + new /obj/effect/temp_visual/plasma_melt_splat(here, px, py) + new /obj/effect/temp_visual/plasma_melt_drip_spot(here, px, py, size) + +/// Under the limb's place on the body as it lies or stands now, as offsets for the pool tile +/datum/plasma_melt/proc/spot_now() + var/list/geometry = GLOB.plasma_melt_geometry[zone]?["[victim.dir]"] + if(!geometry) + return list(0, 0) + if(victim.body_position != LYING_DOWN) + return list(round(geometry[1] - PLASMA_MELT_POOL_TILE_X), PLASMA_MELT_POOL_TILE_Y - (PLASMA_MELT_FLOOR_ROW + PLASMA_MELT_POOL_FRONT)) + var/u = geometry[2] - 15.5 + var/v = 15.5 - geometry[3] + var/matrix/turn = victim.transform + return list(round(turn.a * u + turn.b * v + turn.c + 0.5), round(turn.d * u + turn.e * v + turn.f + 5.5 + plasma_melt_lying_side(turn, u, v) * lying_front())) + +/datum/plasma_melt/proc/make_pool() + if(!isopenturf(start_turf)) + return + pool = new(start_turf) + pool.pixel_x = pool_x + pool.pixel_y = pool_y + +/datum/plasma_melt/proc/grow_pool(grow_to) + if(victim && !at_start()) + // a slumping limb on a body that has moved on runs off under it instead + if(grow_to % 2 == 0) + var/list/spot = spot_now() + drip_trail(1, spot[1], spot[2]) + return + if(!pool) + make_pool() + if(!pool || grow_to <= pool_step) + return + pool_step = grow_to + pool.grow(grow_to) + +/// 2.2 s: the pool crusts from the rim inward +/datum/plasma_melt/proc/pool_cool() + pool?.start_cooling() + +/// 4.5 s: the pool sets into a lasting slag decal (one per turf, capped) +/datum/plasma_melt/proc/pool_set() + if(QDELETED(pool)) + pool = null + return + var/turf/here = get_turf(pool) + if(pool_step >= PLASMA_MELT_POOL_STEPS / 2) + plasma_slag_at(here, pool.pixel_x, pool.pixel_y) + QDEL_NULL(pool) + return + // too little reached the pool to leave slag (the body moved off early): it cools away + var/obj/effect/plasma_melt_pool/small = pool + pool = null + animate(small, alpha = 0, color = "#3a2a20", time = 2 SECONDS) + QDEL_IN(small, 2 SECONDS) + +/// 1 if the limb's spot (u, v from the sprite centre) is above the lying body's middle on screen, else -1: +/// the pool goes out past that side so the body doesn't hide it +/proc/plasma_melt_lying_side(matrix/turn, u, v) + return (turn.d * u + turn.e * v) > 0 ? 1 : -1 + +/// Deciseconds a drop takes to fall `rows` pixels: the storyboard's fall(dt) = 30 dt + 260 dt^2 (dt in seconds) +/proc/plasma_melt_fall_time(rows) + rows = max(rows, 1) + var/seconds = (-30 + sqrt(900 + 1040 * rows)) / 520 + return max(round(seconds * 20) / 20, 0.05) * 1 SECONDS + +/// When the pool of a slumping limb reaches `fraction` of its size (storyboard pool_growth), from the hit +/proc/plasma_melt_growth_time(fraction, lying) + var/start = lying ? 0.08 : 0.2 + var/span = lying ? 0.92 : 0.8 + for(var/tick in 0 to 25) + var/x = clamp((tick / 25 - start) / span, 0, 1) + if(x * x * (3 - 2 * x) >= fraction - 0.001) + return PLASMA_MELT_HEAT_END + tick * 0.05 SECONDS + return PLASMA_MELT_GONE + +/// Slag on `where`: keeps slag already there, otherwise makes new slag, dropping the oldest over `cap` +/proc/plasma_slag_at(turf/open/where, px = 0, py = 0, cap = PLASMA_SLAG_GLOBAL_CAP) + if(!isopenturf(where)) + return null + var/obj/effect/decal/cleanable/plasma_slag/slag = locate() in where + if(slag) + return slag + while(length(GLOB.plasma_slag_decals) >= max(cap, 1)) + var/obj/effect/decal/cleanable/plasma_slag/oldest = GLOB.plasma_slag_decals[1] + GLOB.plasma_slag_decals -= oldest + qdel(oldest) + slag = new(where) + if(QDELETED(slag)) + return null + slag.pixel_x = clamp(px, -16, 16) + slag.pixel_y = clamp(py, -16, 16) + return slag + +// ===== Sleeve ===== + +/// A plasma-melted arm takes its sleeves with it: tg keeps the jumpsuit and coat sleeves on a missing arm, so mask +/// them off the uniform and suit overlays the way tg masks gloves on a missing hand. Only arms that melted, and only +/// until they come back. +/mob/living/carbon/human/update_worn_undersuit(update_obscured = TRUE) + . = ..(update_obscured) + plasma_mask_burned_sleeves(UNIFORM_LAYER) + +/mob/living/carbon/human/update_worn_oversuit(update_obscured = TRUE) + . = ..(update_obscured) + plasma_mask_burned_sleeves(SUIT_LAYER) + +/// Cuts every burned sleeve out of the worn overlay at `layer` +/mob/living/carbon/human/proc/plasma_mask_burned_sleeves(layer) + var/list/burned = list() + for(var/zone in plasma_melts) + var/datum/plasma_melt/melt = plasma_melts[zone] + if(melt.sleeve_burned) + burned += zone + if(!length(burned)) + return + var/mutable_appearance/worn_overlay = overlays_standing[layer] + if(!istype(worn_overlay)) + return + remove_overlay(layer) + var/static/atom/movable/sleeve_mask + if(isnull(sleeve_mask)) + sleeve_mask = new(null) + for(var/zone in burned) + sleeve_mask.icon = PLASMA_MELT_ZONE_ICON(zone) + sleeve_mask.icon_state = "[zone]_sleeve_mask" + sleeve_mask.render_target = "*PlasmaSleeve [REF(src)] [zone] [layer]" + worn_overlay.add_overlay(sleeve_mask) + worn_overlay.filters += filter(type = "alpha", render_source = sleeve_mask.render_target, flags = MASK_INVERSE) + overlays_standing[layer] = worn_overlay + apply_overlay(layer) + +// ===== Effects ===== + +/// A gobbet falling off a hanging arm. Offsets use pixel_w/pixel_z so it keeps the body's row; layer alone puts it +/// in front of the body or, for a far arm, behind it. +/obj/effect/temp_visual/plasma_melt_drop + icon = PLASMA_MELT_ICON + icon_state = "drip_gob" + layer = ABOVE_MOB_LAYER + randomdir = FALSE + duration = 1 SECONDS + +/obj/effect/temp_visual/plasma_melt_drop/Initialize(mapload, size = 2, start_x = 0, start_y = 0, land_y = 0, fall_time = 0.2 SECONDS, behind = FALSE) + duration = fall_time + icon_state = list("drip_small", "drip_gob", "drip_big")[clamp(size, 1, 3)] + . = ..() + if(behind) + layer = BELOW_MOB_LAYER + pixel_w = start_x + pixel_z = start_y + add_overlay(emissive_appearance(icon, icon_state, src)) + animate(src, pixel_z = land_y, time = fall_time, easing = QUAD_EASING | EASE_IN) + +/// The flash where a gobbet lands in the pool +/obj/effect/temp_visual/plasma_melt_splat + icon = PLASMA_MELT_ICON + icon_state = "splat" + plane = FLOOR_PLANE + layer = CLEANABLE_FLOOR_OBJECT_LAYER + randomdir = FALSE + duration = 0.15 SECONDS + +/obj/effect/temp_visual/plasma_melt_splat/Initialize(mapload, px = 0, py = 0) + . = ..() + pixel_x = px + pixel_y = py + add_overlay(emissive_appearance(icon, icon_state, src)) + +/// A drip that landed away from the pool: a small hot spot that darkens and fades +/obj/effect/temp_visual/plasma_melt_drip_spot + icon = PLASMA_MELT_ICON + icon_state = "pool_1" + plane = FLOOR_PLANE + layer = CLEANABLE_FLOOR_OBJECT_LAYER + randomdir = FALSE + duration = 3 SECONDS + +/obj/effect/temp_visual/plasma_melt_drip_spot/Initialize(mapload, px = 0, py = 0, size = 1) + icon_state = size > 1 ? "pool_2" : "pool_1" + . = ..() + pixel_x = px + pixel_y = py + animate(src, color = "#3a2a20", time = 1.5 SECONDS) + animate(alpha = 0, time = 1.5 SECONDS) + +/// The molten pool while it grows and cools; it becomes slag when the melt sets +/obj/effect/plasma_melt_pool + name = "molten slag" + icon = PLASMA_MELT_ICON + icon_state = null + plane = FLOOR_PLANE + layer = CLEANABLE_FLOOR_OBJECT_LAYER + anchored = TRUE + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + light_system = OVERLAY_LIGHT + light_range = 1 + light_power = 0.2 + light_color = PLASMA_LIGHT_COLOR + light_on = FALSE + +/obj/effect/plasma_melt_pool/proc/grow(grow_to) + icon_state = grow_to >= PLASMA_MELT_POOL_STEPS ? "pool_hot" : "pool_[grow_to]" + glow() + set_light_power(0.2 + 0.3 * grow_to / PLASMA_MELT_POOL_STEPS) + set_light_on(TRUE) + +/obj/effect/plasma_melt_pool/proc/start_cooling() + icon_state = "slag_cool" + glow() + set_light_power(0.25) + addtimer(CALLBACK(src, TYPE_PROC_REF(/atom, set_light_power), 0.12), 0.8 SECONDS) + addtimer(CALLBACK(src, TYPE_PROC_REF(/atom, set_light_on), FALSE), 1.4 SECONDS) + +/obj/effect/plasma_melt_pool/proc/glow() + cut_overlays() + add_overlay(emissive_appearance(icon, icon_state, src)) + +/// Light at the melting limb +/obj/effect/plasma_melt_light + anchored = TRUE + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + light_system = OVERLAY_LIGHT + light_range = 1 + light_power = 0.5 + light_color = PLASMA_LIGHT_COLOR + light_on = TRUE + +/// What a plasma melt leaves on the floor +/obj/effect/decal/cleanable/plasma_slag + name = "slag" + desc = "A puddle of green-black slag, cooled hard." + icon = PLASMA_MELT_ICON + icon_state = "slag" + beauty = -50 + +/obj/effect/decal/cleanable/plasma_slag/Initialize(mapload, list/datum/disease/diseases) + . = ..() + if(. == INITIALIZE_HINT_QDEL) + return + GLOB.plasma_slag_decals += src + +/obj/effect/decal/cleanable/plasma_slag/Destroy() + GLOB.plasma_slag_decals -= src + return ..() + +#undef PLASMA_MELT_ICON +#undef PLASMA_MELT_ZONE_ICON +#undef PLASMA_MELT_HEAT_END +#undef PLASMA_MELT_GONE +#undef PLASMA_MELT_LET_GO +#undef PLASMA_MELT_HAND_ROW +#undef PLASMA_MELT_FOOT_ROW +#undef PLASMA_MELT_ITEM_FLOOR +#undef PLASMA_MELT_ITEM_FLOOR_LYING +#undef PLASMA_MELT_ITEM_SIDE +#undef COOLDOWN_PLASMA_MELT_SOUND +#undef PLASMA_MELT_STUMP_COOL +#undef PLASMA_MELT_POOL_COOL +#undef PLASMA_MELT_COOLED +#undef PLASMA_MELT_POOL_STEPS +#undef PLASMA_MELT_FLOOR_ROW +#undef PLASMA_MELT_POOL_FRONT +#undef PLASMA_MELT_POOL_FRONT_LYING +#undef PLASMA_MELT_POOL_TILE_X +#undef PLASMA_MELT_POOL_TILE_Y +#undef PLASMA_MELT_DROP_TILE +#undef PLASMA_MELT_GLIDE_WAIT +#undef PLASMA_SLAG_GLOBAL_CAP diff --git a/voidcrew/modules/plasma_weapons/plasma_pinch.dm b/voidcrew/modules/plasma_weapons/plasma_pinch.dm new file mode 100644 index 00000000000..0ebf5ebef58 --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_pinch.dm @@ -0,0 +1,349 @@ +// Package: P9 pinch. The barrel pinch when a plasma gun fires. +// +// A ball swells at the barrel with a flash on the hands and a small green light, snaps small as the bolt +// leaves it, and a ring opens where the ball was. +// +// Placement is a local table of pixel offsets by facing, read off the wielded in-hand sprite. The +// pieces ride the shooter's vis_contents, so they stay at the barrel if the shooter moves or is turned +// over. When gun_feel's barrel placement is on master this table gives way to it. + +/// When the ball snaps small, at the end of its swell frame. The bolt shows and the ring opens from here +#define PLASMA_PINCH_SNAP_TIME (0.14 SECONDS) +/// How long the ring takes to open and fade +#define PLASMA_PINCH_RING_TIME (0.2 SECONDS) +/// How far the bolt's nose sits ahead of its icon centre +#define PLASMA_PINCH_BOLT_NOSE 13 +/// How far past the ball the bolt's nose shows when it appears +#define PLASMA_PINCH_BOLT_LEAD 12 +/// Layer of a bolt while it leaves the pinch, just over the ball and ring +#define PLASMA_PINCH_BOLT_LAYER (ABOVE_MOB_LAYER + 0.01) +/// How much faster than the real bolt its copy closes on it, in pixels per decisecond (the bolt flies 32) +#define PLASMA_PINCH_CATCH_UP 12 +/// Shot north, how far past the shooter's centre the bolt's centre is before it shows: its tail is clear of the head +#define PLASMA_PINCH_NORTH_CLEAR 32 +/// The pinch light's brightness as an overlay light's alpha (light_power 1) +#define PLASMA_PINCH_LIGHT_ALPHA 150 +/// How long the light takes to grow from the pinch light to the bolt's, from the snap +#define PLASMA_PINCH_LIGHT_RAMP (0.2 SECONDS) + +/obj/item/gun/energy/plasma_weapon + /// Where the pinch sits on the shooter, as "[dir]" = list(pixel_x, pixel_y). Null for a gun with no pinch + var/list/pinch_offsets + +// The Furnace's emitter is the green tip of its wielded in-hand sprite: the east and west sprites end +// at (32, 17) and (1, 17) in icon pixels. Facing north or south the rifle lies across the body, so +// the ball forms on the bolt's path in front of the chest instead (design decision 5). +/obj/item/gun/energy/plasma_weapon/rifle + pinch_offsets = list( + "1" = list(0, 1), + "2" = list(0, 1), + "4" = list(15, 1), + "8" = list(-16, 1), + ) + +/// The plasma ball and ring at the barrel as `user` fires at `target` +/obj/item/gun/energy/plasma_weapon/proc/plasma_pinch(mob/living/user, atom/target) + if(!pinch_offsets || !istype(user) || loc != user || !isturf(user.loc)) + return + var/list/offset = pinch_offsets["[user.dir]"] + if(!offset) + return + // Shooting away from the viewer, the muzzle is on the far side of the body + var/new_layer = user.dir == NORTH ? BELOW_MOB_LAYER : ABOVE_MOB_LAYER + new /obj/effect/temp_visual/plasma_pinch(null, user, offset[1], offset[2], new_layer) + +/obj/effect/temp_visual/plasma_pinch + name = "plasma" + // Its "pinch" state (set with the type in plasma_types.dm) holds the whole move, ball, snap and ring, so the + // timing is the client's and can't slip a tick + duration = PLASMA_PINCH_SNAP_TIME + PLASMA_PINCH_RING_TIME + randomdir = FALSE + appearance_flags = KEEP_APART | RESET_COLOR | TILE_BOUND | PIXEL_SCALE + vis_flags = VIS_INHERIT_PLANE + /// Who we are drawn on + var/atom/movable/shooter + +/obj/effect/temp_visual/plasma_pinch/Initialize(mapload, atom/movable/holder, px = 0, py = 0, new_layer = ABOVE_MOB_LAYER) + . = ..() + pixel_x = px + pixel_y = py + layer = new_layer + // In front of the body we draw above the lighting, off the game world's ambient occlusion: its drop shadow under + // a bright ball darkened the floor around it. Behind the body we stay on the shooter's plane so the body hides us + if(holder && new_layer != BELOW_MOB_LAYER) + vis_flags = NONE + SET_PLANE_EXPLICIT(src, ABOVE_LIGHTING_PLANE, holder) + // The state has a dir per facing: the ring opens a pixel ahead of the ball along the shot + if(holder) + dir = holder.dir + // The flash on the hands, under the ball so it lights the hands and rifle but doesn't wash the ball out. The light + // sits on the turf, so it still shows when the body hides the ball + var/mutable_appearance/flash = mutable_appearance(icon, "pinch_glow") + flash.blend_mode = BLEND_ADD + underlays += flash + // No bloom: the flash and the light already glow + add_overlay(emissive_appearance(icon, icon_state, src, effect_type = EMISSIVE_NO_BLOOM)) + // Gone on the client's clock when the state ends, whenever the delete lands + animate(src, time = duration) + animate(alpha = 0, time = 0) + if(!holder) + return + var/obj/effect/temp_visual/plasma_burst_light/pinch/glow = new(holder.loc) + glow.pixel_w = px + glow.pixel_z = py + shooter = holder + shooter.vis_contents += src + RegisterSignal(shooter, COMSIG_QDELETING, PROC_REF(shooter_gone)) + +/obj/effect/temp_visual/plasma_pinch/Destroy() + shooter_gone() + return ..() + +/obj/effect/temp_visual/plasma_pinch/proc/shooter_gone(datum/source) + SIGNAL_HANDLER + if(!shooter) + return + UnregisterSignal(shooter, COMSIG_QDELETING) + shooter.vis_contents -= src + shooter = null + +/// The pinch's small green light, on the ball. The bolt's copy takes the light over at this size when the ball snaps +/obj/effect/temp_visual/plasma_burst_light/pinch + duration = PLASMA_PINCH_SNAP_TIME + light_range = 1 + light_power = 1 + +// A bolt from a gun with a pinch stays out of sight until the ball snaps: the ball is the bolt forming. By then the +// real bolt is most of a tile ahead, so a copy of it shows at the ball and closes on it. The copy rides the real bolt's +// vis_contents. Every move the bolt makes, the copy queues a change of its offset over the same time, so the copy +// holds still at the ball and then closes on the bolt in step with the bolt's own moves, however late they come. The +// real bolt shows only once the copy has lain on it for a while. Until then the bolt has no icon: it keeps its alpha, +// so its own fade at a hit takes the copy out with it. Nothing here animates the real bolt: its movement steps continue +// its newest animation sequence, and a hold or offset of ours would make them queue. +/obj/projectile/energy/plasma_bolt + /// The copy we see while this bolt leaves the barrel pinch, null once this bolt shows + var/obj/effect/abstract/plasma_pinch_bolt/pinch_copy + +/obj/projectile/energy/plasma_bolt/fire(fire_angle, atom/direct_target) + var/obj/item/gun/energy/plasma_weapon/gun = fired_from + var/list/offset = istype(gun) && firer && gun.loc == firer ? gun.pinch_offsets?["[firer.dir]"] : null + var/turf/barrel_turf = get_turf(firer) + var/shot_north = firer?.dir == NORTH + . = ..() + if(!offset || QDELETED(src) || deletion_queued || !isturf(loc) || !movement_vector || !barrel_turf) + return + // The bolt leaves over the ring. Shot north it is past the body, on the row behind, by the time it shows + if(!shot_north) + layer = PLASMA_PINCH_BOLT_LAYER + addtimer(VARSET_CALLBACK(src, layer, initial(layer)), PLASMA_PINCH_SNAP_TIME + PLASMA_PINCH_RING_TIME) + pinch_copy = new(null, src, barrel_turf, offset, shot_north) + +// plasma_bolt.dm's own move_animate() (the stop at an impact) runs first, through ..() +/obj/projectile/energy/plasma_bolt/move_animate(animate_x, animate_y, animate_time = world.tick_lag, deleting = FALSE) + var/from_x = pixel_x + var/from_y = pixel_y + . = ..() + pinch_copy?.follow(from_x, from_y, animate_x, animate_y, animate_time, deleting) + +/obj/projectile/energy/plasma_bolt/Destroy() + QDEL_NULL(pinch_copy) + return ..() + +/// The bolt as it leaves the pinch, drawn on the real bolt at an offset that closes to nothing +/obj/effect/abstract/plasma_pinch_bolt + name = "plasma" + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + /// The real bolt we ride + var/obj/projectile/energy/plasma_bolt/bolt + /// The real bolt's icon, given back at the handover + var/bolt_icon + /// world.time of the shot. Times below are deciseconds after it, on the bolt's queue of moves + var/fired_at = 0 + /// world.time of the bolt's last move, so we know how far it moves in a tick + var/last_move_at = 0 + /// How many pixels the bolt moves this tick, over one tick + var/tick_pixels = 0 + /// When the bolt's queued moves, and our offsets with them, end + var/queued_to = 0 + /// Map pixel where our centre shows when the ball snaps, or null to show on the real bolt + var/start_x + var/start_y + /// Our offset from the bolt when we show, null until the bolt's moves reach that point + var/show_x + var/show_y + /// When we show, and how long we then take to close on the bolt + var/show_at = PLASMA_PINCH_SNAP_TIME + var/catch_up_time = 0 + /// Shot north: how far along the shot the bolt's centre is before we show + var/north_clear + /// Whether our queue shows us yet + var/shown = FALSE + /// Whether the real bolt shows. We stay on it, unseen, to carry its light until the light has grown + var/handed_over = FALSE + /// Carries the bolt's light on us, from the pinch light's size up to the bolt's + var/obj/effect/abstract/glow_holder + +/obj/effect/abstract/plasma_pinch_bolt/Initialize(mapload, obj/projectile/energy/plasma_bolt/real_bolt, turf/barrel_turf, list/offset, shot_north) + . = ..() + if(!real_bolt || !isturf(real_bolt.loc) || !real_bolt.movement_vector || !barrel_turf) + return INITIALIZE_HINT_QDEL + bolt = real_bolt + fired_at = world.time + last_move_at = world.time + // The bolt's light goes with us until the handover. Off before we take its look, or its light comes along + bolt.set_light_on(FALSE) + appearance = bolt.appearance + // Our own turn, so our offsets are map pixels whatever way the bolt is turned. We take the bolt's alpha + appearance_flags |= RESET_TRANSFORM | RESET_COLOR | KEEP_APART + vis_flags = NONE + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + pixel_x = 0 + pixel_y = 0 + var/dx = bolt.movement_vector.pixel_x + var/dy = bolt.movement_vector.pixel_y + if(shot_north) + // The ball is behind the body: we show on the bolt itself once its tail is clear of the head + north_clear = barrel_turf.x * ICON_SIZE_X * dx + barrel_turf.y * ICON_SIZE_Y * dy + PLASMA_PINCH_NORTH_CLEAR + else + // Our nose shows just past the ball, on the bolt's path + var/distance = offset[1] * dx + offset[2] * dy + PLASMA_PINCH_BOLT_LEAD - PLASMA_PINCH_BOLT_NOSE + start_x = barrel_turf.x * ICON_SIZE_X + dx * distance + start_y = barrel_turf.y * ICON_SIZE_Y + dy * distance + alpha = 0 + // fire() moved the bolt its first pixel over a tick. We start our queue with that move, in the same message + var/bolt_x = bolt.loc.x * ICON_SIZE_X + bolt.pixel_x + var/bolt_y = bolt.loc.y * ICON_SIZE_Y + bolt.pixel_y + if(!isnull(start_x)) + pixel_w = start_x - (bolt_x - dx) + pixel_z = start_y - (bolt_y - dy) + animate(src, pixel_w = start_x - bolt_x, pixel_z = start_y - bolt_y, time = world.tick_lag, flags = ANIMATION_PARALLEL) + else + animate(src, time = world.tick_lag, flags = ANIMATION_PARALLEL) + queued_to = world.tick_lag + // The bolt's light mask, shown at the pinch light's size and brightness when the ball snaps, then grown to the + // bolt's. The holder's alpha and size carry over to the mask + var/datum/component/overlay_lighting/lighting = bolt.GetComponent(/datum/component/overlay_lighting) + if(lighting?.visible_mask) + var/mutable_appearance/mask = new(lighting.visible_mask) + mask.appearance_flags = RESET_COLOR + glow_holder = new(null) + glow_holder.appearance_flags = RESET_ALPHA | RESET_TRANSFORM | RESET_COLOR | KEEP_APART + glow_holder.underlays += mask + glow_holder.alpha = 0 + var/mask_size = (lighting.range - 1) * 64 + 32 + glow_holder.transform = matrix() * (ICON_SIZE_X / mask_size) + animate(glow_holder, time = PLASMA_PINCH_SNAP_TIME) + animate(alpha = min(255, 255 * PLASMA_PINCH_LIGHT_ALPHA / max(lighting.set_alpha, 1)), time = 0) + animate(alpha = 255, transform = matrix(), time = PLASMA_PINCH_LIGHT_RAMP) + vis_contents += glow_holder + bolt_icon = bolt.icon + bolt.icon = null + bolt.vis_contents += src + +/obj/effect/abstract/plasma_pinch_bolt/Destroy() + if(bolt) + bolt.vis_contents -= src + if(!handed_over) + bolt.icon = bolt_icon + if(bolt.pinch_copy == src) + bolt.pinch_copy = null + bolt = null + if(glow_holder) + vis_contents -= glow_holder + QDEL_NULL(glow_holder) + return ..() + +/// Our offset from the bolt `at` after the shot, while the bolt's centre is at map pixel (bolt_x, bolt_y) +/obj/effect/abstract/plasma_pinch_bolt/proc/offset_at(at, bolt_x, bolt_y) + if(isnull(start_x)) + return list(0, 0) + if(isnull(show_x) || at <= show_at) + return list(start_x - bolt_x, start_y - bolt_y) + var/left = catch_up_time > 0 ? max(0, 1 - (at - show_at) / catch_up_time) : 0 + return list(show_x * left, show_y * left) + +/// The bolt is about to move from (from_x, from_y) to (to_x, to_y) on its turf, once its queued moves end. We queue +/// our offset over the same time, split where we show and where we land on the bolt +/obj/effect/abstract/plasma_pinch_bolt/proc/follow(from_x, from_y, to_x, to_y, time_given, deleting) + if(handed_over || QDELETED(bolt) || !isturf(bolt.loc)) + return + var/now = world.time - fired_at + // The bolt's moves in a tick take one tick between them, each by its share of the tick's pixels. We can't see + // that total, but at a whole number of pixels per tick it is what the time since the last move gives + if(world.time != last_move_at) + tick_pixels = min((world.time - last_move_at) * SSprojectiles.pixels_per_decisecond * bolt.speed, SSprojectiles.max_pixels_per_tick) + last_move_at = world.time + var/turf/bolt_turf = bolt.loc + var/x0 = bolt_turf.x * ICON_SIZE_X + from_x + var/y0 = bolt_turf.y * ICON_SIZE_Y + from_y + var/x1 = bolt_turf.x * ICON_SIZE_X + to_x + var/y1 = bolt_turf.y * ICON_SIZE_Y + to_y + var/time = deleting ? time_given : world.tick_lag * sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2) / max(tick_pixels, 1) + var/move_start = max(now, queued_to) + var/move_end = move_start + time + // We have lain on the bolt for two ticks of its moves: it shows from here, even if the client runs a little behind + if(shown && !deleting && move_start >= show_at + catch_up_time + 2 * world.tick_lag) + handover() + return + queued_to = move_end + var/dx = bolt.movement_vector.pixel_x + var/dy = bolt.movement_vector.pixel_y + // Shot north we show when the bolt's centre passes the head + if(!shown && !isnull(north_clear)) + var/along0 = x0 * dx + y0 * dy + var/along1 = x1 * dx + y1 * dy + show_at = INFINITY + if(along1 >= north_clear) + show_at = along1 > along0 ? move_start + time * clamp((north_clear - along0) / (along1 - along0), 0, 1) : move_start + // The snap falls in this move: our offset from the bolt then sets how long we take to close it + if(isnull(show_x) && move_end >= show_at) + var/progress = time > 0 ? clamp((show_at - move_start) / time, 0, 1) : 1 + var/list/show_offset = offset_at(show_at, x0 + (x1 - x0) * progress, y0 + (y1 - y0) * progress) + show_x = show_offset[1] + show_y = show_offset[2] + catch_up_time = sqrt(show_x ** 2 + show_y ** 2) / PLASMA_PINCH_CATCH_UP + var/list/stops = list() + for(var/stop in list(show_at, show_at + catch_up_time)) + if(stop > move_start && stop < move_end) + stops += stop + stops += move_end + var/last = move_start + for(var/stop in stops) + var/progress = time > 0 ? (stop - move_start) / time : 1 + var/list/offset = offset_at(stop, x0 + (x1 - x0) * progress, y0 + (y1 - y0) * progress) + animate(src, pixel_w = offset[1], pixel_z = offset[2], time = max(stop - last, 0), flags = ANIMATION_PARALLEL | ANIMATION_CONTINUE) + if(!shown && stop >= show_at) + animate(alpha = 255, time = 0, flags = ANIMATION_CONTINUE) + shown = TRUE + last = stop + +/// The real bolt shows where we lie on it. We hide and carry its light until the light has grown to the bolt's own +/obj/effect/abstract/plasma_pinch_bolt/proc/handover() + if(handed_over || QDELETED(bolt)) + return + handed_over = TRUE + bolt.icon = bolt_icon + animate(src, alpha = 0, time = 0) + var/light_left = fired_at + PLASMA_PINCH_SNAP_TIME + PLASMA_PINCH_LIGHT_RAMP - world.time + if(!glow_holder || light_left <= 0) + light_handover() + return + addtimer(CALLBACK(src, PROC_REF(light_handover)), light_left) + +/// The bolt's own light takes over from ours at the same size and brightness, and we go +/obj/effect/abstract/plasma_pinch_bolt/proc/light_handover() + if(QDELETED(src)) + return + if(!QDELETED(bolt)) + bolt.set_light_on(TRUE) + qdel(src) + +#undef PLASMA_PINCH_SNAP_TIME +#undef PLASMA_PINCH_RING_TIME +#undef PLASMA_PINCH_BOLT_LAYER +#undef PLASMA_PINCH_BOLT_NOSE +#undef PLASMA_PINCH_BOLT_LEAD +#undef PLASMA_PINCH_CATCH_UP +#undef PLASMA_PINCH_NORTH_CLEAR +#undef PLASMA_PINCH_LIGHT_ALPHA +#undef PLASMA_PINCH_LIGHT_RAMP diff --git a/voidcrew/modules/plasma_weapons/plasma_pirates.dm b/voidcrew/modules/plasma_weapons/plasma_pirates.dm new file mode 100644 index 00000000000..74b27f8025e --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_pirates.dm @@ -0,0 +1,74 @@ +// Package: P6 NPCs. The plasma pistol and plasma rifle pirate loadouts. +// +// Yellow- and red-zone pirate gunners can roll a Cinder, red-zone ones a Furnace, and the red-zone +// Director Prime and Chief Auditor carry a Furnace. Their bolts are the real plasma bolts. Like every +// pirate gun these are hand sprites: nothing drops. Pirates are basic mobs firing through +// /datum/component/pirate_gun, so they don't run the rifle's heat model. + +/datum/pirate_gun_loadout/plasma_pistol + energy_shots = 15 + burst = 2 + burst_delay = 0.5 SECONDS + cooldown = 6 SECONDS + reload_time = 3 SECONDS + reload_sound = 'voidcrew/modules/plasma_weapons/sound/flask_load.ogg' + reload_lines = list("Changing flasks!", "Flask's dry!", "Cover me!") + +/datum/pirate_gun_loadout/plasma_rifle + energy_shots = 12 + burst = 1 + cooldown = 4 SECONDS + reload_time = 4 SECONDS + reload_sound = 'voidcrew/modules/plasma_weapons/sound/flask_load.ogg' + reload_lines = list("Venting!", "She's cooking!", "Changing flasks!") + +/// The overmap zone `gunner` was spawned for, or null if it isn't known yet +/proc/plasma_pirate_zone(mob/living/gunner) + var/datum/npc_spawn_context/context = gunner?.get_npc_spawn_context() + if(!context) + return null + return context.zone_type || SSovermap_zones?.get_zone_type_anywhere(context.source_turf) + +/// `weights` with the plasma loadouts added for a gunner from a yellow or red zone. Never changes `weights`. +/proc/plasma_pirate_gun_weights(mob/living/gunner, list/weights) + var/zone = plasma_pirate_zone(gunner) + if(zone != ZONE_YELLOW && zone != ZONE_RED) + return weights + var/list/plasma_weights = weights.Copy() + plasma_weights[/datum/pirate_gun_loadout/plasma_pistol] = 2 + if(zone == ZONE_RED) + plasma_weights[/datum/pirate_gun_loadout/plasma_rifle] = 1 + return plasma_weights + +/// Hands a red-zone boss a Furnace in place of its stock shot. Returns the loadout, or null. +/mob/living/basic/trooper/pirate/faction/boss/proc/arm_plasma_rifle() + if(plasma_pirate_zone(src) != ZONE_RED) + return null + return pirate_crew_arm(src, /datum/pirate_gun_loadout/plasma_rifle) + +// A boss's zone can resolve after Initialize; the weapon ladder runs again when it does. +/mob/living/basic/trooper/pirate/faction/boss/interdyne/apply_npc_weapon_ladder() + . = ..() + if(.) + arm_plasma_rifle() + +/mob/living/basic/trooper/pirate/faction/boss/irs/apply_npc_weapon_ladder() + . = ..() + if(.) + arm_plasma_rifle() + +// The Old Furnace is a red-zone prize: a boss met anywhere else loses the roll before its loot is set +/mob/living/basic/trooper/pirate/faction/boss/initialize_weapon_loot(zone) + if(!weapon_loot_initialized && zone != ZONE_RED && rolled_rare_loot) + rolled_rare_loot -= /obj/item/gun/energy/plasma_weapon/rifle/old_furnace + return ..() + +// A Furnace pirate vents its rifle as it changes flasks +/datum/component/pirate_gun/reload_effects() + . = ..() + if(!istype(loadout, /datum/pirate_gun_loadout/plasma_rifle)) + return + var/mob/living/owner = parent + if(QDELETED(owner) || owner.stat != CONSCIOUS) + return + plasma_vent_puff(get_turf(owner)) diff --git a/voidcrew/modules/plasma_weapons/plasma_shop.dm b/voidcrew/modules/plasma_weapons/plasma_shop.dm new file mode 100644 index 00000000000..c2de4027c3c --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_shop.dm @@ -0,0 +1,64 @@ +// Package: P5 supply. Shop SKU datums for the outfitter and black market. +// Names are set here because the guns' names carry \improper, which a shop window would print. + +// ===== Quartermain outfitter ===== + +/datum/shop_sku/outfitter/kestrel + zone_permission = ZONE_PERMISSION_YELLOW + category = "Firearms & Ammo" + name = "Kestrel laser pistol" + item_path = /obj/item/gun/energy/frontier_laser/pistol + price_credits = 900 + stock_min = 2 + stock_max = 2 + restock_interval = 1 HOURS + fixed_price = TRUE + contract_reward = FALSE + +/datum/shop_sku/outfitter/cinder + zone_permission = ZONE_PERMISSION_YELLOW + category = "Firearms & Ammo" + name = "Cinder plasma pistol" + item_path = /obj/item/gun/energy/plasma_weapon/pistol + price_credits = 2000 + stock_min = 1 + stock_max = 1 + restock_interval = 1 HOURS + fixed_price = TRUE + contract_reward = FALSE + +/datum/shop_sku/outfitter/plasma_flask + zone_permission = ZONE_PERMISSION_YELLOW + category = "Firearms & Ammo" + item_path = /obj/item/plasma_flask + price_credits = 150 + stock_min = 4 + stock_max = 4 + restock_interval = 1 HOURS + +// ===== Undertow Exchange black market ===== + +/datum/shop_sku/black_market/hornet + zone_permission = ZONE_PERMISSION_YELLOW + category = "Weapons" + name = "Hornet laser SMG" + item_path = /obj/item/gun/energy/frontier_laser/smg + price_credits = 3500 + stock_min = 1 + stock_max = 1 + restock_interval = 1 HOURS + fixed_price = TRUE + contract_reward = FALSE + +/datum/shop_sku/black_market/rotating/furnace + zone_permission = ZONE_PERMISSION_ADVANCED + category = "Weapons" + name = "Furnace plasma rifle" + item_path = /obj/item/gun/energy/plasma_weapon/rifle + price_vouchers = 1 + price_credits = 5000 + stock_min = 1 + stock_max = 1 + restock_interval = 1 HOURS + fixed_price = TRUE + contract_reward = FALSE diff --git a/voidcrew/modules/plasma_weapons/plasma_types.dm b/voidcrew/modules/plasma_weapons/plasma_types.dm new file mode 100644 index 00000000000..ef50c71ac26 --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_types.dm @@ -0,0 +1,172 @@ +// Package: P0 seams. Every plasma and frontier laser type, its identity (name, desc, icons), the type +// paths that tie guns, casings, bolts and the chamber together, and the vars more than one package +// shares. Vars set here are final: packages never set them again on the same type, since two +// assignments in different files fight over include order. + +/// Floor soot decals in the world, oldest first (written by P8, capped at PLASMA_SOOT_GLOBAL_CAP) +GLOBAL_LIST_EMPTY(plasma_soot_decals) + +// ===== Bolts and casings (S1) ===== + +/obj/projectile/energy/plasma_bolt + name = "plasma bolt" + icon = 'voidcrew/modules/plasma_weapons/icons/projectiles.dmi' + icon_state = "plasma_bolt" + /// An arm or leg this close to its max damage after the hit melts, if it was already hurt before it + var/ash_margin = PLASMA_ASH_MARGIN_PISTOL + /// Whether this bolt also melts an arm or leg that already carried a Plasma Burn-Through with slag still in it + var/ash_critical_burns = FALSE + /// Zone (any, for searing cuts), damage and slagged burn-through state of the limb this bolt hit, + /// read in on_hit before the damage lands + var/ash_zone + var/ash_prior_damage = 0 + var/ash_prior_critical = FALSE + /// The burst this bolt left on the person it hit; a melt there plays the burn-through + var/datum/weakref/burst_ref + /// Lazy list of mobs this bolt has already hissed past + var/list/hissed_mobs + +/obj/projectile/energy/plasma_bolt/heavy + icon_state = "plasma_bolt_heavy" + ash_margin = PLASMA_ASH_MARGIN_RIFLE + ash_critical_burns = TRUE + +/obj/item/ammo_casing/energy/plasma_bolt + projectile_type = /obj/projectile/energy/plasma_bolt + +/obj/item/ammo_casing/energy/plasma_bolt/heavy + projectile_type = /obj/projectile/energy/plasma_bolt/heavy + +/obj/item/ammo_casing/energy/frontier_laser + projectile_type = /obj/projectile/beam/laser/frontier_pistol + +/obj/item/ammo_casing/energy/lasergun/frontier_smg + projectile_type = /obj/projectile/beam/laser + +/obj/projectile/beam/laser/frontier_pistol + +// ===== Plasma guns (S2, S3, S6) ===== + +/obj/item/gun/energy/plasma_weapon + name = "plasma weapon" + icon = 'voidcrew/modules/plasma_weapons/icons/guns.dmi' + lefthand_file = 'voidcrew/modules/plasma_weapons/icons/guns_lefthand.dmi' + righthand_file = 'voidcrew/modules/plasma_weapons/icons/guns_righthand.dmi' + cell_type = /obj/item/stock_parts/power_store/cell/plasma_chamber + can_charge = FALSE + /// The seated flask, if any + var/obj/item/plasma_flask/flask + /// Barrel heat; only guns with uses_heat gain it. Named barrel_heat because /obj/item/var/heat is the ignition temperature. + var/barrel_heat = 0 + /// Heat one shot adds + var/heat_per_shot = 0 + /// Whether this gun runs the heat model (the rifles do, the pistol doesn't) + var/uses_heat = FALSE + /// world.time of the next allowed manual vent + var/next_vent_at = 0 + /// world.time until which a vent blocks firing + var/vent_lock_until = 0 + /// Coil overlay step shown, 0 to 3 + var/heat_step = 0 + /// Sound distant listeners hear when this fires + var/far_fire_sound + +/obj/item/gun/energy/plasma_weapon/pistol + name = "\improper Cinder plasma pistol" + desc = "A plasma pistol with a glass flask seated behind the grip. The barrel smells of hot metal." + icon_state = "cinder" + inhand_icon_state = "cinder" + ammo_type = list(/obj/item/ammo_casing/energy/plasma_bolt) + +/obj/item/gun/energy/plasma_weapon/rifle + name = "\improper Furnace plasma rifle" + desc = "A heavy plasma rifle. Its coils tick as they cool." + icon_state = "furnace" + inhand_icon_state = "furnace_wielded" + worn_icon = 'voidcrew/modules/plasma_weapons/icons/guns_back.dmi' + worn_icon_state = "furnace" + ammo_type = list(/obj/item/ammo_casing/energy/plasma_bolt/heavy) + uses_heat = TRUE + heat_per_shot = PLASMA_HEAT_PER_SHOT + +/obj/item/gun/energy/plasma_weapon/rifle/old_furnace + name = "\improper Old Furnace" + desc = "A Furnace rifle with a hand-wound coil and a pirate captain's tally burned into the stock." + icon_state = "old_furnace" + heat_per_shot = PLASMA_HEAT_PER_SHOT_UNIQUE + +// ===== Frontier lasers (S2, S7) ===== + +/obj/item/gun/energy/frontier_laser + name = "frontier laser" + icon = 'voidcrew/modules/plasma_weapons/icons/guns.dmi' + lefthand_file = 'voidcrew/modules/plasma_weapons/icons/guns_lefthand.dmi' + righthand_file = 'voidcrew/modules/plasma_weapons/icons/guns_righthand.dmi' + /// Sound distant listeners hear when this fires + var/far_fire_sound + +/obj/item/gun/energy/frontier_laser/pistol + name = "\improper Kestrel laser pistol" + desc = "A light laser pistol with a scuffed white grip." + icon_state = "kestrel" + inhand_icon_state = "kestrel" + ammo_type = list(/obj/item/ammo_casing/energy/frontier_laser) + +/obj/item/gun/energy/frontier_laser/smg + name = "\improper Hornet laser SMG" + desc = "A stubby laser gun built to empty its cell in a hurry." + icon_state = "hornet" + inhand_icon_state = "hornet_wielded" + worn_icon = 'voidcrew/modules/plasma_weapons/icons/guns_back.dmi' + worn_icon_state = "hornet" + ammo_type = list(/obj/item/ammo_casing/energy/lasergun/frontier_smg) + +// ===== Flask and chamber (S3) ===== + +/obj/item/plasma_flask + name = "plasma flask" + desc = "A thick glass flask with a brass valve." + icon = 'voidcrew/modules/plasma_weapons/icons/flask.dmi' + icon_state = "flask_0" + /// Mol of plasma held + var/stored = 0 + +/obj/item/stock_parts/power_store/cell/plasma_chamber + name = "plasma chamber" + +// ===== Effects (S8) ===== + +/obj/effect/temp_visual/plasma_splash + icon = 'voidcrew/modules/plasma_weapons/icons/effects.dmi' + icon_state = "splash_wall" + +/obj/effect/temp_visual/plasma_wall_glow + icon = 'voidcrew/modules/plasma_weapons/icons/effects.dmi' + icon_state = "wall_glow" + +/obj/effect/decal/cleanable/plasma_soot + name = "soot" + icon = 'voidcrew/modules/plasma_weapons/icons/effects.dmi' + icon_state = "soot" + +/obj/effect/temp_visual/plasma_gutter + icon = 'voidcrew/modules/plasma_weapons/icons/effects.dmi' + icon_state = "gutter" + +/obj/effect/temp_visual/plasma_vent_puff + icon = 'voidcrew/modules/plasma_weapons/icons/effects.dmi' + icon_state = "vent_puff" + +/obj/effect/temp_visual/plasma_pinch + icon = 'voidcrew/modules/plasma_weapons/icons/effects.dmi' + icon_state = "pinch" + +// ===== Pirate loadouts (S12) ===== + +/datum/pirate_gun_loadout/plasma_pistol + gun_type = /obj/item/gun/energy/plasma_weapon/pistol + energy_casing = /obj/item/ammo_casing/energy/plasma_bolt + +/datum/pirate_gun_loadout/plasma_rifle + gun_type = /obj/item/gun/energy/plasma_weapon/rifle + energy_casing = /obj/item/ammo_casing/energy/plasma_bolt/heavy diff --git a/voidcrew/modules/plasma_weapons/plasma_wound_care.dm b/voidcrew/modules/plasma_weapons/plasma_wound_care.dm new file mode 100644 index 00000000000..746af74d26a --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_wound_care.dm @@ -0,0 +1,181 @@ +// Plasma burn care: picking slag out in the field, the slag surgery and the slag bead. +// The wound itself (slag, heat, stored healing, remove_slag(), quench()) lives in plasma_wounds.dm. + +/// Tools that can pick slag out of a plasma burn in the field +#define PLASMA_PICK_TOOLS list(TOOL_HEMOSTAT, TOOL_WIRECUTTER, TOOL_SCALPEL) + +/// Picking tools are only claimed while there is slag to pick, so a cleaned burn leaves them alone. +/datum/wound/burn/flesh/plasma/item_can_treat(obj/item/potential_treater, mob/user) + if(slag > 0 && (potential_treater.tool_behaviour in PLASMA_PICK_TOOLS)) + return TRUE + return ..() + +/datum/wound/burn/flesh/plasma/treat(obj/item/I, mob/user) + if(slag > 0 && (I.tool_behaviour in PLASMA_PICK_TOOLS)) + return pick_slag(I, user) + return ..() + +/// Picks slag out of a plasma burn with a tool, one bead at a time, until it is all out or the medic stops. +/datum/wound/burn/flesh/plasma/proc/pick_slag(obj/item/tool, mob/user) + var/improvised = tool.tool_behaviour != TOOL_HEMOSTAT + var/delay = PLASMA_WOUND_PICK_TIME + if(user == victim) + delay *= 1.5 + if(improvised) + delay *= PLASMA_WOUND_IMPROVISED_TIME + if(HAS_TRAIT(src, TRAIT_WOUND_SCANNED)) + delay *= 0.5 + var/brute = improvised ? PLASMA_WOUND_PICK_BRUTE_IMPROVISED : PLASMA_WOUND_PICK_BRUTE + + var/mob/living/carbon/patient = victim + var/obj/item/bodypart/part = limb + var/whose = user == patient ? "[patient.p_their()]" : "[patient]'s" + var/your_whose = user == patient ? "your" : "[patient]'s" + user.visible_message( + span_notice("[user] begins picking slag out of [whose] [part.plaintext_zone] with [tool]..."), + span_notice("You begin picking slag out of [your_whose] [part.plaintext_zone] with [tool]..."), + ) + + while(slag > 0) + if(!do_after(user, delay, target = patient, extra_checks = CALLBACK(src, PROC_REF(still_exists)))) + break + // The wound may have changed under the tool: healed, upgraded away or emptied by someone else + if(QDELETED(src) || victim != patient || limb != part || slag <= 0) + break + var/was_hot = heat > 0 + remove_slag(PLASMA_WOUND_PICK_SLAG, user) + patient.apply_damage(brute, BRUTE, part, wound_bonus = CANT_WOUND, attacking_item = tool) + if(was_hot) + patient.emote("scream") + if(QDELETED(src) || slag <= 0) + user.visible_message( + span_notice("[user] pulls the last of the slag from [whose] [part.plaintext_zone]."), + span_notice("You pull the last of the slag from [your_whose] [part.plaintext_zone]."), + ) + break + user.visible_message( + span_notice("[user] pulls a bead of slag from [whose] [part.plaintext_zone]."), + span_notice("You pull a bead of slag from [your_whose] [part.plaintext_zone]."), + ) + return TRUE + +#undef PLASMA_PICK_TOOLS + +///// Excise plasma slag +/datum/surgery/excise_slag + name = "Excise plasma slag" + surgery_flags = SURGERY_REQUIRE_RESTING | SURGERY_REQUIRE_LIMB | SURGERY_REQUIRES_REAL_LIMB + targetable_wound = /datum/wound/burn/flesh/plasma + possible_locs = list( + BODY_ZONE_R_ARM, + BODY_ZONE_L_ARM, + BODY_ZONE_R_LEG, + BODY_ZONE_L_LEG, + BODY_ZONE_CHEST, + BODY_ZONE_HEAD, + ) + steps = list( + /datum/surgery_step/excise_slag, + /datum/surgery_step/dress, + ) + +/datum/surgery/excise_slag/can_start(mob/living/user, mob/living/carbon/target) + . = ..() + if(!.) + return . + var/obj/item/bodypart/part = target.get_bodypart(user.zone_selected) + var/datum/wound/burn/flesh/plasma/plasma_burn = part?.get_wound_type(targetable_wound) + return plasma_burn?.slag > 0 + +/datum/surgery_step/excise_slag + name = "excise slag (hemostat)" + implements = list( + TOOL_HEMOSTAT = 100, + TOOL_SCALPEL = 85, + TOOL_SAW = 60, + TOOL_WIRECUTTER = 40) + time = 2 SECONDS + repeatable = TRUE + preop_sound = 'sound/items/handling/surgery/scalpel1.ogg' + success_sound = 'sound/items/handling/surgery/retractor2.ogg' + failure_sound = 'sound/items/handling/surgery/organ1.ogg' + surgery_effects_mood = TRUE + +/// The wound this step works on, while it is still a plasma burn on the operated limb +/datum/surgery_step/excise_slag/proc/get_plasma_burn(datum/surgery/surgery) + var/datum/wound/burn/flesh/plasma/plasma_burn = surgery.operated_wound + if(!istype(plasma_burn) || QDELETED(plasma_burn) || plasma_burn.limb != surgery.operated_bodypart) + return null + return plasma_burn + +/datum/surgery_step/excise_slag/preop(mob/user, mob/living/carbon/target, target_zone, obj/item/tool, datum/surgery/surgery) + var/datum/wound/burn/flesh/plasma/plasma_burn = get_plasma_burn(surgery) + if(!plasma_burn) + user.visible_message(span_notice("[user] looks for [target]'s [target.parse_zone_with_bodypart(user.zone_selected)]."), span_notice("You look for [target]'s [target.parse_zone_with_bodypart(user.zone_selected)]...")) + return SURGERY_STEP_FAIL + if(plasma_burn.slag <= 0) + // Someone got it all out already: go straight on to the dressing + to_chat(user, span_notice("[target]'s [target.parse_zone_with_bodypart(user.zone_selected)] has no slag left to remove!")) + surgery.status++ + repeatable = FALSE + return SURGERY_STEP_FAIL + display_results( + user, + target, + span_notice("You begin to excise slag from [target]'s [target.parse_zone_with_bodypart(user.zone_selected)]..."), + span_notice("[user] begins to excise slag from [target]'s [target.parse_zone_with_bodypart(user.zone_selected)] with [tool]."), + span_notice("[user] begins to excise slag from [target]'s [target.parse_zone_with_bodypart(user.zone_selected)]."), + ) + display_pain(target, "The slag in your [target.parse_zone_with_bodypart(user.zone_selected)] stings like hell! It feels like you're being stabbed!") + +/datum/surgery_step/excise_slag/success(mob/living/user, mob/living/carbon/target, target_zone, obj/item/tool, datum/surgery/surgery, default_display_results = FALSE) + var/datum/wound/burn/flesh/plasma/plasma_burn = get_plasma_burn(surgery) + if(plasma_burn) + plasma_burn.remove_slag(PLASMA_WOUND_SURGERY_SLAG, user) + var/done = QDELETED(plasma_burn) || plasma_burn.slag <= 0 + var/how_much = done ? "the last of the" : "some of the" + display_results( + user, + target, + span_notice("You successfully excise [how_much] slag from [target]'s [target.parse_zone_with_bodypart(target_zone)]."), + span_notice("[user] successfully excises [how_much] slag from [target]'s [target.parse_zone_with_bodypart(target_zone)] with [tool]!"), + span_notice("[user] successfully excises [how_much] slag from [target]'s [target.parse_zone_with_bodypart(target_zone)]!"), + ) + log_combat(user, target, "excised plasma slag in", addition = "COMBAT MODE: [uppertext(user.combat_mode)]") + if(done) + repeatable = FALSE + else + to_chat(user, span_warning("[target] has no slag there!")) + repeatable = FALSE + return ..() + +/datum/surgery_step/excise_slag/failure(mob/user, mob/living/target, target_zone, obj/item/tool, datum/surgery/surgery, fail_prob = 0) + ..() + display_results( + user, + target, + span_notice("You carve away some of the healthy flesh from [target]'s [target.parse_zone_with_bodypart(target_zone)]."), + span_notice("[user] carves away some of the healthy flesh from [target]'s [target.parse_zone_with_bodypart(target_zone)] with [tool]!"), + span_notice("[user] carves away some of the healthy flesh from [target]'s [target.parse_zone_with_bodypart(target_zone)]!"), + ) + target.apply_damage(rand(4, 8), BRUTE, surgery.operated_bodypart, sharpness = SHARP_EDGED, attacking_item = tool) + +/datum/surgery_step/excise_slag/initiate(mob/user, mob/living/carbon/target, target_zone, obj/item/tool, datum/surgery/surgery, try_to_fail = FALSE) + if(!..()) + return + while(get_plasma_burn(surgery)?.slag > 0) + if(!..()) + break + +/// What comes out of a plasma burn. Worth nothing: no materials, no reagents, no export. +/obj/item/plasma_slag + name = "plasma slag" + desc = "A bead of black glass with a green heart. It came out of somebody." + icon = 'voidcrew/modules/plasma_weapons/icons/plasma_wounds.dmi' + icon_state = "slag_bead" + w_class = WEIGHT_CLASS_TINY + +/obj/item/plasma_slag/Initialize(mapload) + . = ..() + pixel_x = rand(-6, 6) + pixel_y = rand(-6, 6) diff --git a/voidcrew/modules/plasma_weapons/plasma_wound_looks.dm b/voidcrew/modules/plasma_weapons/plasma_wound_looks.dm new file mode 100644 index 00000000000..cbc10e92e8b --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_wound_looks.dm @@ -0,0 +1,215 @@ +// Plasma burn looks: the wound's own overlays on the victim. +// _set and _raw sit at tg's DAMAGE_LAYER through COMSIG_ATOM_UPDATE_OVERLAYS (as tg's fire overlay does), so +// clothing covers them like tg's burns. The hot glow is a pair of vis_contents objects over the clothes (one +// visible, one emissive) so it can pulse and cool with animate() instead of a per-tick redraw. No light is cast. + +#define PLASMA_WOUND_LOOK_ICON 'voidcrew/modules/plasma_weapons/icons/plasma_wounds.dmi' +/// The sprites are drawn for human-shaped limbs; monkeys and husks show none. +#define PLASMA_WOUND_LOOK_DRAWS(limb) (limb.dmg_overlay_type == "human") +/// One breath of the glow: 100% -> 62% -> 100% alpha. +#define PLASMA_WOUND_GLOW_PULSE (1.2 SECONDS) +#define PLASMA_WOUND_GLOW_PULSE_ALPHA 160 +/// How fast a quench cuts the glow. +#define PLASMA_WOUND_GLOW_QUENCH (0.3 SECONDS) +/// A colour matrix for the glow sprite: keeps `keep` of its own colour, adds its green channel tinted by +/// tint_r/g/b and adds a flat `add` of white. The fade and the breathing run on the alpha var, not in here. +#define PLASMA_WOUND_GLOW_MATRIX(keep, tint_r, tint_g, tint_b, add_r, add_g, add_b) list( keep, 0, 0, tint_r, keep + tint_g, tint_b, 0, 0, keep, add_r, add_g, add_b) + +/datum/wound/burn/flesh/plasma + /// The visible glow while the slag is hot. + var/obj/effect/abstract/plasma_wound_glow/look_glow + /// The emissive twin of the glow, so it shows in the dark. + var/obj/effect/abstract/plasma_wound_glow_emissive/look_emissive + +/// Called by the wound core on every state change. +/datum/wound/burn/flesh/plasma/proc/update_look() + if(QDELETED(src) || !victim || !limb || !PLASMA_WOUND_LOOK_DRAWS(limb)) + clear_look_glow() + else if(look_state() == "hot") + start_look_glow() + else + fade_look_glow() // a quench or the end of the cooling + victim?.update_appearance(UPDATE_OVERLAYS) + +/datum/wound/burn/flesh/plasma/set_victim(new_victim) + var/mob/living/carbon/old_victim = victim + if(old_victim) + UnregisterSignal(old_victim, list(COMSIG_ATOM_UPDATE_OVERLAYS, COMSIG_MOVABLE_Z_CHANGED)) + clear_look_glow() + . = ..() + if(old_victim && !QDELETED(old_victim)) + old_victim.update_appearance(UPDATE_OVERLAYS) + if(victim) + RegisterSignal(victim, COMSIG_ATOM_UPDATE_OVERLAYS, PROC_REF(add_look_overlays)) + RegisterSignal(victim, COMSIG_MOVABLE_Z_CHANGED, PROC_REF(look_z_changed)) + update_look() + +/datum/wound/burn/flesh/plasma/set_limb(obj/item/bodypart/new_value, replaced = FALSE) + . = ..() + if(victim) + update_look() + +/datum/wound/burn/flesh/plasma/Destroy() + clear_look_glow() + return ..() + +/// The tier name the sprites use. +/datum/wound/burn/flesh/plasma/proc/look_tier() + switch(severity) + if(WOUND_SEVERITY_CRITICAL) + return "through" + if(WOUND_SEVERITY_SEVERE) + return "slag" + return "spatter" + +/// Seconds a fresh wound of this tier stays hot. +/datum/wound/burn/flesh/plasma/proc/look_hot_time() + switch(severity) + if(WOUND_SEVERITY_CRITICAL) + return PLASMA_WOUND_HEAT_CRITICAL + if(WOUND_SEVERITY_SEVERE) + return PLASMA_WOUND_HEAT_SEVERE + return PLASMA_WOUND_HEAT_MODERATE + +/datum/wound/burn/flesh/plasma/proc/add_look_overlays(mob/living/carbon/source, list/overlays) + SIGNAL_HANDLER + if(!limb || limb.owner != source || !PLASMA_WOUND_LOOK_DRAWS(limb)) + return + var/kind = (look_state() == "raw") ? "raw" : "set" // hot draws the glass under its glow + overlays += mutable_appearance(PLASMA_WOUND_LOOK_ICON, "[limb.body_zone]_[look_tier()]_[kind]", -DAMAGE_LAYER) + +/datum/wound/burn/flesh/plasma/proc/look_z_changed(datum/source) + SIGNAL_HANDLER + if(look_emissive && victim) + SET_PLANE_EXPLICIT(look_emissive, EMISSIVE_PLANE, victim) + +/// Puts the glow on (or re-times it from the current heat) and starts its cooling. +/datum/wound/burn/flesh/plasma/proc/start_look_glow() + var/glow_state = "[limb.body_zone]_[look_tier()]_glow" + if(look_glow && look_glow.icon_state != glow_state) + clear_look_glow() + if(!look_glow) + look_glow = new(null, glow_state) + look_emissive = new(null, glow_state) + SET_PLANE_EXPLICIT(look_emissive, EMISSIVE_PLANE, victim) + victim.vis_contents += look_glow + victim.vis_contents += look_emissive + + var/hot_time = look_hot_time() + var/left = clamp(heat, 0, hot_time) + var/done = 1 - left / hot_time + + // The glow cools from white-hot through pale green and #74FFA0 to a deep, dim green, fading the whole way. + // Keys are fractions of the hot time, each a colour matrix over the green sprite and an alpha. + var/static/list/ramp_at = list(0, 0.3, 0.55, 0.85, 1) + var/static/list/ramp = list( + PLASMA_WOUND_GLOW_MATRIX(0.25, 0, 0, 0, 0.75, 0.75, 0.75), // white-hot + PLASMA_WOUND_GLOW_MATRIX(0.55, 0, 0, 0, 0.3, 0.42, 0.33), // pale green + PLASMA_WOUND_GLOW_MATRIX(0, 0.455, 1, 0.627, 0, 0, 0), // #74FFA0 + PLASMA_WOUND_GLOW_MATRIX(0, 0.12, 0.5, 0.25, 0, 0, 0), // deep green + PLASMA_WOUND_GLOW_MATRIX(0, 0.12, 0.5, 0.25, 0, 0, 0), + ) + var/static/list/ramp_alpha = list(255, 215, 165, 90, 0) + var/key = 1 + while(key < length(ramp_at) && ramp_at[key + 1] <= done) + key++ + var/list/now + var/now_alpha + if(key >= length(ramp_at)) + now = ramp[length(ramp)] + now_alpha = 0 + else + var/span = (done - ramp_at[key]) / (ramp_at[key + 1] - ramp_at[key]) + var/list/from_matrix = ramp[key] + var/list/to_matrix = ramp[key + 1] + now = list() + for(var/i in 1 to length(from_matrix)) + now += from_matrix[i] + (to_matrix[i] - from_matrix[i]) * span + now_alpha = round(ramp_alpha[key] + (ramp_alpha[key + 1] - ramp_alpha[key]) * span) + // A zero-time first step: a glow the client has not drawn yet would otherwise start from the end of the ramp + animate(look_glow, color = now, time = 0) + var/at = done + for(var/i in (key + 1) to length(ramp_at)) + animate(color = ramp[i], time = max((ramp_at[i] - at) * hot_time * 1 SECONDS, 0)) + at = ramp_at[i] + // Alpha fades with the ramp and breathes on the way down: each half breath is one step. + animate(look_glow, alpha = now_alpha, time = 0, flags = ANIMATION_PARALLEL) + var/left_ds = left * 1 SECONDS + var/elapsed = 0 + var/dim = TRUE + while(elapsed < left_ds) + var/step = min(PLASMA_WOUND_GLOW_PULSE / 2, left_ds - elapsed) + elapsed += step + var/fraction = done + (1 - done) * elapsed / left_ds + var/target = plasma_glow_alpha_at(fraction, ramp_at, ramp_alpha) + if(dim) + target = round(target * PLASMA_WOUND_GLOW_PULSE_ALPHA / 255) + animate(alpha = target, time = step, easing = SINE_EASING) + dim = !dim + + // The emissive dims steadily with the glow across the whole hot time. + animate(look_emissive, color = _EMISSIVE_COLOR(1 - done), time = 0) + animate(color = _EMISSIVE_COLOR(0), time = left * 1 SECONDS) + +/// The ramp's alpha at a fraction of the hot time. +/datum/wound/burn/flesh/plasma/proc/plasma_glow_alpha_at(fraction, list/ramp_at, list/ramp_alpha) + if(fraction >= 1) + return 0 + for(var/i in 1 to length(ramp_at) - 1) + if(fraction < ramp_at[i + 1]) + var/span = (fraction - ramp_at[i]) / (ramp_at[i + 1] - ramp_at[i]) + return round(ramp_alpha[i] + (ramp_alpha[i + 1] - ramp_alpha[i]) * span) + return 0 + +/// Cuts the glow quickly, as water hitting it would. +/datum/wound/burn/flesh/plasma/proc/fade_look_glow() + if(!look_glow) + return + var/obj/effect/abstract/plasma_wound_glow/glow = look_glow + var/obj/effect/abstract/plasma_wound_glow_emissive/emissive = look_emissive + look_glow = null + look_emissive = null + animate(glow, alpha = 0, time = PLASMA_WOUND_GLOW_QUENCH) + animate(emissive, color = _EMISSIVE_COLOR(0), time = PLASMA_WOUND_GLOW_QUENCH) + QDEL_IN(glow, PLASMA_WOUND_GLOW_QUENCH) // Destroy takes them out of the victim's vis_contents + QDEL_IN(emissive, PLASMA_WOUND_GLOW_QUENCH) + +/datum/wound/burn/flesh/plasma/proc/clear_look_glow() + if(victim) + victim.vis_contents -= look_glow + victim.vis_contents -= look_emissive + QDEL_NULL(look_glow) + QDEL_NULL(look_emissive) + +/// Hot slag showing over the clothes. Turns and lies down with its wearer. +/obj/effect/abstract/plasma_wound_glow + name = "glowing slag" + icon = PLASMA_WOUND_LOOK_ICON + layer = -HIGHEST_LAYER + appearance_flags = RESET_COLOR|PIXEL_SCALE|TILE_BOUND + vis_flags = VIS_INHERIT_DIR | VIS_INHERIT_PLANE | VIS_INHERIT_ID + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + +/obj/effect/abstract/plasma_wound_glow/Initialize(mapload, glow_state) + . = ..() + icon_state = glow_state + +/// The emissive mask of the glow, on its own plane so it lights up in the dark. +/obj/effect/abstract/plasma_wound_glow_emissive + name = "slag glow" + icon = PLASMA_WOUND_LOOK_ICON + layer = -HIGHEST_LAYER + appearance_flags = EMISSIVE_APPEARANCE_FLAGS|PIXEL_SCALE|TILE_BOUND + vis_flags = VIS_INHERIT_DIR | VIS_INHERIT_ID + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + +/obj/effect/abstract/plasma_wound_glow_emissive/Initialize(mapload, glow_state) + . = ..() + icon_state = glow_state + +#undef PLASMA_WOUND_LOOK_ICON +#undef PLASMA_WOUND_LOOK_DRAWS +#undef PLASMA_WOUND_GLOW_PULSE +#undef PLASMA_WOUND_GLOW_PULSE_ALPHA +#undef PLASMA_WOUND_GLOW_QUENCH +#undef PLASMA_WOUND_GLOW_MATRIX diff --git a/voidcrew/modules/plasma_weapons/plasma_wounds.dm b/voidcrew/modules/plasma_weapons/plasma_wounds.dm new file mode 100644 index 00000000000..4004124af4f --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_wounds.dm @@ -0,0 +1,490 @@ +// Plasma burns: a wound series for plasma bolts. +// +// A subtype of tg's flesh burn, so every burn treatment (ointment, mesh, synthflesh, gauze, the +// debride surgery, sterilizine, the UV penlight) works unchanged. On top of that the wound holds +// molten slag: while slag is in, the burn cannot close, cryo cannot clear it, and a critical limps +// or fumbles. Hot slag keeps burning the limb through tg's own apply_damage until it is cooled or +// cut out. Field picks and the surgery live in plasma_wound_care.dm, the overlay in +// plasma_wound_looks.dm. + +/// TRUE when a damage source should roll the plasma burn series instead of tg's burns. +/proc/plasma_burn_source(source) + return istype(source, /obj/projectile/energy/plasma_bolt) + +/// TRUE if the limb carries a critical burn of any kind. Burst C reads this. +/proc/plasma_limb_burned_through(obj/item/bodypart/limb) + if(!limb) + return FALSE + for(var/datum/wound/burn/flesh/burn in limb.wounds) + if(burn.severity >= WOUND_SEVERITY_CRITICAL) + return TRUE + return FALSE + +/// TRUE if the limb carries a Plasma Burn-Through that still holds slag: the rifle's burn-through melt. +/proc/plasma_burn_through_ready(obj/item/bodypart/limb) + var/datum/wound/burn/flesh/plasma/critical/burn_through = limb?.get_wound_type(/datum/wound/burn/flesh/plasma/critical) + return !!(burn_through && burn_through.slag > 0) + +/// The plasma burn typepath for a severity, or null. +/proc/plasma_burn_tier_path(severity) + switch(severity) + if(WOUND_SEVERITY_MODERATE) + return /datum/wound/burn/flesh/plasma/moderate + if(WOUND_SEVERITY_SEVERE) + return /datum/wound/burn/flesh/plasma/severe + if(WOUND_SEVERITY_CRITICAL) + return /datum/wound/burn/flesh/plasma/critical + +// --------------------------------------------------------------------------------------------- +// Pregen data. Static weight 0 keeps the series out of get_corresponding_wound_type() (fire, lava, +// smites, admin wound verbs); the real roll only happens through get_weight() with a plasma source. + +/datum/wound_pregen_data/flesh_burn/plasma + abstract = TRUE + weight = 0 + overpower_wounds_of_even_severity = TRUE + +/datum/wound_pregen_data/flesh_burn/plasma/get_weight(obj/item/bodypart/limb, woundtype, damage, attack_direction, damage_source) + return plasma_burn_source(damage_source) ? WOUND_DEFAULT_WEIGHT : 0 + +/datum/wound_pregen_data/flesh_burn/plasma/get_threshold_for(obj/item/bodypart/part, attack_direction, damage_source) + return plasma_burn_source(damage_source) ? threshold_minimum : INFINITY + +/datum/wound_pregen_data/flesh_burn/plasma/moderate + abstract = FALSE + wound_path_to_generate = /datum/wound/burn/flesh/plasma/moderate + threshold_minimum = 40 + +/datum/wound_pregen_data/flesh_burn/plasma/severe + abstract = FALSE + wound_path_to_generate = /datum/wound/burn/flesh/plasma/severe + threshold_minimum = 80 + +/datum/wound_pregen_data/flesh_burn/plasma/critical + abstract = FALSE + wound_path_to_generate = /datum/wound/burn/flesh/plasma/critical + threshold_minimum = 140 + +// --------------------------------------------------------------------------------------------- +// Status effects + +/datum/status_effect/wound/burn/flesh/plasma/moderate + id = "slagspatter" + +/datum/status_effect/wound/burn/flesh/plasma/severe + id = "slagembedded" + +/datum/status_effect/wound/burn/flesh/plasma/critical + id = "burnthrough" + +// --------------------------------------------------------------------------------------------- +// The wound + +/datum/wound/burn/flesh/plasma + name = "Plasma Burns" + undiagnosed_name = "Plasma Burns" + default_scar_file = PLASMA_SCAR_FILE + // The bolt's own hit and burn-through sounds carry the wound; no tg sizzle on top + sound_effect = null + // The slag tools (hemostat, wirecutters, scalpel) are gated in plasma_wound_care.dm's item_can_treat(). + + /// Slag still in the wound + var/slag = 0 + /// Seconds of heat left + var/heat = 0 + /// Healing held back while slag is in + var/stored_healing = 0 + + /// Burn a second while hot + var/cook_rate = 0 + /// Moderate flecks fall off by themselves once cool + var/sheds = FALSE + /// A slag bead drops when the last slag comes out + var/drops_bead = FALSE + /// Set when this wound replaced a slagged one because of a non-plasma burn: it keeps the old slag and heat instead of fresh ones + var/carried_over = FALSE + /// tg's burn of the same tier; its short treatment text is used once the slag is out + var/datum/wound/burn/flesh/tg_tier_path = /datum/wound/burn/flesh/moderate + + /// Examine text while the slag glows + var/examine_hot = "" + /// Examine text once the slag has set + var/examine_set = "" + /// Examine text with the slag out + var/examine_raw = "" + /// Scanner short treatment text while slag is in + var/slag_treat_text_short = "" + +/datum/wound/burn/flesh/plasma/moderate + name = "Slag Spatter Burns" + desc = "Patient has flecks of plasma slag stuck to scalded skin, weakening limb integrity." + treat_text = "Cool the area. The flecks will shed, or can be picked off. Apply ointment or regenerative mesh." + treat_text_short = "Cool, then apply ointment or regenerative mesh." + slag_treat_text_short = "Cool, then apply ointment or regenerative mesh." + examine_desc = "is spattered with glowing green flecks" + examine_hot = "is spattered with glowing green flecks" + examine_set = "is scalded and crusted with black flecks" + examine_raw = "is scalded and blistering" + occur_text = "spits and blisters as molten flecks stick to it" + severity = WOUND_SEVERITY_MODERATE + damage_multiplier_penalty = 1.1 + series_threshold_penalty = 30 + status_effect_type = /datum/status_effect/wound/burn/flesh/plasma/moderate + flesh_damage = 5 + scar_keyword = "plasmamoderate" + tg_tier_path = /datum/wound/burn/flesh/moderate + + slag = PLASMA_WOUND_SLAG_MODERATE + heat = PLASMA_WOUND_HEAT_MODERATE + sheds = TRUE + + simple_desc = "Patient's skin is scalded and spattered with plasma slag!" + simple_treat_text = "Water will cool it. Ointment or regenerative mesh will speed up recovery." + homemade_treat_text = "The flecks fall off by themselves once cool. Healthy tea will speed up recovery." + +/datum/wound/burn/flesh/plasma/severe + name = "Embedded Slag Burns" + desc = "Patient has plasma slag fused into deep burns. The burns will not close while it remains, and there is serious risk of infection." + treat_text = "Cool the slag. Excise it with a hemostat, in the field or in surgery. Then treat as third degree burns: regenerative mesh or Synthflesh, disinfect, and dress." + treat_text_short = "Cool the slag, excise it with a hemostat, then treat the burns." + slag_treat_text_short = "Cool the slag, excise it with a hemostat, then treat the burns." + examine_desc = "is studded with glowing slag, the flesh around it still cooking" + examine_hot = "is studded with glowing slag, the flesh around it still cooking" + examine_set = "is charred, with beads of black glass fused into the flesh" + examine_raw = "is charred and pitted with raw sockets" + occur_text = "sizzles as molten slag sinks into the flesh" + severity = WOUND_SEVERITY_SEVERE + damage_multiplier_penalty = 1.2 + series_threshold_penalty = 40 + status_effect_type = /datum/status_effect/wound/burn/flesh/plasma/severe + treatable_by = list(/obj/item/flashlight/pen/paramedic, /obj/item/stack/medical/ointment, /obj/item/stack/medical/mesh) + infestation_rate = 0.07 + flesh_damage = 12.5 + scar_keyword = "plasmasevere" + tg_tier_path = /datum/wound/burn/flesh/severe + + slag = PLASMA_WOUND_SLAG_SEVERE + heat = PLASMA_WOUND_HEAT_SEVERE + cook_rate = PLASMA_WOUND_COOK_SEVERE + drops_bead = TRUE + + simple_desc = "Patient has plasma slag burned into the flesh, and it won't heal until that comes out!!" + simple_treat_text = "Cool it with water, then have the slag picked out with a hemostat. After that, ointment or regenerative mesh." + homemade_treat_text = "A fire extinguisher cools it as well as anything. Wirecutters will do if there is no hemostat. It will hurt." + +/datum/wound/burn/flesh/plasma/critical + name = "Plasma Burn-Through" + desc = "Patient's limb is burned through, with plasma slag fused to muscle and bone. Plasma may have entered the blood. Negligible limb integrity." + treat_text = "Cool the slag immediately. Excise it with a hemostat, in the field or in surgery. Check the blood for plasma. Then treat as catastrophic burns." + treat_text_short = "Cool the slag, excise it with a hemostat, check for plasma in the blood, then treat the burns." + slag_treat_text_short = "Cool the slag, excise it with a hemostat, check for plasma in the blood, then treat the burns." + examine_desc = "is burned clean through, the hole ringed with glowing slag" + examine_hot = "is burned clean through, the hole ringed with glowing slag" + examine_set = "is burned clean through, the hole lined with black glass" + examine_raw = "has a charred hole burned clean through it" + occur_text = "burns straight through, slag fusing to the bone" + severity = WOUND_SEVERITY_CRITICAL + damage_multiplier_penalty = 1.3 + threshold_penalty = 25 + status_effect_type = /datum/status_effect/wound/burn/flesh/plasma/critical + treatable_by = list(/obj/item/flashlight/pen/paramedic, /obj/item/stack/medical/ointment, /obj/item/stack/medical/mesh) + infestation_rate = 0.075 + flesh_damage = 20 + scar_keyword = "plasmacritical" + tg_tier_path = /datum/wound/burn/flesh/critical + // Critical with slag in: a leg limps, an arm fumbles. Cleared in update_inefficiencies() once the slag is out. + limp_slowdown = PLASMA_WOUND_LIMP_SLOWDOWN + limp_chance = PLASMA_WOUND_LIMP_CHANCE + interaction_efficiency_penalty = PLASMA_WOUND_FUMBLE + + slag = PLASMA_WOUND_SLAG_CRITICAL + heat = PLASMA_WOUND_HEAT_CRITICAL + cook_rate = PLASMA_WOUND_COOK_CRITICAL + drops_bead = TRUE + + simple_desc = "Patient has a hole burned through the limb, full of plasma slag!!!" + simple_treat_text = "Cool it with water now. Get the slag cut out and get professional care before infection sets in." + homemade_treat_text = "A fire extinguisher or a shower will cool it. Wirecutters will do if there is no hemostat. Keep the patient out of fights." + +// --------------------------------------------------------------------------------------------- +// API + +/// Takes slag out, clamped at zero. Returns the amount removed. Only the call that empties the wound +/// clears it (one bead, healing released, limp cleared), so two picks landing at once can't double up. +/datum/wound/burn/flesh/plasma/proc/remove_slag(amount, mob/user) + if(slag <= 0 || amount <= 0) + return 0 + var/removed = min(amount, slag) + slag = max(round(slag - removed, 0.01), 0) + if(slag <= 0) + slag_cleared(user) + return removed + +/// Cools the wound at once. Returns TRUE if it was hot. +/datum/wound/burn/flesh/plasma/proc/quench(silent = FALSE) + if(heat <= 0) + return FALSE + heat = 0 + if(!silent && victim && limb) + victim.visible_message( + span_notice("The slag in [victim]'s [limb.plaintext_zone] hisses and goes dark."), + span_notice("The slag in your [limb.plaintext_zone] hisses and goes dark."), + vision_distance = COMBAT_MESSAGE_RANGE, + ) + playsound(victim, 'sound/effects/wounds/sizzle1.ogg', 25, TRUE, falloff_exponent = SOUND_FALLOFF_EXPONENT + 2, ignore_walls = FALSE) + var/turf/victim_turf = get_turf(victim) + if(victim_turf) + new /obj/effect/particle_effect/steam(victim_turf) + plasma_state_changed() + return TRUE + +/// Returns "hot", "set" or "raw". +/datum/wound/burn/flesh/plasma/proc/look_state() + if(slag <= 0) + return "raw" + if(heat > 0) + return "hot" + return "set" + +// --------------------------------------------------------------------------------------------- +// Internals + +/// Text and look follow the wound's state. +/datum/wound/burn/flesh/plasma/proc/plasma_state_changed() + if(QDELETED(src)) + return + update_descriptions() + update_look() + +/// The last slag is out: the burn can close, the limp ends, healing given early is released. +/datum/wound/burn/flesh/plasma/proc/slag_cleared(mob/user) + slag = 0 + heat = 0 + if(stored_healing > 0) + flesh_healing += stored_healing + stored_healing = 0 + if(limb) + update_inefficiencies() + if(drops_bead) + var/atom/drop_at = victim ? victim.drop_location() : limb?.drop_location() + if(drop_at) + new /obj/item/plasma_slag(drop_at) + plasma_state_changed() + +/// Moves healing the burn can't use yet into storage, capped at the flesh damage. +/datum/wound/burn/flesh/plasma/proc/bank_healing() + if(flesh_healing <= 0) + return + stored_healing = min(stored_healing + flesh_healing, flesh_damage) + flesh_healing = 0 + +/// Tops the blood up to PLASMA_WOUND_BLOOD_PLASMA units of plasma; never adds past it. +/datum/wound/burn/flesh/plasma/proc/top_up_blood_plasma() + if(!victim?.reagents) + return + var/current = victim.reagents.get_reagent_amount(/datum/reagent/toxin/plasma) + if(current < PLASMA_WOUND_BLOOD_PLASMA) + victim.reagents.add_reagent(/datum/reagent/toxin/plasma, PLASMA_WOUND_BLOOD_PLASMA - current) + +/// How fast the slag cools where the victim is: 0 in a fire, faster in the cold. +/datum/wound/burn/flesh/plasma/proc/cooling_factor() + if(victim.on_fire || victim.bodytemperature > BODYTEMP_HEAT_DAMAGE_LIMIT) + return 0 + if(victim.bodytemperature < BODYTEMP_COLD_DAMAGE_LIMIT) + return PLASMA_WOUND_COLD_BODY_FACTOR + var/datum/gas_mixture/air = victim.loc?.return_air() + if(air && air.return_temperature() < T0C && air.return_pressure() >= PLASMA_WOUND_COLD_AIR_PRESSURE) + return PLASMA_WOUND_COLD_AIR_FACTOR + return 1 + +/// Wet skin quenches; otherwise hot slag cooks the limb (if cook is TRUE) and cools a little. +/datum/wound/burn/flesh/plasma/proc/process_heat(seconds_per_tick, cook = TRUE) + if(heat <= 0 || slag <= 0) + return + if(victim.has_status_effect(/datum/status_effect/fire_handler/wet_stacks)) + quench() + return + if(cook && cook_rate > 0) + // CANT_WOUND: cooking never rolls or upgrades a wound, and attacking_item is null so the bolt's melt check ignores it. + victim.apply_damage(cook_rate * seconds_per_tick, BURN, limb, wound_bonus = CANT_WOUND, wound_clothing = FALSE) + if(QDELETED(src) || !victim || !limb) + return + if(prob(8)) + to_chat(victim, span_danger("The slag in your [limb.plaintext_zone] is still cooking!")) + heat = max(heat - (seconds_per_tick * cooling_factor()), 0) + if(heat <= 0) + to_chat(victim, span_notice("The slag in your [limb.plaintext_zone] stops glowing.")) + plasma_state_changed() + +/// Moderate flecks fall off once cool. +/datum/wound/burn/flesh/plasma/proc/process_shedding(seconds_per_tick) + if(!sheds || heat > 0 || slag <= 0) + return + remove_slag(PLASMA_WOUND_SHED_RATE * seconds_per_tick) + if(slag <= 0 && victim && limb) + to_chat(victim, span_notice("The flecks on your [limb.plaintext_zone] flake away.")) + +// --------------------------------------------------------------------------------------------- +// tg hooks + +/datum/wound/burn/flesh/plasma/wound_injury(datum/wound/old_wound = null, attack_direction = null) + . = ..() + var/datum/wound/burn/flesh/plasma/old_plasma = istype(old_wound, /datum/wound/burn/flesh/plasma) ? old_wound : null + if(carried_over && old_plasma) + // A fire or laser burn replaced slagged plasma: keep what was there, add nothing. + slag = old_plasma.slag + heat = old_plasma.heat + else + heat = initial(heat) + if(old_plasma) + slag = max(slag, old_plasma.slag) + if(old_plasma) + infestation = old_plasma.infestation + sanitization = old_plasma.sanitization + if(!carried_over && severity >= WOUND_SEVERITY_CRITICAL && (!old_wound || old_wound.severity < severity)) + top_up_blood_plasma() + if(limb) + update_inefficiencies() + plasma_state_changed() + +/datum/wound/burn/flesh/plasma/replace_wound(datum/wound/new_wound, smited = FALSE, attack_direction = null) + // A bigger tg burn over slag (a slagged limb held in a fire) becomes the plasma burn of that tier, so slag is never burned away. + if(slag > 0 && new_wound && (new_wound.type in list(/datum/wound/burn/flesh/moderate, /datum/wound/burn/flesh/severe, /datum/wound/burn/flesh/critical))) + var/plasma_path = plasma_burn_tier_path(new_wound.severity) + if(plasma_path) + var/datum/wound/burn/flesh/plasma/swapped = new plasma_path + swapped.carried_over = TRUE + qdel(new_wound) + new_wound = swapped + return ..(new_wound, smited, attack_direction) + +// tg's bodypart receive_damage() passes the damage source in this proc's attack_direction slot +// (_bodyparts.dm: iter_wound.receive_damage(wounding_type, wounding_dmg, wound_bonus, damage_source)), +// so both slots are read. A fresh plasma bolt re-melts slag already in the limb. +/datum/wound/burn/flesh/plasma/receive_damage(wounding_type, wounding_dmg, wound_bonus, attack_direction, damage_source) + . = ..() + if(slag <= 0) + return + if(!plasma_burn_source(attack_direction) && !plasma_burn_source(damage_source)) + return + heat = initial(heat) + // Re-times the glow from full heat, or lights it again if it had cooled + plasma_state_changed() + +/datum/wound/burn/flesh/plasma/handle_process(seconds_per_tick, times_fired) + if(!victim || HAS_TRAIT(victim, TRAIT_STASIS)) + return ..() + var/held_damage = flesh_damage + var/held_infestation = infestation + // Bank healing before tg's tick so its heal step has nothing to spend while slag is in. + if(slag > 0) + bank_healing() + . = ..() + if(QDELETED(src) || !victim || !limb) + return + // The burn can't close while slag is in: undo any healing tg's tick did, keep what reagents added for later. + if(slag > 0) + flesh_damage = max(flesh_damage, held_damage) + bank_healing() + // Hot slag is sterile. + if(heat > 0) + infestation = min(infestation, held_infestation) + process_heat(seconds_per_tick) + if(QDELETED(src) || !victim || !limb) + return + process_shedding(seconds_per_tick) + +// On a stasis bed tg's tick doesn't run: the slag cools with no cooking, and stored healing stays stored. +/datum/wound/burn/flesh/plasma/on_stasis(seconds_per_tick, times_fired) + var/held_damage = flesh_damage + if(slag > 0) + bank_healing() + . = ..() + if(QDELETED(src) || !victim || !limb) + return + if(slag > 0) + flesh_damage = max(flesh_damage, held_damage) + bank_healing() + process_heat(seconds_per_tick, cook = FALSE) + process_shedding(seconds_per_tick) + +/datum/wound/burn/flesh/plasma/on_xadone(power) + // Cryoxadone works on a frozen body; pyroxadone on a hot one doesn't cool slag. + if(victim && victim.bodytemperature < T0C) + quench() + return ..() + +/datum/wound/burn/flesh/plasma/handle_xadone_progress() + if(slag > 0) + cryo_progress = min(cryo_progress || 0, get_xadone_progress_to_qdel()) + return + return ..() + +/datum/wound/burn/flesh/plasma/can_be_ointmented_or_meshed() + if(slag <= 0) + return ..() + if(infestation > 0 && sanitization < infestation) + return TRUE + return flesh_damage > 0 && (flesh_healing + stored_healing) < flesh_damage + +/datum/wound/burn/flesh/plasma/update_inefficiencies(replaced_or_replacing = FALSE) + . = ..() + if(!limb) + return + if(slag > 0 && (limb.body_zone in GLOB.arm_zones)) + return + // Fumble only applies to an arm with slag in. + if(interaction_efficiency_penalty != 1) + set_interaction_efficiency_penalty(1) + if(slag > 0) + return + if(!limp_slowdown && !limp_chance) + return + limp_slowdown = 0 + limp_chance = 0 + // The limp effect only re-reads wounds on gain and loss signals. + var/datum/status_effect/limp/limp = victim?.has_status_effect(/datum/status_effect/limp) + limp?.update_limp(victim) + +/datum/wound/burn/flesh/plasma/set_disabling(new_value) + . = ..() + update_descriptions() + +/datum/wound/burn/flesh/plasma/update_descriptions() + switch(look_state()) + if("hot") + examine_desc = examine_hot + if("set") + examine_desc = examine_set + else + examine_desc = examine_raw + if(disabling && strikes_to_lose_limb <= 0) + return // tg's sepsis text stands + if(slag > 0) + treat_text_short = slag_treat_text_short + else + var/datum/wound/burn/flesh/tg_tier = tg_tier_path + treat_text_short = initial(tg_tier.treat_text_short) + +/datum/wound/burn/flesh/plasma/get_scanner_description(mob/user) + . = ..() + if(slag <= 0) + return + var/foreign = "Foreign matter: plasma slag, [heat > 0 ? "molten" : "set"].\n" + var/div_open = "
" + var/div_at = findtext(., div_open) + if(div_at) + var/insert_at = div_at + length(div_open) + . = copytext(., 1, insert_at) + foreign + copytext(., insert_at) + else + . += "[div_open][foreign]
" + +/datum/wound/burn/flesh/plasma/get_limb_examine_description() + if(severity >= WOUND_SEVERITY_CRITICAL) + return span_warning("The flesh on this limb is burned through and crusted with black glass.") + return span_warning("The flesh on this limb is charred and studded with black glass.") + +/datum/wound/burn/flesh/plasma/get_scar_file(obj/item/bodypart/scarred_limb, add_to_scars) + if(scarred_limb.biological_state & BIO_FLESH) + return PLASMA_SCAR_FILE + return ..() diff --git a/voidcrew/modules/plasma_weapons/sound/attributions.txt b/voidcrew/modules/plasma_weapons/sound/attributions.txt new file mode 100644 index 00000000000..a4f87ea45b8 --- /dev/null +++ b/voidcrew/modules/plasma_weapons/sound/attributions.txt @@ -0,0 +1,21 @@ +Mojave Sun (https://github.com/Mojave-Sun/mojave-sun-13), commit 1302c0c1f01. Its README puts icons under the mojave directory under CC BY-NC-SA 3.0 and does not name sounds; these sounds are used under the same CC BY-NC-SA 3.0 terms. +Files are renamed copies of the originals listed below, from mojave/sound/ms13weapons/. + +kestrel_fire.ogg - gunsounds/laspistol/las_pistol_1.ogg +hornet_fire.ogg - gunsounds/lasrcw/rcw_5.ogg +cinder_fire.ogg - gunsounds/plaspistol/plasma_3.ogg +furnace_fire.ogg - gunsounds/plasrifle/plasma_3.ogg +flask_load.ogg - gunsounds/lasrifle/energy_reload.ogg +flask_unload.ogg - gunsounds/lasrifle/energy_unload.ogg +kestrel_far.ogg - distant_shots/laser_pistol.ogg +hornet_far.ogg - distant_shots/rcw.ogg +cinder_far.ogg - distant_shots/plasma_pistol.ogg +furnace_far.ogg - distant_shots/plasma_3.ogg + +New sounds, original to this codebase: + +plasma_hit_pistol.ogg +plasma_hit_rifle.ogg +plasma_burnthrough_1.ogg +plasma_burnthrough_2.ogg +plasma_melt.ogg diff --git a/voidcrew/modules/plasma_weapons/sound/cinder_far.ogg b/voidcrew/modules/plasma_weapons/sound/cinder_far.ogg new file mode 100644 index 00000000000..52d189f9ed4 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/sound/cinder_far.ogg differ diff --git a/voidcrew/modules/plasma_weapons/sound/cinder_fire.ogg b/voidcrew/modules/plasma_weapons/sound/cinder_fire.ogg new file mode 100644 index 00000000000..567213092a4 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/sound/cinder_fire.ogg differ diff --git a/voidcrew/modules/plasma_weapons/sound/flask_load.ogg b/voidcrew/modules/plasma_weapons/sound/flask_load.ogg new file mode 100644 index 00000000000..de75b37f83a Binary files /dev/null and b/voidcrew/modules/plasma_weapons/sound/flask_load.ogg differ diff --git a/voidcrew/modules/plasma_weapons/sound/flask_unload.ogg b/voidcrew/modules/plasma_weapons/sound/flask_unload.ogg new file mode 100644 index 00000000000..d359d273349 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/sound/flask_unload.ogg differ diff --git a/voidcrew/modules/plasma_weapons/sound/furnace_far.ogg b/voidcrew/modules/plasma_weapons/sound/furnace_far.ogg new file mode 100644 index 00000000000..1982b4237ba Binary files /dev/null and b/voidcrew/modules/plasma_weapons/sound/furnace_far.ogg differ diff --git a/voidcrew/modules/plasma_weapons/sound/furnace_fire.ogg b/voidcrew/modules/plasma_weapons/sound/furnace_fire.ogg new file mode 100644 index 00000000000..04f61fc3547 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/sound/furnace_fire.ogg differ diff --git a/voidcrew/modules/plasma_weapons/sound/hornet_far.ogg b/voidcrew/modules/plasma_weapons/sound/hornet_far.ogg new file mode 100644 index 00000000000..e109edc9cd3 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/sound/hornet_far.ogg differ diff --git a/voidcrew/modules/plasma_weapons/sound/hornet_fire.ogg b/voidcrew/modules/plasma_weapons/sound/hornet_fire.ogg new file mode 100644 index 00000000000..6a03ed7b60d Binary files /dev/null and b/voidcrew/modules/plasma_weapons/sound/hornet_fire.ogg differ diff --git a/voidcrew/modules/plasma_weapons/sound/kestrel_far.ogg b/voidcrew/modules/plasma_weapons/sound/kestrel_far.ogg new file mode 100644 index 00000000000..ed7acbb8577 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/sound/kestrel_far.ogg differ diff --git a/voidcrew/modules/plasma_weapons/sound/kestrel_fire.ogg b/voidcrew/modules/plasma_weapons/sound/kestrel_fire.ogg new file mode 100644 index 00000000000..03bf87a879a Binary files /dev/null and b/voidcrew/modules/plasma_weapons/sound/kestrel_fire.ogg differ diff --git a/voidcrew/modules/plasma_weapons/sound/plasma_burnthrough_1.ogg b/voidcrew/modules/plasma_weapons/sound/plasma_burnthrough_1.ogg new file mode 100644 index 00000000000..4a3217b0b90 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/sound/plasma_burnthrough_1.ogg differ diff --git a/voidcrew/modules/plasma_weapons/sound/plasma_burnthrough_2.ogg b/voidcrew/modules/plasma_weapons/sound/plasma_burnthrough_2.ogg new file mode 100644 index 00000000000..b78a0558583 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/sound/plasma_burnthrough_2.ogg differ diff --git a/voidcrew/modules/plasma_weapons/sound/plasma_hit_pistol.ogg b/voidcrew/modules/plasma_weapons/sound/plasma_hit_pistol.ogg new file mode 100644 index 00000000000..42f444c1edf Binary files /dev/null and b/voidcrew/modules/plasma_weapons/sound/plasma_hit_pistol.ogg differ diff --git a/voidcrew/modules/plasma_weapons/sound/plasma_hit_rifle.ogg b/voidcrew/modules/plasma_weapons/sound/plasma_hit_rifle.ogg new file mode 100644 index 00000000000..2bd6266f90b Binary files /dev/null and b/voidcrew/modules/plasma_weapons/sound/plasma_hit_rifle.ogg differ diff --git a/voidcrew/modules/plasma_weapons/sound/plasma_melt.ogg b/voidcrew/modules/plasma_weapons/sound/plasma_melt.ogg new file mode 100644 index 00000000000..a10f010f1a6 Binary files /dev/null and b/voidcrew/modules/plasma_weapons/sound/plasma_melt.ogg differ diff --git a/voidcrew/modules/trade/shop_catalog_black_market.dm b/voidcrew/modules/trade/shop_catalog_black_market.dm index 00c4ff36e3f..1c2cdcafe4c 100644 --- a/voidcrew/modules/trade/shop_catalog_black_market.dm +++ b/voidcrew/modules/trade/shop_catalog_black_market.dm @@ -63,6 +63,7 @@ /datum/shop_sku/black_market/cutlass, /datum/shop_sku/black_market/c20r, /datum/shop_sku/black_market/wt550, + /datum/shop_sku/black_market/hornet, /datum/shop_sku/black_market/rotating/mateba, /datum/shop_sku/black_market/pistol_mag, /datum/shop_sku/black_market/speedloader, @@ -142,6 +143,7 @@ /datum/shop_sku/black_market/rotating/mod_chameleon, /datum/shop_sku/black_market/rotating/ew_runaway_burn, /datum/shop_sku/black_market/rotating/ew_helm_poltergeist, + /datum/shop_sku/black_market/rotating/furnace, ) rare_pool = list( /datum/shop_sku/black_market/rare/energy_sword, diff --git a/voidcrew/modules/trade/shop_catalog_outfitter.dm b/voidcrew/modules/trade/shop_catalog_outfitter.dm index ebc5f8198b7..37fb32d9b3a 100644 --- a/voidcrew/modules/trade/shop_catalog_outfitter.dm +++ b/voidcrew/modules/trade/shop_catalog_outfitter.dm @@ -62,6 +62,9 @@ /datum/shop_sku/outfitter/disabler, /datum/shop_sku/outfitter/energy_gun, /datum/shop_sku/outfitter/laser_gun, + /datum/shop_sku/outfitter/kestrel, + /datum/shop_sku/outfitter/cinder, + /datum/shop_sku/outfitter/plasma_flask, /datum/shop_sku/outfitter/rubbershot, /datum/shop_sku/outfitter/lethalshot, /datum/shop_sku/outfitter/rifle_clip, diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 8c83b62d267..2d70a4416e5 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -88,11 +88,13 @@ #include "voidcrew_event_horizon.dm" #include "voidcrew_fleet_waypoints.dm" #include "voidcrew_fresh_cores.dm" +#include "voidcrew_frontier_lasers.dm" #include "voidcrew_gear_behaviour.dm" #include "voidcrew_gear_fixes.dm" #include "voidcrew_gene_injector_loot.dm" #include "voidcrew_helm_access.dm" #include "voidcrew_helpers.dm" +#include "voidcrew_hitscan_lasers.dm" #include "voidcrew_hull_containment.dm" #include "voidcrew_hull_survey.dm" #include "voidcrew_launch_access.dm" @@ -193,6 +195,14 @@ #include "voidcrew_planet_definitions.dm" #include "voidcrew_planet_shared_air.dm" #include "voidcrew_planetary_factions.dm" +#include "voidcrew_plasma_bolt.dm" +#include "voidcrew_plasma_effects.dm" +#include "voidcrew_plasma_flask.dm" +#include "voidcrew_plasma_heat.dm" +#include "voidcrew_plasma_melt.dm" +#include "voidcrew_plasma_pirates.dm" +#include "voidcrew_plasma_supply.dm" +#include "voidcrew_plasma_wounds.dm" #include "voidcrew_player_outpost.dm" #include "voidcrew_plumbing_shuttle_move.dm" #include "voidcrew_puzzle_containment.dm" diff --git a/voidcrew/modules/unit_tests/voidcrew_frontier_lasers.dm b/voidcrew/modules/unit_tests/voidcrew_frontier_lasers.dm new file mode 100644 index 00000000000..36382033bce --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_frontier_lasers.dm @@ -0,0 +1,48 @@ +// Package: P4 frontier lasers. Unit tests for voidcrew/modules/plasma_weapons/. + +/// The Kestrel and Hornet fire the right projectiles; the Hornet's is exactly the stock laser. +/datum/unit_test/voidcrew_frontier_laser_projectiles/Run() + var/obj/item/ammo_casing/energy/pistol_casing = /obj/item/ammo_casing/energy/frontier_laser + TEST_ASSERT_EQUAL(initial(pistol_casing.projectile_type), /obj/projectile/beam/laser/frontier_pistol, "The Kestrel casing fires the wrong projectile") + var/obj/projectile/pistol_beam = /obj/projectile/beam/laser/frontier_pistol + TEST_ASSERT_EQUAL(initial(pistol_beam.damage), 20, "The Kestrel beam does not do 20 damage") + + var/obj/item/ammo_casing/energy/smg_casing = /obj/item/ammo_casing/energy/lasergun/frontier_smg + TEST_ASSERT_EQUAL(initial(smg_casing.projectile_type), /obj/projectile/beam/laser, "The Hornet does not fire the stock laser") + + var/obj/item/gun/energy/pistol = allocate(/obj/item/gun/energy/frontier_laser/pistol) + var/obj/item/ammo_casing/energy/pistol_shot = pistol.ammo_type[1] + TEST_ASSERT_EQUAL(pistol_shot.type, /obj/item/ammo_casing/energy/frontier_laser, "The Kestrel loads the wrong casing") + TEST_ASSERT_EQUAL(pistol.fire_delay, 5, "The Kestrel does not fire every 0.5 seconds") + + var/obj/item/gun/energy/smg = allocate(/obj/item/gun/energy/frontier_laser/smg) + var/obj/item/ammo_casing/energy/smg_shot = smg.ammo_type[1] + TEST_ASSERT_EQUAL(smg_shot.type, /obj/item/ammo_casing/energy/lasergun/frontier_smg, "The Hornet loads the wrong casing") + TEST_ASSERT_NOTNULL(smg.GetComponent(/datum/component/automatic_fire), "The Hornet is not automatic") + + for(var/obj/item/gun/energy/gun as anything in list(pistol, smg)) + TEST_ASSERT(!istype(gun, /obj/item/gun/energy/laser), "[gun.type] is a laser gun subtype, so the laser export and slapcraft would take it") + +/// A full standard cell gives each gun exactly 20 shots. +/datum/unit_test/voidcrew_frontier_laser_shots/Run() + for(var/gun_type in list(/obj/item/gun/energy/frontier_laser/pistol, /obj/item/gun/energy/frontier_laser/smg)) + var/obj/item/gun/energy/gun = allocate(gun_type) + var/obj/item/ammo_casing/energy/shot = gun.ammo_type[1] + TEST_ASSERT_EQUAL(gun.cell.charge, gun.cell.maxcharge, "[gun_type] did not spawn with a full cell") + TEST_ASSERT_EQUAL(round(gun.cell.maxcharge / shot.e_cost), 20, "[gun_type] does not hold 20 shots") + +/// Both guns go into a recharger and charge there. +/datum/unit_test/voidcrew_frontier_laser_recharge/Run() + var/mob/living/carbon/human/consistent/user = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + var/obj/machinery/recharger/charger = allocate(/obj/machinery/recharger, run_loc_floor_bottom_left) + for(var/gun_type in list(/obj/item/gun/energy/frontier_laser/pistol, /obj/item/gun/energy/frontier_laser/smg)) + var/obj/item/gun/energy/gun = allocate(gun_type) + TEST_ASSERT(gun.can_charge, "[gun_type] refuses rechargers") + gun.cell.charge = 0 + TEST_ASSERT(user.put_in_active_hand(gun), "The user could not hold [gun_type]") + charger.attackby(gun, user) + TEST_ASSERT_EQUAL(charger.charging, gun, "The recharger did not take [gun_type]") + charger.process(10) + TEST_ASSERT(gun.cell.charge > 0, "The recharger did not charge [gun_type]") + gun.forceMove(run_loc_floor_bottom_left) + TEST_ASSERT_NULL(charger.charging, "The recharger still holds [gun_type] after it left") diff --git a/voidcrew/modules/unit_tests/voidcrew_hitscan_lasers.dm b/voidcrew/modules/unit_tests/voidcrew_hitscan_lasers.dm new file mode 100644 index 00000000000..273bb2604ac --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_hitscan_lasers.dm @@ -0,0 +1,57 @@ +/// Standard lasers are hitscan with a visible tracer; the beams we chose to keep as bolts are not. +/datum/unit_test/voidcrew_hitscan_lasers/Run() + var/list/obj/projectile/hitscan_types = list( + /obj/projectile/beam/laser, + /obj/projectile/beam/laser/carbine, + /obj/projectile/beam/laser/carbine/cybersun, + /obj/projectile/beam/laser/hellfire, + /obj/projectile/beam/laser/heavylaser, + /obj/projectile/beam/laser/musket, + /obj/projectile/beam/laser/outpost, + /obj/projectile/beam/scatter, + /obj/projectile/beam/weak/penetrator, + /obj/projectile/beam/practice, + ) + for(var/obj/projectile/laser_type as anything in hitscan_types) + TEST_ASSERT(initial(laser_type.hitscan), "[laser_type] is not hitscan") + TEST_ASSERT_NOTNULL(initial(laser_type.tracer_type), "[laser_type] is hitscan with no tracer, so its beam is invisible") + + var/list/obj/projectile/bolt_types = list( + /obj/projectile/beam/disabler, + /obj/projectile/beam/xray, + /obj/projectile/beam/pulse, + /obj/projectile/beam/emitter, + /obj/projectile/beam/ship_defense, + /obj/projectile/beam/laser/accelerator, + /obj/projectile/beam/laser/laser_eyes, + /obj/projectile/beam/laser/mech, + /obj/projectile/beam/laser/heavylaser/mech, + ) + for(var/obj/projectile/bolt_type as anything in bolt_types) + TEST_ASSERT(!initial(bolt_type.hitscan), "[bolt_type] became hitscan") + + var/obj/item/mecha_parts/mecha_equipment/weapon/energy/laser/mech_laser = /obj/item/mecha_parts/mecha_equipment/weapon/energy/laser + TEST_ASSERT_EQUAL(initial(mech_laser.projectile), /obj/projectile/beam/laser/mech, "The mech laser fires a hitscan laser") + +/// A hitscan laser fired at someone three tiles away lands, from a bare projectile and from a trigger pull. +/// The beam finishes on the firing tick unless the tick is overrun, which it always is in a test, so wait for it. +/datum/unit_test/voidcrew_hitscan_laser_lands/Run() + var/mob/living/carbon/human/consistent/shooter = allocate(/mob/living/carbon/human/consistent, run_loc_floor_bottom_left) + var/turf/target_turf = locate(run_loc_floor_bottom_left.x + 3, run_loc_floor_bottom_left.y, run_loc_floor_bottom_left.z) + var/mob/living/carbon/human/consistent/target = allocate(/mob/living/carbon/human/consistent, target_turf) + + var/obj/projectile/beam/laser/beam = new(run_loc_floor_bottom_left) + beam.aim_projectile(target, shooter) + beam.firer = shooter + beam.fire() + UNTIL(QDELETED(beam)) + TEST_ASSERT(target.getFireLoss() > 0, "A hitscan laser fired at a target three tiles away missed") + + var/before = target.getFireLoss() + var/obj/item/gun/energy/laser/rifle = allocate(/obj/item/gun/energy/laser) + TEST_ASSERT(shooter.put_in_active_hand(rifle), "The shooter could not hold the laser rifle") + var/charge_before = rifle.cell.charge + rifle.fire_gun(target, shooter, FALSE) + TEST_ASSERT(rifle.cell.charge < charge_before, "The laser rifle did not fire") + sleep(1 SECONDS) + TEST_ASSERT(target.getFireLoss() > before, "A laser rifle shot three tiles away missed") diff --git a/voidcrew/modules/unit_tests/voidcrew_loot.dm b/voidcrew/modules/unit_tests/voidcrew_loot.dm index 068de6eac78..95ec09fe2ca 100644 --- a/voidcrew/modules/unit_tests/voidcrew_loot.dm +++ b/voidcrew/modules/unit_tests/voidcrew_loot.dm @@ -291,7 +291,7 @@ /// A working copy may be consumed without damaging another crate's singleton tables. /datum/unit_test/voidcrew_cache_weapon_pools/Run() - var/list/main_counts = list("armory" = list(1, 3, 5), "syndicate" = list(1, 2, 3), "plunder" = list(2, 1, 3), "freight" = list(4, 2)) + var/list/main_counts = list("armory" = list(2, 5, 5), "syndicate" = list(1, 2, 3), "plunder" = list(2, 2, 5), "freight" = list(5, 2)) for(var/family in main_counts) var/list/counts = main_counts[family] var/list/classes = GLOB.loot_gun_pools[family] @@ -300,7 +300,8 @@ var/list/pool = classes[class] TEST_ASSERT_EQUAL(length(pool), counts[text2num(class)], "Main gun class changed size") for(var/entry in pool) - TEST_ASSERT_EQUAL(pool[entry], 10, "Main gun class is not uniform") + // The Old Furnace is the one rare unique in the main pools + TEST_ASSERT_EQUAL(pool[entry], entry == /obj/item/gun/energy/plasma_weapon/rifle/old_furnace ? 1 : 10, "Main gun class is not uniform") var/list/melee_counts = list(9, 6, 4) for(var/class in GLOB.loot_melee_pools) var/list/pool = GLOB.loot_melee_pools[class] diff --git a/voidcrew/modules/unit_tests/voidcrew_planetary_factions.dm b/voidcrew/modules/unit_tests/voidcrew_planetary_factions.dm index a0a240408a5..6c303586084 100644 --- a/voidcrew/modules/unit_tests/voidcrew_planetary_factions.dm +++ b/voidcrew/modules/unit_tests/voidcrew_planetary_factions.dm @@ -163,7 +163,7 @@ for(var/mob_type in prizes) var/datum/npc_spawn_context/context = allocate(/datum/npc_spawn_context, src, 3) var/mob/living/basic/trooper/pirate/faction/pirate = spawn_npc_in_zone(mob_type, floor, context) - var/expected = length(pirate.rare_loot_pool) ? (prizes[mob_type] in pirate.rolled_rare_loot) : 1 + var/expected = (prizes[mob_type] in pirate.rare_loot_pool) ? (prizes[mob_type] in pirate.rolled_rare_loot) : 1 var/list/before = floor.get_all_contents() pirate.death(TRUE) var/count = 0 diff --git a/voidcrew/modules/unit_tests/voidcrew_plasma_bolt.dm b/voidcrew/modules/unit_tests/voidcrew_plasma_bolt.dm new file mode 100644 index 00000000000..c1db4924735 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_plasma_bolt.dm @@ -0,0 +1,244 @@ +// Package: P1 bolt. Unit tests for voidcrew/modules/plasma_weapons/. + +/// Shared helpers for the plasma bolt tests. +/datum/unit_test/voidcrew_plasma_bolt + abstract_type = /datum/unit_test/voidcrew_plasma_bolt + +/datum/unit_test/voidcrew_plasma_bolt/proc/turf_at(dx, dy) + return locate(run_loc_floor_bottom_left.x + dx, run_loc_floor_bottom_left.y + dy, run_loc_floor_bottom_left.z) + +/// A bolt sitting on `where` that came from there, ready to be handed to bullet_act(). +/datum/unit_test/voidcrew_plasma_bolt/proc/make_bolt(bolt_type, turf/where, zone = BODY_ZONE_CHEST) + var/obj/projectile/energy/plasma_bolt/bolt = allocate(bolt_type, where) + bolt.starting = where + bolt.def_zone = zone + return bolt + +/// Both bolts are matter: energy armour, never reflected, stopped by glass and grilles, never hitscan. +/datum/unit_test/voidcrew_plasma_bolt/flags + +/datum/unit_test/voidcrew_plasma_bolt/flags/Run() + for(var/obj/projectile/energy/plasma_bolt/bolt_type as anything in list(/obj/projectile/energy/plasma_bolt, /obj/projectile/energy/plasma_bolt/heavy)) + TEST_ASSERT_EQUAL(initial(bolt_type.armor_flag), ENERGY, "[bolt_type] does not use energy armour") + TEST_ASSERT_EQUAL(initial(bolt_type.damage_type), BURN, "[bolt_type] does not burn") + TEST_ASSERT_EQUAL(initial(bolt_type.reflectable), NONE, "[bolt_type] can be reflected") + TEST_ASSERT(!initial(bolt_type.hitscan), "[bolt_type] is hitscan") + TEST_ASSERT(!(initial(bolt_type.pass_flags) & (PASSGLASS|PASSGRILLE)), "[bolt_type] passes through glass or grilles") + TEST_ASSERT_EQUAL(initial(bolt_type.dismemberment), 0, "[bolt_type] dismembers through the brute projectile path") + TEST_ASSERT_EQUAL(initial(bolt_type.light_system), OVERLAY_LIGHT, "[bolt_type] does not carry an overlay light") + TEST_ASSERT_EQUAL(/obj/projectile/energy/plasma_bolt::damage, 32, "pistol bolt damage changed") + TEST_ASSERT_EQUAL(/obj/projectile/energy/plasma_bolt/heavy::damage, 45, "rifle bolt damage changed") + TEST_ASSERT(/obj/projectile/energy/plasma_bolt/heavy::wound_bonus > /obj/projectile/energy/plasma_bolt::wound_bonus, "the rifle bolt should wound more readily than the pistol bolt") + TEST_ASSERT(/obj/projectile/energy/plasma_bolt::wound_bonus > 0, "the pistol bolt has no wound bonus") + TEST_ASSERT_EQUAL(/obj/projectile/energy/plasma_bolt::demolition_mod, PLASMA_DEMOLITION_PISTOL, "pistol bolt structure damage changed") + TEST_ASSERT_EQUAL(/obj/projectile/energy/plasma_bolt/heavy::demolition_mod, PLASMA_DEMOLITION_RIFLE, "rifle bolt structure damage changed") + TEST_ASSERT_EQUAL(/obj/item/ammo_casing/energy/plasma_bolt::projectile_type, /obj/projectile/energy/plasma_bolt, "the pistol casing fires the wrong bolt") + TEST_ASSERT_EQUAL(/obj/item/ammo_casing/energy/plasma_bolt/heavy::projectile_type, /obj/projectile/energy/plasma_bolt/heavy, "the rifle casing fires the wrong bolt") + TEST_ASSERT_EQUAL(/obj/item/ammo_casing/energy/plasma_bolt::e_cost, PLASMA_SHOT_MOL_PISTOL, "the pistol casing costs the wrong mol") + TEST_ASSERT_EQUAL(/obj/item/ammo_casing/energy/plasma_bolt/heavy::e_cost, PLASMA_SHOT_MOL_RIFLE, "the rifle casing costs the wrong mol") + +/// A canister's energy armour stops a bolt cold; a shuttle window has none and takes the hit at the +/// bolt's demolition multiplier. Doors take it too; other machines take plain damage. +/datum/unit_test/voidcrew_plasma_bolt/structures + +/datum/unit_test/voidcrew_plasma_bolt/structures/Run() + var/turf/open/here = turf_at(1, 1) + var/obj/machinery/portable_atmospherics/canister/canister = allocate(/obj/machinery/portable_atmospherics/canister, turf_at(2, 1)) + var/canister_before = canister.get_integrity() + canister.projectile_hit(make_bolt(/obj/projectile/energy/plasma_bolt/heavy, here)) + TEST_ASSERT_EQUAL(canister.get_integrity(), canister_before, "a rifle bolt damaged a canister") + + var/obj/structure/window/reinforced/shuttle/window = allocate(/obj/structure/window/reinforced/shuttle, turf_at(1, 2)) + var/window_before = window.get_integrity() + window.projectile_hit(make_bolt(/obj/projectile/energy/plasma_bolt, here)) + var/expected = /obj/projectile/energy/plasma_bolt::damage * PLASMA_DEMOLITION_PISTOL + TEST_ASSERT_EQUAL(window_before - window.get_integrity(), expected, "a pistol bolt took [window_before - window.get_integrity()] from a shuttle window, expected [expected]") + + var/obj/projectile/energy/plasma_bolt/heavy/rifle_bolt = make_bolt(/obj/projectile/energy/plasma_bolt/heavy, here) + var/obj/machinery/door/airlock/public/door = allocate(/obj/machinery/door/airlock/public, turf_at(2, 2)) + TEST_ASSERT_EQUAL(rifle_bolt.get_demolition_modifier(window), PLASMA_DEMOLITION_RIFLE, "a rifle bolt does not break windows faster") + TEST_ASSERT_EQUAL(rifle_bolt.get_demolition_modifier(door), PLASMA_DEMOLITION_RIFLE, "a rifle bolt does not break doors faster") + var/obj/machinery/recharger/machine = allocate(/obj/machinery/recharger, turf_at(1, 1)) + TEST_ASSERT_EQUAL(rifle_bolt.get_demolition_modifier(machine), 1, "a rifle bolt breaks ordinary machines faster") + +/// A flying bolt lights a turf holding fuel gas and oxygen, and nothing in clean air or vacuum. +/datum/unit_test/voidcrew_plasma_bolt/ignition + +/datum/unit_test/voidcrew_plasma_bolt/ignition/Run() + var/turf/open/start = turf_at(1, 1) + var/turf/open/fuel = turf_at(2, 1) + var/turf/open/clean = turf_at(1, 2) + var/turf/open/vacuum = turf_at(2, 2) + TEST_ASSERT(istype(fuel) && istype(clean) && istype(vacuum), "the test room has no open floor to fire across") + var/datum/gas_mixture/vacuum_saved = vacuum.air.copy() + var/datum/gas_mixture/fuel_saved = fuel.air.copy() + + fuel.atmos_spawn_air("[GAS_PLASMA]=5;TEMP=[T20C]") + vacuum.air.remove_ratio(1) + + var/obj/projectile/energy/plasma_bolt/bolt = make_bolt(/obj/projectile/energy/plasma_bolt, start) + bolt.fired = TRUE + bolt.forceMove(clean) + TEST_ASSERT_NULL(clean.active_hotspot, "a bolt lit clean air") + bolt.forceMove(vacuum) + TEST_ASSERT_NULL(vacuum.active_hotspot, "a bolt lit a vacuum") + bolt.forceMove(fuel) + TEST_ASSERT_NOTNULL(fuel.active_hotspot, "a bolt flew through 5 mol of plasma in air and lit nothing") + + if(fuel.active_hotspot) + qdel(fuel.active_hotspot) + fuel.air.copy_from(fuel_saved) + vacuum.air.copy_from(vacuum_saved) + +/// A bolt that hits someone in breathable air burns them but never sets them alight. +/datum/unit_test/voidcrew_plasma_bolt/no_fire_on_hit + +/datum/unit_test/voidcrew_plasma_bolt/no_fire_on_hit/Run() + for(var/bolt_type in list(/obj/projectile/energy/plasma_bolt, /obj/projectile/energy/plasma_bolt/heavy)) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + victim.bullet_act(make_bolt(bolt_type, turf_at(1, 1)), BODY_ZONE_CHEST) + TEST_ASSERT(victim.getFireLoss() > 0, "a [bolt_type] hit did no burn damage") + TEST_ASSERT_EQUAL(victim.fire_stacks, 0, "a [bolt_type] hit added [victim.fire_stacks] fire stacks") + TEST_ASSERT(!victim.on_fire, "a [bolt_type] hit set the target alight") + +/// A rifle bolt on a bare, unhurt arm always leaves a burn wound (its wound bonus clears the moderate +/// threshold on any roll) and does not melt the arm in one hit. +/datum/unit_test/voidcrew_plasma_bolt/burn_wound + +/datum/unit_test/voidcrew_plasma_bolt/burn_wound/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + victim.bullet_act(make_bolt(/obj/projectile/energy/plasma_bolt/heavy, turf_at(1, 1), BODY_ZONE_R_ARM), BODY_ZONE_R_ARM) + var/obj/item/bodypart/arm = victim.get_bodypart(BODY_ZONE_R_ARM) + TEST_ASSERT_NOTNULL(arm, "one rifle bolt melted an unhurt arm") + TEST_ASSERT_NOTNULL(arm?.get_wound_type(/datum/wound/burn/flesh), "a rifle bolt on a bare arm left no burn wound") + +/// A rifle bolt burns away an arm already at 40/50, without a blood spray, and leaves no ash (the melt leaves slag). +/datum/unit_test/voidcrew_plasma_bolt/ash_arm + +/datum/unit_test/voidcrew_plasma_bolt/ash_arm/Run() + var/turf/open/spot = turf_at(2, 1) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, spot) + var/obj/item/bodypart/arm = victim.get_bodypart(BODY_ZONE_L_ARM) + arm.receive_damage(brute = 40, wound_bonus = CANT_WOUND) + TEST_ASSERT_EQUAL(arm.get_damage(), 40, "could not set the arm to 40 damage") + + victim.bullet_act(make_bolt(/obj/projectile/energy/plasma_bolt/heavy, turf_at(1, 1), BODY_ZONE_L_ARM), BODY_ZONE_L_ARM) + TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_L_ARM), "a rifle bolt left an arm at 40/50 attached") + TEST_ASSERT_NULL(locate(/obj/effect/decal/cleanable/ash) in spot, "the melted arm left tg's ash pile") + TEST_ASSERT_NULL(locate(/obj/effect/decal/cleanable/blood/hitsplatter) in range(4, spot), "ashing an arm sprayed blood") + for(var/obj/effect/decal/cleanable/leftover in range(4, spot)) + qdel(leftover) + +/// A barely hurt arm stays; a pistol bolt melts a hurt arm within its margin; a rifle bolt melts a leg +/// carrying a slagged plasma critical and a pistol bolt does not; a tg critical burn alone sets up no melt; +/// chests and heads never melt. +/datum/unit_test/voidcrew_plasma_bolt/ash_limits + +/datum/unit_test/voidcrew_plasma_bolt/ash_limits/Run() + var/turf/open/from = turf_at(1, 1) + + // Damage under the "already hurt" line: even a full rifle bolt leaves the arm on. + var/mob/living/carbon/human/consistent/light = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + var/obj/item/bodypart/right_arm = light.get_bodypart(BODY_ZONE_R_ARM) + right_arm.receive_damage(brute = PLASMA_ASH_PRIOR_DAMAGE - 5, wound_bonus = CANT_WOUND) + light.bullet_act(make_bolt(/obj/projectile/energy/plasma_bolt/heavy, from, BODY_ZONE_R_ARM), BODY_ZONE_R_ARM) + TEST_ASSERT_NOTNULL(light.get_bodypart(BODY_ZONE_R_ARM), "a rifle bolt melted an arm that was barely hurt") + + // Pistol: 15 before + 32 = 47, inside its margin (40). 5 before + 32 = 37 stays. + var/mob/living/carbon/human/consistent/hurt = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + var/obj/item/bodypart/hurt_arm = hurt.get_bodypart(BODY_ZONE_L_ARM) + hurt_arm.receive_damage(brute = 15, wound_bonus = CANT_WOUND) + hurt.bullet_act(make_bolt(/obj/projectile/energy/plasma_bolt, from, BODY_ZONE_L_ARM), BODY_ZONE_L_ARM) + TEST_ASSERT_NULL(hurt.get_bodypart(BODY_ZONE_L_ARM), "a pistol bolt left a hurt arm at 47/50 attached") + var/obj/item/bodypart/hurt_leg = hurt.get_bodypart(BODY_ZONE_L_LEG) + hurt_leg.receive_damage(brute = 5, wound_bonus = CANT_WOUND) + hurt.bullet_act(make_bolt(/obj/projectile/energy/plasma_bolt, from, BODY_ZONE_L_LEG), BODY_ZONE_L_LEG) + TEST_ASSERT_NOTNULL(hurt.get_bodypart(BODY_ZONE_L_LEG), "a pistol bolt melted a leg that was barely hurt") + + // A slagged plasma critical and no damage: the rifle bolt burns through, the pistol bolt does not. + for(var/bolt_type in list(/obj/projectile/energy/plasma_bolt, /obj/projectile/energy/plasma_bolt/heavy)) + var/mob/living/carbon/human/consistent/burned = allocate(/mob/living/carbon/human/consistent, turf_at(2, 2)) + var/obj/item/bodypart/leg = burned.get_bodypart(BODY_ZONE_R_LEG) + leg.force_wound_upwards(/datum/wound/burn/flesh/plasma/critical) + leg.heal_damage(brute = 200, burn = 200) + var/datum/wound/burn/flesh/plasma/critical/slagged = leg.get_wound_type(/datum/wound/burn/flesh/plasma/critical) + TEST_ASSERT_NOTNULL(slagged, "could not give a leg a plasma critical burn") + TEST_ASSERT(slagged.slag > 0, "a fresh plasma critical carries no slag") + var/obj/projectile/energy/plasma_bolt/bolt = make_bolt(bolt_type, from, BODY_ZONE_R_LEG) + bolt.damage = 5 + burned.bullet_act(bolt, BODY_ZONE_R_LEG) + if(bolt.ash_critical_burns) + TEST_ASSERT_NULL(burned.get_bodypart(BODY_ZONE_R_LEG), "a rifle bolt left a leg with a slagged plasma critical attached") + else + TEST_ASSERT_NOTNULL(burned.get_bodypart(BODY_ZONE_R_LEG), "a pistol bolt melted a critically burnt leg with no damage") + + // A tg critical burn alone (fire, a laser) does not set up the rifle's burn-through. + var/mob/living/carbon/human/consistent/fire_burned = allocate(/mob/living/carbon/human/consistent, turf_at(2, 2)) + var/obj/item/bodypart/fire_leg = fire_burned.get_bodypart(BODY_ZONE_R_LEG) + fire_leg.force_wound_upwards(/datum/wound/burn/flesh/critical) + fire_leg.heal_damage(brute = 200, burn = 200) + TEST_ASSERT_NOTNULL(fire_leg.get_wound_type(/datum/wound/burn/flesh/critical), "could not give a leg a tg critical burn") + var/obj/projectile/energy/plasma_bolt/rifle_bolt = make_bolt(/obj/projectile/energy/plasma_bolt/heavy, from, BODY_ZONE_R_LEG) + rifle_bolt.damage = 5 + fire_burned.bullet_act(rifle_bolt, BODY_ZONE_R_LEG) + TEST_ASSERT_NOTNULL(fire_burned.get_bodypart(BODY_ZONE_R_LEG), "a rifle bolt melted a leg with only a tg critical burn") + + for(var/zone in list(BODY_ZONE_CHEST, BODY_ZONE_HEAD)) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, turf_at(2, 2)) + var/obj/item/bodypart/part = victim.get_bodypart(zone) + part.receive_damage(brute = part.max_damage - 10, wound_bonus = CANT_WOUND) + victim.bullet_act(make_bolt(/obj/projectile/energy/plasma_bolt/heavy, from, zone), zone) + TEST_ASSERT_NOTNULL(victim.get_bodypart(zone), "a rifle bolt ashed a [zone]") + + for(var/obj/effect/decal/cleanable/leftover in range(4, from)) + qdel(leftover) + +/// The ash handler removes itself on its first damage event, even one the bolt didn't cause. +/datum/unit_test/voidcrew_plasma_bolt/ash_unregisters + +/datum/unit_test/voidcrew_plasma_bolt/ash_unregisters/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + + var/obj/projectile/energy/plasma_bolt/bolt = make_bolt(/obj/projectile/energy/plasma_bolt, turf_at(1, 1)) + victim.bullet_act(bolt, BODY_ZONE_CHEST) + TEST_ASSERT(!QDELETED(bolt), "bullet_act deleted the bolt") + TEST_ASSERT_NULL(LAZYACCESS(bolt._signal_procs, victim), "the bolt is still listening to its target after the hit") + + // Some other damage lands first: the handler leaves, and that damage ashes nothing. + var/obj/projectile/energy/plasma_bolt/heavy/second = make_bolt(/obj/projectile/energy/plasma_bolt/heavy, turf_at(1, 1)) + second.RegisterSignal(victim, COMSIG_MOB_AFTER_APPLY_DAMAGE, TYPE_PROC_REF(/obj/projectile/energy/plasma_bolt, plasma_check_ash)) + var/obj/item/bodypart/leg = victim.get_bodypart(BODY_ZONE_L_LEG) + victim.apply_damage(50, BURN, leg, wound_bonus = CANT_WOUND) + TEST_ASSERT_NOTNULL(victim.get_bodypart(BODY_ZONE_L_LEG), "damage from something other than the bolt ashed a leg") + TEST_ASSERT_NULL(LAZYACCESS(second._signal_procs, victim), "the ash handler did not unregister on a damage event that wasn't its bolt") + + // A bolt deleted before its damage lands takes its registration with it. + var/obj/projectile/energy/plasma_bolt/third = make_bolt(/obj/projectile/energy/plasma_bolt, turf_at(1, 1)) + third.RegisterSignal(victim, COMSIG_MOB_AFTER_APPLY_DAMAGE, TYPE_PROC_REF(/obj/projectile/energy/plasma_bolt, plasma_check_ash)) + LAZYADD(third.hissed_mobs, victim) + qdel(third) + var/listeners = victim._listen_lookup?[COMSIG_MOB_AFTER_APPLY_DAMAGE] + TEST_ASSERT(islist(listeners) ? !(third in listeners) : listeners != third, "a deleted bolt is still registered on its target") + TEST_ASSERT_NULL(third.hissed_mobs, "a deleted bolt kept its hiss-by list") + +/// Sound plumbing: no tg sizzle on a plasma burn, no tg dismember sound mid-melt, and a shield slot that falls back to the shield +/datum/unit_test/voidcrew_plasma_sound_slots + +/datum/unit_test/voidcrew_plasma_sound_slots/Run() + for(var/datum/wound/burn/flesh/plasma/wound_type as anything in typesof(/datum/wound/burn/flesh/plasma)) + TEST_ASSERT_NULL(initial(wound_type.sound_effect), "[wound_type] plays a tg wound sound on top of the bolt's") + + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent) + TEST_ASSERT(!plasma_melt_in_progress(victim), "A body counts as mid-melt with no melt running") + GLOB.plasma_melt_no_splatter = WEAKREF(victim) + TEST_ASSERT(plasma_melt_in_progress(victim), "A body mid-melt does not count as mid-melt") + GLOB.plasma_melt_no_splatter = null + + var/obj/projectile/energy/plasma_bolt/bolt = allocate(/obj/projectile/energy/plasma_bolt) + var/obj/item/shield/riot/shield = allocate(/obj/item/shield/riot) + var/obj/item/knife/kitchen/knife = allocate(/obj/item/knife/kitchen) + bolt.shield_block_sound = 'sound/machines/click.ogg' + TEST_ASSERT_EQUAL(bolt.blocked_sound_for(shield), 'sound/machines/click.ogg', "A set shield sound does not replace the shield's own") + TEST_ASSERT_NULL(bolt.blocked_sound_for(knife), "The shield sound replaced a non-shield's block sound") + bolt.shield_block_sound = null + TEST_ASSERT_NULL(bolt.blocked_sound_for(shield), "An unset shield sound does not fall back to the shield's own") diff --git a/voidcrew/modules/unit_tests/voidcrew_plasma_effects.dm b/voidcrew/modules/unit_tests/voidcrew_plasma_effects.dm new file mode 100644 index 00000000000..1bb88ba8011 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_plasma_effects.dm @@ -0,0 +1,226 @@ +// Package: P8 effects. Unit tests for voidcrew/modules/plasma_weapons/. + +/// Soot is one per turf, capped globally with the oldest dropped first, and leaves the global list when deleted +/datum/unit_test/voidcrew_plasma_soot_caps + +/datum/unit_test/voidcrew_plasma_soot_caps/Run() + var/list/start_soot = GLOB.plasma_soot_decals.Copy() + var/turf/open/first_turf = run_loc_floor_bottom_left + var/turf/open/second_turf = get_step(first_turf, EAST) + var/turf/open/third_turf = get_step(second_turf, EAST) + TEST_ASSERT(isopenturf(first_turf) && isopenturf(second_turf) && isopenturf(third_turf), "The test room has no three open floor tiles in a row") + var/cap = length(start_soot) + 2 + + var/obj/effect/decal/cleanable/plasma_soot/first = plasma_soot_at(first_turf, cap = cap) + TEST_ASSERT_NOTNULL(first, "No soot was made on an open floor") + TEST_ASSERT(first in GLOB.plasma_soot_decals, "New soot was not added to the global list") + + var/obj/effect/decal/cleanable/plasma_soot/again = plasma_soot_at(first_turf, cap = cap) + TEST_ASSERT_EQUAL(again, first, "A second hit on the same tile made a second soot decal") + var/on_turf = 0 + for(var/obj/effect/decal/cleanable/plasma_soot/soot in first_turf) + on_turf++ + TEST_ASSERT_EQUAL(on_turf, 1, "A tile holds more than one soot decal") + + var/obj/effect/decal/cleanable/plasma_soot/second = plasma_soot_at(second_turf, cap = cap) + var/obj/effect/decal/cleanable/plasma_soot/third = plasma_soot_at(third_turf, cap = cap) + TEST_ASSERT(QDELETED(first), "Soot over the cap did not drop the oldest decal") + TEST_ASSERT(!QDELETED(second) && !QDELETED(third), "Soot over the cap dropped a newer decal") + TEST_ASSERT_EQUAL(length(GLOB.plasma_soot_decals), cap, "The global soot list grew past its cap") + + qdel(second) + TEST_ASSERT(!(second in GLOB.plasma_soot_decals), "Deleted soot stayed in the global list") + qdel(third) + TEST_ASSERT_EQUAL(length(GLOB.plasma_soot_decals), length(start_soot), "The global soot list kept test decals") + + +/// Wall glows are capped per z-level, and a deleted glow frees its slot +/datum/unit_test/voidcrew_plasma_glow_cap + +/datum/unit_test/voidcrew_plasma_glow_cap/Run() + var/turf/here = run_loc_floor_bottom_left + var/key = "[here.z]" + var/start_count = GLOB.plasma_wall_glows_by_z[key] || 0 + var/cap = start_count + 2 + + var/obj/effect/temp_visual/plasma_wall_glow/first = plasma_wall_glow_at(here, cap = cap) + var/obj/effect/temp_visual/plasma_wall_glow/second = plasma_wall_glow_at(here, cap = cap) + TEST_ASSERT(first && second, "Glows under the cap were not made") + TEST_ASSERT_NULL(plasma_wall_glow_at(here, cap = cap), "A glow over the per-z cap was made") + TEST_ASSERT_EQUAL(GLOB.plasma_wall_glows_by_z[key], cap, "The per-z glow count is wrong") + + // Soot glows count against the same cap, so soot made at the cap gets none + var/turf/open/soot_turf = get_step(here, EAST) + var/room_for_glow = plasma_glow_allowed(here.z) + var/obj/effect/decal/cleanable/plasma_soot/soot = plasma_soot_at(soot_turf) + TEST_ASSERT_NOTNULL(soot, "Soot was not made") + if(room_for_glow) + TEST_ASSERT_NOTNULL(soot.glow, "Fresh soot under the real cap has no glow") + qdel(soot) + + qdel(first) + TEST_ASSERT(plasma_glow_allowed(here.z, cap), "A deleted glow did not free its slot") + qdel(second) + TEST_ASSERT_EQUAL(GLOB.plasma_wall_glows_by_z[key] || 0, start_count, "Deleted glows stayed counted") + +/// The distant firing sound only reaches listeners past normal hearing range and inside the far range +/datum/unit_test/voidcrew_plasma_far_sound + +/datum/unit_test/voidcrew_plasma_far_sound/Run() + var/turf/source = run_loc_floor_bottom_left + TEST_ASSERT_EQUAL(plasma_far_sound_volume(source, source), 0, "The far sound plays at the source") + var/list/expected = list("10" = FALSE, "17" = FALSE, "18" = TRUE, "40" = TRUE, "41" = FALSE) + for(var/dist_text in expected) + var/dist = text2num(dist_text) + if(source.x + dist > world.maxx) + continue + var/turf/listener_turf = locate(source.x + dist, source.y, source.z) + var/volume = plasma_far_sound_volume(listener_turf, source) + if(expected[dist_text]) + TEST_ASSERT(volume >= 30 && volume <= 60, "A listener [dist] tiles away got far-sound volume [volume]") + else + TEST_ASSERT_EQUAL(volume, 0, "A listener [dist] tiles away heard the far sound") + + if(source.x + 25 > world.maxx) + return + var/mob/living/carbon/human/near = allocate(/mob/living/carbon/human/consistent, locate(source.x + 5, source.y, source.z)) + var/mob/living/carbon/human/far = allocate(/mob/living/carbon/human/consistent, locate(source.x + 25, source.y, source.z)) + var/list/picked = plasma_far_sound_pick(source, list(near, far)) + TEST_ASSERT(!(near in picked), "A listener inside normal hearing range was picked for the far sound") + // The far listener stands on whatever turf is there; in thin air it is rightly skipped + var/turf/far_turf = get_turf(far) + var/datum/gas_mixture/far_air = far_turf.return_air_readonly() + var/datum/gas_mixture/source_air = source.return_air_readonly() + if(far_air?.return_pressure() >= ONE_ATMOSPHERE && source_air?.return_pressure() >= ONE_ATMOSPHERE) + TEST_ASSERT(far in picked, "A listener 25 tiles away in air was not picked for the far sound") + +/// A held shield that blocks a plasma bolt stops it and takes the hit itself, and the bolt splashes on the shield, +/// coats it and drips off as slag; with the body in the way it all draws under the holder. +/datum/unit_test/voidcrew_plasma_bolt/shield_block + +/datum/unit_test/voidcrew_plasma_bolt/shield_block/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + var/obj/item/shield/riot/shield = allocate(/obj/item/shield/riot) + shield.block_chance = INFINITY + victim.put_in_active_hand(shield, forced = TRUE) + var/integrity_before = shield.get_integrity() + var/turf/victim_turf = get_turf(victim) + + // Facing the shot: it meets the shield in view + victim.setDir(SOUTH) + var/obj/projectile/energy/plasma_bolt/heavy/bolt = make_bolt(/obj/projectile/energy/plasma_bolt/heavy, turf_at(2, 0)) + var/side = REVERSE_DIR(angle2dir_cardinal(bolt.angle)) + var/key = plasma_shield_key(victim, shield, side) + TEST_ASSERT_EQUAL(key, "riot_[IS_LEFT_INDEX(victim.get_held_index_of_item(shield)) ? "L" : "R"]S[plasma_burst_dir_letter(side)]", "a riot shield block picked the wrong baked look") + var/list/anchor = GLOB.plasma_shield_anchors[key] + TEST_ASSERT_NOTNULL(anchor, "no anchor for a riot shield block") + victim.bullet_act(bolt, BODY_ZONE_CHEST) + TEST_ASSERT_EQUAL(victim.getFireLoss(), 0, "a plasma bolt burned through a shield with a forced block") + TEST_ASSERT(shield.get_integrity() < integrity_before, "a shield that blocked a plasma bolt took no damage") + TEST_ASSERT_NULL(locate(/obj/effect/temp_visual/plasma_burst) in victim_turf, "a shield block made a flesh burst") + TEST_ASSERT_NULL(locate(/obj/effect/temp_visual/block) in victim_turf, "a shield block of a plasma bolt left tg's block star") + TEST_ASSERT_EQUAL(bolt.layer < victim.layer, !!anchor[3], "a blocked bolt drew on the wrong side of the holder") + // The splash waits a tick for the bolt's sprite to reach the shield + sleep(world.tick_lag * 3) + TEST_ASSERT_NULL(locate(/obj/effect/temp_visual/plasma_splash) in victim_turf, "a shield block made the old armour splash") + var/list/parts = list() + for(var/obj/effect/temp_visual/plasma_shield_splash/part in victim_turf) + parts[part.icon_state] = part + var/obj/effect/temp_visual/plasma_shield_splash/low = parts["l_[key]"] + TEST_ASSERT_NOTNULL(low, "a shield block left no slag") + TEST_ASSERT(low.layer < victim.layer, "a shield block's slag drew over the holder") + var/obj/effect/temp_visual/plasma_shield_coat/coat = locate() in victim.vis_contents + if(anchor[3]) + TEST_ASSERT_NULL(parts["h_[key]"], "a shield block behind the body drew over the holder") + TEST_ASSERT_NULL(coat, "a shield block behind the body coated the shield in view") + else + var/obj/effect/temp_visual/plasma_shield_splash/high = parts["h_[key]"] + TEST_ASSERT_NOTNULL(high, "a shield block in view drew nothing over the holder") + TEST_ASSERT(high.layer > victim.layer, "a shield block in view drew under the holder") + TEST_ASSERT_NOTNULL(coat, "a shield block in view left no molten coat on the shield") + TEST_ASSERT_EQUAL(coat.icon_state, "c_[key]", "the molten coat was baked for another pose") + // Turning away takes the coat with the pose it was drawn for + victim.setDir(NORTH) + TEST_ASSERT(QDELETED(coat), "the molten coat stayed on after the holder turned") + + // Every baked case has its anchor, slag and, in view, its coat + for(var/baked_key in GLOB.plasma_shield_anchors) + var/list/baked = GLOB.plasma_shield_anchors[baked_key] + TEST_ASSERT(icon_exists('voidcrew/modules/plasma_weapons/icons/plasma_shield.dmi', "l_[baked_key]"), "no slag state for [baked_key]") + if(!baked[3]) + TEST_ASSERT(icon_exists('voidcrew/modules/plasma_weapons/icons/plasma_shield.dmi', "h_[baked_key]"), "no splash state for [baked_key]") + TEST_ASSERT(icon_exists('voidcrew/modules/plasma_weapons/icons/plasma_shield_coat.dmi', "c_[baked_key]"), "no coat state for [baked_key]") + +/// Ordinary person hits alternate the splatter and the flash; a killing hit burns through. +/datum/unit_test/voidcrew_plasma_bolt/burst_pick + +/datum/unit_test/voidcrew_plasma_bolt/burst_pick/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + var/last + for(var/i in 1 to 6) + var/look = plasma_burst_pick(victim) + TEST_ASSERT(look == "A" || look == "B", "an ordinary burst picked [look]") + TEST_ASSERT_NOTEQUAL(look, last, "the same burst played twice in a row") + last = look + + var/mob/living/carbon/human/consistent/hit = allocate(/mob/living/carbon/human/consistent, turf_at(2, 2)) + hit.bullet_act(make_bolt(/obj/projectile/energy/plasma_bolt, turf_at(1, 2)), BODY_ZONE_CHEST) + var/obj/effect/temp_visual/plasma_burst/burst = locate() in get_turf(hit) + TEST_ASSERT_NOTNULL(burst, "a bolt hitting a person made no burst") + TEST_ASSERT(burst.drawn, "the burst's look was not picked in the tick of the hit") + TEST_ASSERT(burst.look == "A" || burst.look == "B", "an ordinary hit picked [burst.look]") + // It shows a tick later, once the bolt's sprite has arrived; show it now + burst.show(WEAKREF(hit)) + TEST_ASSERT(findtext(burst.icon_state, "sp_[burst.look]") == 1, "an ordinary hit drew [burst.icon_state]") + TEST_ASSERT_NOTNULL(locate(/obj/effect/temp_visual/plasma_burst_bloom) in get_turf(hit), "a burst had no bloom") + // The light is on the body, not on the spray's canvas offset + var/obj/effect/temp_visual/plasma_burst_light/glow = locate() in get_turf(hit) + TEST_ASSERT_NOTNULL(glow, "a burst had no light") + TEST_ASSERT(glow.light_on, "a burst's light was not on") + TEST_ASSERT(abs(glow.pixel_w) <= ICON_SIZE_X / 2 && abs(glow.pixel_z) <= ICON_SIZE_Y / 2, "a burst's light sat off the body at [glow.pixel_w],[glow.pixel_z]") + var/obj/effect/temp_visual/plasma_burst_body/stuck = locate() in hit.vis_contents + TEST_ASSERT_NOTNULL(stuck, "nothing stuck to a body hit from the front") + TEST_ASSERT(stuck.vis_flags & VIS_INHERIT_DIR, "what sticks to the body does not turn with it") + + var/mob/living/carbon/human/consistent/dying = allocate(/mob/living/carbon/human/consistent, turf_at(2, 3)) + dying.adjustOxyLoss(190) + TEST_ASSERT(dying.stat != DEAD, "the dying test body was already dead") + dying.bullet_act(make_bolt(/obj/projectile/energy/plasma_bolt/heavy, turf_at(1, 3)), BODY_ZONE_CHEST) + TEST_ASSERT_EQUAL(dying.stat, DEAD, "a rifle bolt did not kill a body at -90 health") + burst = locate() in get_turf(dying) + TEST_ASSERT_EQUAL(burst?.look, "C", "a killing hit did not pick the burn-through") + +/// What sticks to a body is named by the side of the body it is on, the same whichever way the body faces +/datum/unit_test/voidcrew_plasma_burst_body_side + +/datum/unit_test/voidcrew_plasma_burst_body_side/Run() + TEST_ASSERT_EQUAL(plasma_burst_body_side(SOUTH, SOUTH), SOUTH, "the front of a body facing south is not its front") + TEST_ASSERT_EQUAL(plasma_burst_body_side(EAST, EAST), SOUTH, "the front of a body facing east is not its front") + TEST_ASSERT_EQUAL(plasma_burst_body_side(SOUTH, NORTH), NORTH, "the back of a body facing north is not its back") + TEST_ASSERT_EQUAL(plasma_burst_body_side(WEST, NORTH), EAST, "the left side of a body facing north is not its left") + TEST_ASSERT_EQUAL(plasma_burst_body_side(NORTH, WEST), WEST, "the right side of a body facing west is not its right") + +/// A bolt that hits a wall leaves one mark, its glow on the wall face, and no soot on the floor in front of it +/datum/unit_test/voidcrew_plasma_wall_one_mark + +/datum/unit_test/voidcrew_plasma_wall_one_mark/Run() + var/turf/open/in_front = run_loc_floor_bottom_left + var/turf/wall = get_step(in_front, EAST) + var/old_type = wall.type + wall = wall.ChangeTurf(/turf/closed/wall) + var/obj/projectile/energy/plasma_bolt/bolt = allocate(/obj/projectile/energy/plasma_bolt, in_front) + bolt.angle = 90 + // the old floor soot was a coin flip per hit + for(var/i in 1 to 10) + bolt.plasma_impact_visuals(wall, 0) + var/soot = locate(/obj/effect/decal/cleanable/plasma_soot) in in_front + var/glow = locate(/obj/effect/temp_visual/plasma_wall_glow) in wall + for(var/atom/movable/thing in wall) + if(istype(thing, /obj/effect/temp_visual)) + qdel(thing) + for(var/atom/movable/thing in in_front) + if(istype(thing, /obj/effect/temp_visual) || istype(thing, /obj/effect/decal/cleanable/plasma_soot)) + qdel(thing) + wall.ChangeTurf(old_type) + TEST_ASSERT_NULL(soot, "A wall hit left soot on the floor in front of the wall") + TEST_ASSERT_NOTNULL(glow, "A wall hit left no glow on the wall") diff --git a/voidcrew/modules/unit_tests/voidcrew_plasma_flask.dm b/voidcrew/modules/unit_tests/voidcrew_plasma_flask.dm new file mode 100644 index 00000000000..aca860128e9 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_plasma_flask.dm @@ -0,0 +1,153 @@ +// Package: P2 guns and flask. Unit tests for voidcrew/modules/plasma_weapons/. + +/// Plasma a gun and its seated or loose flask hold together +/proc/plasma_test_total(obj/item/gun/energy/plasma_weapon/gun, obj/item/plasma_flask/flask) + return gun.chamber_mol() + (flask ? flask.stored : 0) + +/// Spawn fills: shop guns and the Old Furnace come full, loot guns at most half full +/datum/unit_test/voidcrew_plasma_spawn_fill/Run() + for(var/gun_type in list(/obj/item/gun/energy/plasma_weapon/pistol, /obj/item/gun/energy/plasma_weapon/rifle, /obj/item/gun/energy/plasma_weapon/rifle/old_furnace)) + var/obj/item/gun/energy/plasma_weapon/gun = allocate(gun_type) + TEST_ASSERT_NOTNULL(gun.flask, "[gun_type] spawned without a flask") + TEST_ASSERT_EQUAL(gun.chamber_mol(), PLASMA_FLASK_CAPACITY, "[gun_type] did not spawn full") + TEST_ASSERT_EQUAL(gun.flask.stored, 0, "[gun_type]'s seated flask still holds plasma") + for(var/gun_type in list(/obj/item/gun/energy/plasma_weapon/pistol/loot, /obj/item/gun/energy/plasma_weapon/rifle/loot)) + var/obj/item/gun/energy/plasma_weapon/gun = allocate(gun_type) + TEST_ASSERT_NOTNULL(gun.flask, "[gun_type] spawned without a flask") + TEST_ASSERT(gun.chamber_mol() >= 0 && gun.chamber_mol() <= PLASMA_FLASK_CAPACITY / 2, "[gun_type] spawned with [gun.chamber_mol()] mol") + +/// Loading and unloading 100 times never changes the plasma a gun and its flask hold +/datum/unit_test/voidcrew_plasma_flask_invariant/Run() + var/obj/item/gun/energy/plasma_weapon/rifle/gun = allocate(/obj/item/gun/energy/plasma_weapon/rifle) + gun.take_mol(17) + var/obj/item/plasma_flask/flask = gun.flask + var/total = plasma_test_total(gun, null) + TEST_ASSERT_EQUAL(total, PLASMA_FLASK_CAPACITY - 17, "take_mol took the wrong amount") + for(var/i in 1 to 100) + TEST_ASSERT_EQUAL(gun.unload_flask(null), flask, "unload_flask didn't return the flask") + TEST_ASSERT_NULL(gun.flask, "flask still seated after unloading") + TEST_ASSERT_EQUAL(gun.chamber_mol(), 0, "chamber kept plasma after unloading") + TEST_ASSERT_EQUAL(plasma_test_total(gun, flask), total, "plasma changed on unload [i]") + TEST_ASSERT(gun.load_flask(flask, null), "load_flask failed on loop [i]") + TEST_ASSERT_EQUAL(plasma_test_total(gun, null), total, "plasma changed on load [i]") + // Loading a second flask pushes the first one out with its plasma + var/obj/item/plasma_flask/second = allocate(/obj/item/plasma_flask) + second.stored = 20 + TEST_ASSERT(gun.load_flask(second, null), "couldn't swap flasks") + TEST_ASSERT_EQUAL(gun.flask, second, "second flask not seated") + TEST_ASSERT_EQUAL(flask.stored, total, "first flask lost plasma on the swap") + TEST_ASSERT_EQUAL(gun.chamber_mol(), 20, "chamber didn't take the second flask's plasma") + +/// A seated flask that leaves any other way still takes the chamber's plasma with it +/datum/unit_test/voidcrew_plasma_flask_writeback/Run() + var/obj/item/gun/energy/plasma_weapon/pistol/gun = allocate(/obj/item/gun/energy/plasma_weapon/pistol) + gun.take_mol(12) + var/obj/item/plasma_flask/flask = gun.flask + var/turf/elsewhere = locate(run_loc_floor_bottom_left.x + 1, run_loc_floor_bottom_left.y, run_loc_floor_bottom_left.z) + flask.forceMove(elsewhere) + TEST_ASSERT_NULL(gun.flask, "gun still thinks the flask is seated") + TEST_ASSERT_EQUAL(gun.chamber_mol(), 0, "chamber kept plasma after the flask left") + TEST_ASSERT_EQUAL(flask.stored, PLASMA_FLASK_CAPACITY - 12, "flask didn't get the chamber's plasma") + TEST_ASSERT(!gun.can_shoot(), "gun can still fire with no flask") + + // Destroying the gun drops the flask with its plasma + TEST_ASSERT(gun.load_flask(flask, null), "couldn't reseat the flask") + gun.take_mol(4) + qdel(gun) + TEST_ASSERT(isturf(flask.loc), "flask didn't drop when the gun was destroyed") + TEST_ASSERT_EQUAL(flask.stored, PLASMA_FLASK_CAPACITY - 16, "flask lost plasma when the gun was destroyed") + qdel(flask) + +/// Only plasma sheets and ore go in, a whole unit at a time, and only with room for all of it +/datum/unit_test/voidcrew_plasma_flask_feeding/Run() + var/obj/item/plasma_flask/flask = allocate(/obj/item/plasma_flask) + var/obj/item/stack/sheet/mineral/plasma/sheets = allocate(/obj/item/stack/sheet/mineral/plasma, null, 5) + var/obj/item/stack/ore/plasma/ore = allocate(/obj/item/stack/ore/plasma, null, 5) + + flask.stored = 59 + TEST_ASSERT_EQUAL(flask.feed(sheets, null), 0, "a sheet went into a flask with 1 mol of room") + TEST_ASSERT_EQUAL(sheets.get_amount(), 5, "a refused sheet was used up") + TEST_ASSERT_EQUAL(flask.stored, 59, "a refused sheet changed the flask") + + flask.stored = 50 + TEST_ASSERT_EQUAL(flask.feed(ore, null), plasma_fuel_mol_per_unit(ore), "ore didn't fill the last 10 mol") + TEST_ASSERT_EQUAL(flask.stored, PLASMA_FLASK_CAPACITY, "flask not full after the ore") + TEST_ASSERT_EQUAL(ore.get_amount(), 4, "feeding took the wrong amount of ore") + + flask.stored = 0 + TEST_ASSERT_EQUAL(flask.feed(sheets, null), PLASMA_FLASK_CAPACITY, "three sheets didn't fill an empty flask") + TEST_ASSERT_EQUAL(sheets.get_amount(), 2, "feeding took the wrong number of sheets") + + // Nothing else goes in: liquid plasma, glass, other flasks + flask.stored = 0 + var/obj/item/reagent_containers/cup/beaker/beaker = allocate(/obj/item/reagent_containers/cup/beaker) + beaker.reagents.add_reagent(/datum/reagent/toxin/plasma, 50) + flask.item_interaction(null, beaker) + TEST_ASSERT_EQUAL(flask.stored, 0, "liquid plasma went into a flask") + TEST_ASSERT_EQUAL(beaker.reagents.total_volume, 50, "the beaker lost plasma to a flask") + var/obj/item/stack/sheet/glass/glass = allocate(/obj/item/stack/sheet/glass, null, 5) + TEST_ASSERT_EQUAL(flask.feed(glass, null), 0, "glass went into a flask") + var/obj/item/plasma_flask/other = allocate(/obj/item/plasma_flask) + other.stored = 40 + flask.item_interaction(null, other) + TEST_ASSERT_EQUAL(flask.stored, 0, "a flask poured into another flask") + TEST_ASSERT_EQUAL(other.stored, 40, "a flask lost plasma to another flask") + + // Feeding a gun goes straight into its chamber, and needs a seated flask + var/obj/item/gun/energy/plasma_weapon/pistol/gun = allocate(/obj/item/gun/energy/plasma_weapon/pistol) + gun.take_mol(30) + TEST_ASSERT_EQUAL(gun.feed_plasma(sheets, null), plasma_fuel_mol_per_unit(sheets), "a gun with 30 mol of room took the wrong amount") + TEST_ASSERT_EQUAL(gun.chamber_mol(), 50, "feeding a gun didn't reach its chamber") + TEST_ASSERT_EQUAL(gun.feed_plasma(sheets, null), 0, "a sheet went into a gun with 10 mol of room") + var/obj/item/plasma_flask/taken = gun.unload_flask(null) + TEST_ASSERT_EQUAL(gun.feed_plasma(sheets, null), 0, "a gun with no flask took plasma") + TEST_ASSERT_EQUAL(taken.stored, 50, "unloaded flask has the wrong fill") + qdel(taken) + +/// Nothing charges the chamber except plasma +/datum/unit_test/voidcrew_plasma_chamber_sealed/Run() + var/obj/item/gun/energy/plasma_weapon/rifle/gun = allocate(/obj/item/gun/energy/plasma_weapon/rifle) + gun.take_mol(40) + var/obj/item/stock_parts/power_store/cell/plasma_chamber/chamber = gun.cell + TEST_ASSERT(istype(chamber), "the rifle's cell isn't a plasma chamber") + TEST_ASSERT_EQUAL(chamber.give(1000), 0, "give() reported charge") + TEST_ASSERT_EQUAL(chamber.change(1000), 0, "change() reported charge") + chamber.on_magic_charge(chamber, null, null) + SEND_SIGNAL(gun, COMSIG_ITEM_RECHARGED) + chamber.corrupt(TRUE) + chamber.explode() + TEST_ASSERT_EQUAL(gun.chamber_mol(), 20, "the chamber gained or lost plasma without feeding") + TEST_ASSERT_EQUAL(chamber.maxcharge, PLASMA_FLASK_CAPACITY, "corrupt() shrank the chamber") + TEST_ASSERT(!chamber.rigged, "the chamber got rigged") + TEST_ASSERT_NULL(chamber.reagents, "the chamber has a reagent holder") + TEST_ASSERT(!QDELETED(chamber), "the chamber exploded") + + // Code that tops up every cell a mob carries must not find the chamber + var/mob/living/carbon/human/consistent/holder = allocate(/mob/living/carbon/human/consistent) + holder.put_in_hands(gun) + TEST_ASSERT(!(chamber in holder.get_all_contents()), "the chamber is reachable through its holder's contents") + TEST_ASSERT_EQUAL(gun.cell, chamber, "the gun lost its chamber") + + // No recharger + TEST_ASSERT(!gun.can_charge, "plasma guns can be recharged") + var/obj/machinery/recharger/recharger = allocate(/obj/machinery/recharger) + recharger.attackby(gun, holder) + TEST_ASSERT(gun.loc != recharger, "a recharger took a plasma gun") + + // EMP leaves the plasma alone + gun.emp_act(EMP_HEAVY) + chamber.emp_act(EMP_HEAVY) + TEST_ASSERT_EQUAL(gun.chamber_mol(), 20, "an EMP drained the chamber") + + // No turret or emitter fires plasma bolts off the power grid + for(var/obj/item/gun/energy/plasma_weapon/gun_type as anything in subtypesof(/obj/item/gun/energy/plasma_weapon)) + TEST_ASSERT(initial(gun_type.gun_flags) & TURRET_INCOMPATIBLE, "[gun_type] can be built into a turret") + +/// The world sprite shows empty once the chamber holds less than one shot +/datum/unit_test/voidcrew_plasma_empty_sprite/Run() + var/obj/item/gun/energy/plasma_weapon/pistol/gun = allocate(/obj/item/gun/energy/plasma_weapon/pistol) + var/obj/item/ammo_casing/energy/shot = gun.ammo_type[gun.select] + TEST_ASSERT_EQUAL(gun.icon_state, "cinder", "a full Cinder shows the wrong state") + gun.take_mol(gun.chamber_mol() - shot.e_cost + 1) + TEST_ASSERT_EQUAL(gun.icon_state, "cinder_empty", "a Cinder short of one shot isn't shown empty") + TEST_ASSERT(!gun.can_shoot(), "a Cinder short of one shot can fire") diff --git a/voidcrew/modules/unit_tests/voidcrew_plasma_heat.dm b/voidcrew/modules/unit_tests/voidcrew_plasma_heat.dm new file mode 100644 index 00000000000..a4d32e911f4 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_plasma_heat.dm @@ -0,0 +1,155 @@ +// Package: P3 heat. Unit tests for voidcrew/modules/plasma_weapons/. +// Shots are simulated with add_heat(heat_per_shot) and 0.7 s of process() between them, the pace the +// balance sim fires at. A boil shows as lost integrity, so these pass with or without P2's take_mol. + +/// Fires `shots` simulated rifle shots, cooling 0.7 s after each. Returns the shot number of the first boil, or 0. +/proc/plasma_heat_test_burst(obj/item/gun/energy/plasma_weapon/gun, shots) + for(var/shot in 1 to shots) + var/integrity_before = gun.get_integrity() + gun.add_heat(gun.heat_per_shot) + if(gun.get_integrity() < integrity_before) + return shot + gun.process(0.7) + return 0 + +/// Plasma mol in `air` +/proc/plasma_heat_test_plasma(datum/gas_mixture/air) + var/list/plasma = air.gases[/datum/gas/plasma] + return plasma ? plasma[MOLES] : 0 + +/// A full flask fired flat out in room air never boils. +/datum/unit_test/voidcrew_plasma_heat_air + +/datum/unit_test/voidcrew_plasma_heat_air/Run() + var/turf/open/spot = run_loc_floor_bottom_left + var/datum/gas_mixture/air = spot.return_air() + TEST_ASSERT(air.return_pressure() > 90, "The test floor has no room air ([air.return_pressure()] kPa)") + var/obj/item/gun/energy/plasma_weapon/rifle/gun = allocate(/obj/item/gun/energy/plasma_weapon/rifle, spot) + TEST_ASSERT_EQUAL(plasma_heat_test_burst(gun, 12), 0, "A Furnace boiled within 12 shots in room air") + TEST_ASSERT(gun.barrel_heat > 0 && gun.barrel_heat < PLASMA_HEAT_BOIL, "A Furnace ended 12 shots at heat [gun.barrel_heat]") + TEST_ASSERT_EQUAL(gun.heat_step, 2, "A Furnace at heat [gun.barrel_heat] shows coil step [gun.heat_step]") + +/// In vacuum the rifle only cools by its base rate and boils on the 9th shot. +/datum/unit_test/voidcrew_plasma_heat_vacuum + var/turf/open/spot + var/datum/gas_mixture/saved_air + +/datum/unit_test/voidcrew_plasma_heat_vacuum/Run() + spot = run_loc_floor_bottom_left + var/datum/gas_mixture/air = spot.return_air() + saved_air = air.copy() + air.remove_ratio(1) + TEST_ASSERT(air.return_pressure() < 1, "Emptying the test floor's air left [air.return_pressure()] kPa") + var/obj/item/gun/energy/plasma_weapon/rifle/gun = allocate(/obj/item/gun/energy/plasma_weapon/rifle, spot) + gun.cell.charge = 0 // a boil with plasma would leave hot gas on the tile and change the cooling + // 13.5 + 11.75 per shot after the first: 95.75 after the 8th, 107.5 after the 9th + TEST_ASSERT_EQUAL(plasma_heat_test_burst(gun, 12), 9, "A Furnace in vacuum did not first boil on the 9th shot") + TEST_ASSERT_EQUAL(gun.get_integrity(), gun.max_integrity - PLASMA_BOIL_INTEGRITY, "One boil did not cost [PLASMA_BOIL_INTEGRITY] integrity") + TEST_ASSERT(gun.barrel_heat < PLASMA_HEAT_BOIL, "A boil left the Furnace at heat [gun.barrel_heat]") + + // The Old Furnace runs cooler: 10 + 8.25 per shot after the first, so 10 shots stay under the boil. + air.remove_ratio(1) + var/obj/item/gun/energy/plasma_weapon/rifle/old_furnace/old = allocate(/obj/item/gun/energy/plasma_weapon/rifle/old_furnace, spot) + old.cell.charge = 0 + TEST_ASSERT_EQUAL(plasma_heat_test_burst(old, 10), 0, "The Old Furnace boiled within 10 shots in vacuum") + + // The pistol has no heat at all. + var/obj/item/gun/energy/plasma_weapon/pistol/pistol = allocate(/obj/item/gun/energy/plasma_weapon/pistol, spot) + pistol.add_heat(PLASMA_HEAT_BOIL * 2) + TEST_ASSERT_EQUAL(pistol.barrel_heat, 0, "The Cinder gained heat") + TEST_ASSERT(!(pistol.datum_flags & DF_ISPROCESSING), "The Cinder started processing") + +/datum/unit_test/voidcrew_plasma_heat_vacuum/Destroy() + if(spot && saved_air) + spot.return_air().copy_from(saved_air) + return ..() + +/// Every mole a vent puts on the turf left the chamber, a vent locks firing, and the cooldown holds. +/datum/unit_test/voidcrew_plasma_heat_vent + var/turf/open/spot + var/datum/gas_mixture/saved_air + +/datum/unit_test/voidcrew_plasma_heat_vent/Run() + spot = run_loc_floor_bottom_left + var/datum/gas_mixture/air = spot.return_air() + saved_air = air.copy() + air.remove_ratio(1) // vacuum, so the hot puff can't burn and eat the plasma we count + var/mob/living/carbon/human/consistent/user = allocate(/mob/living/carbon/human/consistent, spot) + var/obj/item/gun/energy/plasma_weapon/rifle/gun = allocate(/obj/item/gun/energy/plasma_weapon/rifle, spot) + + // A full chamber + gun.cell.charge = PLASMA_FLASK_CAPACITY + gun.barrel_heat = 80 + var/chamber_before = gun.chamber_mol() + var/turf_before = plasma_heat_test_plasma(air) + gun.attack_self(user) + var/lost = chamber_before - gun.chamber_mol() + var/gained = plasma_heat_test_plasma(air) - turf_before + TEST_ASSERT_EQUAL(gained, lost, "A vent put [gained] mol on the turf but took [lost] mol from the chamber") + TEST_ASSERT(lost == PLASMA_VENT_MOL, "A vent from a full chamber took [lost] mol") + if(lost) + TEST_ASSERT_EQUAL(gun.barrel_heat, 80 - PLASMA_VENT_COOL, "A vent did not cool the barrel by [PLASMA_VENT_COOL]") + TEST_ASSERT(!gun.can_fire_heat(), "The Furnace can fire straight after a vent") + TEST_ASSERT(!gun.can_shoot(), "can_shoot ignores the vent lock") + var/chamber_mid = gun.chamber_mol() + gun.attack_self(user) + TEST_ASSERT_EQUAL(gun.chamber_mol(), chamber_mid, "A second vent inside the cooldown took plasma") + gun.vent_lock_until = world.time + TEST_ASSERT(gun.can_fire_heat(), "The vent lock outlived vent_lock_until") + + // A chamber short of a full vent gives up what it has, no more, and the turf gets exactly that. + gun.next_vent_at = 0 + gun.cell.charge = 3 + chamber_before = gun.chamber_mol() + turf_before = plasma_heat_test_plasma(air) + gun.attack_self(user) + lost = chamber_before - gun.chamber_mol() + gained = plasma_heat_test_plasma(air) - turf_before + TEST_ASSERT_EQUAL(gained, lost, "A short vent put [gained] mol on the turf but took [lost] mol from the chamber") + TEST_ASSERT(lost >= 0 && lost <= 3, "A vent from a 3 mol chamber took [lost] mol") + + // A boil conserves the same way. + gun.cell.charge = PLASMA_FLASK_CAPACITY + chamber_before = gun.chamber_mol() + turf_before = plasma_heat_test_plasma(air) + gun.barrel_heat = PLASMA_HEAT_BOIL + gun.add_heat(0) + lost = chamber_before - gun.chamber_mol() + gained = plasma_heat_test_plasma(air) - turf_before + TEST_ASSERT_EQUAL(gained, lost, "A boil put [gained] mol on the turf but took [lost] mol from the chamber") + TEST_ASSERT(lost == PLASMA_BOIL_MOL, "A boil from a full chamber took [lost] mol") + +/datum/unit_test/voidcrew_plasma_heat_vent/Destroy() + if(spot && saved_air) + spot.return_air().copy_from(saved_air) + return ..() + +/// A hot gun processes on SSobj and stops once it has cooled. +/datum/unit_test/voidcrew_plasma_heat_processing + +/datum/unit_test/voidcrew_plasma_heat_processing/Run() + var/obj/item/gun/energy/plasma_weapon/rifle/gun = allocate(/obj/item/gun/energy/plasma_weapon/rifle, run_loc_floor_bottom_left) + TEST_ASSERT(!(gun.datum_flags & DF_ISPROCESSING), "A cold Furnace is processing") + gun.add_heat(gun.heat_per_shot) + TEST_ASSERT(gun.datum_flags & DF_ISPROCESSING, "A hot Furnace did not start processing") + gun.process(100) + TEST_ASSERT_EQUAL(gun.barrel_heat, 0, "A Furnace did not cool in 100 s of room air") + TEST_ASSERT(!(gun.datum_flags & DF_ISPROCESSING), "A cooled Furnace kept processing") + TEST_ASSERT_EQUAL(gun.heat_step, 0, "A cooled Furnace still shows coil step [gun.heat_step]") + +/// Twenty boils break the gun, chamber empty or not. +/datum/unit_test/voidcrew_plasma_heat_breaks + +/datum/unit_test/voidcrew_plasma_heat_breaks/Run() + var/obj/item/gun/energy/plasma_weapon/rifle/gun = allocate(/obj/item/gun/energy/plasma_weapon/rifle, run_loc_floor_bottom_left) + gun.cell.charge = 0 // nothing to vent, so no hot plasma lights the test room + var/boils = gun.max_integrity / PLASMA_BOIL_INTEGRITY + TEST_ASSERT_EQUAL(boils, 20, "A Furnace takes [boils] boils to break, not 20") + for(var/boil in 1 to boils - 1) + gun.barrel_heat = PLASMA_HEAT_BOIL + gun.add_heat(0) + TEST_ASSERT(!QDELETED(gun), "A Furnace broke before its 20th boil") + TEST_ASSERT_EQUAL(gun.get_integrity(), PLASMA_BOIL_INTEGRITY, "19 boils left the Furnace at [gun.get_integrity()] integrity") + gun.barrel_heat = PLASMA_HEAT_BOIL + gun.add_heat(0) + TEST_ASSERT(QDELETED(gun), "A Furnace survived 20 boils") diff --git a/voidcrew/modules/unit_tests/voidcrew_plasma_melt.dm b/voidcrew/modules/unit_tests/voidcrew_plasma_melt.dm new file mode 100644 index 00000000000..e4acc890407 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_plasma_melt.dm @@ -0,0 +1,214 @@ +// Package: P1 bolt. Unit tests for the plasma melt-off (voidcrew/modules/plasma_weapons/plasma_melt.dm). + +/// Counts things of `type` on `where` +/proc/voidcrew_plasma_melt_count(turf/where, type) + . = 0 + for(var/atom/thing as anything in where) + if(istype(thing, type)) + .++ + +/// Whether `victim` draws any overlay from the melt icon +/proc/voidcrew_plasma_melt_drawn(mob/living/carbon/victim) + for(var/mutable_appearance/overlay as anything in victim.overlays) + if(findtext("[overlay.icon]", "plasma_melt")) + return TRUE + return FALSE + +/// A melted limb leaves no tg ash and no blood splatter, but the body still loses tg's blood +/datum/unit_test/voidcrew_plasma_melt_remains + +/datum/unit_test/voidcrew_plasma_melt_remains/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent) + var/turf/where = get_turf(victim) + var/blood_before = victim.blood_volume + TEST_ASSERT(plasma_melt_limb(victim.get_bodypart(BODY_ZONE_R_LEG), SOUTH), "The leg did not melt off") + TEST_ASSERT_NULL(victim.get_bodypart(BODY_ZONE_R_LEG), "The melted leg is still attached") + TEST_ASSERT_EQUAL(voidcrew_plasma_melt_count(where, /obj/effect/decal/cleanable/ash), 0, "tg's ash pile was left under the melt") + TEST_ASSERT_EQUAL(voidcrew_plasma_melt_count(where, /obj/effect/decal/cleanable/blood), 0, "The melt left a blood splatter") + TEST_ASSERT(victim.blood_volume <= blood_before - 20, "The melt did not take tg's dismember blood loss ([blood_before] -> [victim.blood_volume])") + TEST_ASSERT(voidcrew_plasma_melt_drawn(victim), "Nothing of the melt is drawn on the body") + + // the splatter guard is only for the body mid-melt, and only stops the splatter + GLOB.plasma_melt_no_splatter = WEAKREF(victim) + victim.add_splatter_floor(where) + GLOB.plasma_melt_no_splatter = null + TEST_ASSERT_EQUAL(voidcrew_plasma_melt_count(where, /obj/effect/decal/cleanable/blood), 0, "The guard let a splatter through") + victim.add_splatter_floor(where) + TEST_ASSERT(voidcrew_plasma_melt_count(where, /obj/effect/decal/cleanable/blood) > 0, "Splatter outside a melt was blocked") + + for(var/zone in victim.plasma_melts) + qdel(victim.plasma_melts[zone]) + for(var/obj/effect/thing in where) + if(istype(thing, /obj/effect/decal/cleanable) || istype(thing, /obj/effect/plasma_melt_pool) || istype(thing, /obj/effect/plasma_melt_light)) + qdel(thing) + +/// Slag is one decal per tile and capped globally, oldest first; it can be cleaned away +/datum/unit_test/voidcrew_plasma_slag_caps + +/datum/unit_test/voidcrew_plasma_slag_caps/Run() + var/list/start_slag = GLOB.plasma_slag_decals.Copy() + var/turf/open/first_turf = run_loc_floor_bottom_left + var/turf/open/second_turf = get_step(first_turf, EAST) + var/turf/open/third_turf = get_step(second_turf, EAST) + TEST_ASSERT(isopenturf(first_turf) && isopenturf(second_turf) && isopenturf(third_turf), "The test room has no three open floor tiles in a row") + var/cap = length(start_slag) + 2 + + var/obj/effect/decal/cleanable/plasma_slag/first = plasma_slag_at(first_turf, cap = cap) + TEST_ASSERT_NOTNULL(first, "No slag was made on an open floor") + TEST_ASSERT_EQUAL(plasma_slag_at(first_turf, cap = cap), first, "A second melt on the same tile made a second slag decal") + var/obj/effect/decal/cleanable/plasma_slag/stray = new(first_turf) + TEST_ASSERT(QDELETED(stray), "A second slag decal on one tile did not merge into the first") + TEST_ASSERT_EQUAL(voidcrew_plasma_melt_count(first_turf, /obj/effect/decal/cleanable/plasma_slag), 1, "A tile holds more than one slag decal") + + var/obj/effect/decal/cleanable/plasma_slag/second = plasma_slag_at(second_turf, cap = cap) + var/obj/effect/decal/cleanable/plasma_slag/third = plasma_slag_at(third_turf, cap = cap) + TEST_ASSERT(QDELETED(first), "Slag over the cap did not drop the oldest decal") + TEST_ASSERT(!QDELETED(second) && !QDELETED(third), "Slag over the cap dropped a newer decal") + TEST_ASSERT_EQUAL(length(GLOB.plasma_slag_decals), cap, "The global slag list grew past its cap") + + second_turf.wash(CLEAN_SCRUB) + TEST_ASSERT(QDELETED(second), "Scrubbing the floor left the slag") + TEST_ASSERT(!(second in GLOB.plasma_slag_decals), "Cleaned slag stayed in the global list") + qdel(third) + TEST_ASSERT_EQUAL(length(GLOB.plasma_slag_decals), length(start_slag), "The global slag list kept test decals") + +/// A melted arm burns its jumpsuit sleeve away; a melted leg doesn't touch the uniform. Putting the arm back +/// brings the sleeve back and takes the stump off the body. +/datum/unit_test/voidcrew_plasma_melt_sleeve + +/datum/unit_test/voidcrew_plasma_melt_sleeve/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent) + victim.equipOutfit(/datum/outfit/job/assistant/consistent) + var/mutable_appearance/uniform = victim.overlays_standing[UNIFORM_LAYER] + TEST_ASSERT_NOTNULL(uniform, "The test body has no jumpsuit drawn") + var/filters_before = length(uniform.filters) + + TEST_ASSERT(plasma_melt_limb(victim.get_bodypart(BODY_ZONE_L_LEG), SOUTH), "The leg did not melt off") + var/datum/plasma_melt/leg_melt = victim.plasma_melts[BODY_ZONE_L_LEG] + leg_melt.pose_melt() + TEST_ASSERT(!leg_melt.sleeve_burned, "A melted leg burned a sleeve") + uniform = victim.overlays_standing[UNIFORM_LAYER] + TEST_ASSERT_EQUAL(length(uniform.filters), filters_before, "A melted leg masked the uniform") + + TEST_ASSERT(plasma_melt_limb(victim.get_bodypart(BODY_ZONE_R_ARM), SOUTH), "The arm did not melt off") + var/datum/plasma_melt/arm_melt = victim.plasma_melts[BODY_ZONE_R_ARM] + TEST_ASSERT_NOTNULL(arm_melt, "The melted arm has no melt on the body") + uniform = victim.overlays_standing[UNIFORM_LAYER] + TEST_ASSERT_EQUAL(length(uniform.filters), filters_before, "The sleeve burned before the arm started melting") + arm_melt.pose_melt() + TEST_ASSERT(arm_melt.sleeve_burned, "The melting arm did not burn its sleeve") + uniform = victim.overlays_standing[UNIFORM_LAYER] + TEST_ASSERT_EQUAL(length(uniform.filters), filters_before + 1, "The burned sleeve is not masked off the uniform") + victim.update_worn_undersuit() + uniform = victim.overlays_standing[UNIFORM_LAYER] + TEST_ASSERT_EQUAL(length(uniform.filters), filters_before + 1, "Redrawing the jumpsuit lost or doubled the sleeve mask") + + arm_melt.pose_stump("stump", FALSE) + TEST_ASSERT(length(arm_melt.shown), "The cooled stump is not drawn") + victim.regenerate_limb(BODY_ZONE_R_ARM) + TEST_ASSERT_NOTNULL(victim.get_bodypart(BODY_ZONE_R_ARM), "The arm did not regrow") + TEST_ASSERT(QDELETED(arm_melt), "The arm's melt outlived the regrown arm") + TEST_ASSERT_NULL(LAZYACCESS(victim.plasma_melts, BODY_ZONE_R_ARM), "The body still tracks a melt on the regrown arm") + uniform = victim.overlays_standing[UNIFORM_LAYER] + TEST_ASSERT_EQUAL(length(uniform.filters), filters_before, "The sleeve mask stayed after the arm came back") + + victim.regenerate_limb(BODY_ZONE_L_LEG) + TEST_ASSERT(QDELETED(leg_melt), "The leg's melt outlived the regrown leg") + TEST_ASSERT(!voidcrew_plasma_melt_drawn(victim), "A stump stayed on the body after both limbs came back") + var/turf/where = get_turf(victim) + for(var/obj/effect/thing in where) + if(istype(thing, /obj/effect/decal/cleanable) || istype(thing, /obj/effect/plasma_melt_pool) || istype(thing, /obj/effect/plasma_melt_light) || istype(thing, /obj/effect/temp_visual)) + qdel(thing) + +/// The pool stays on the tile where the melt started; melt that runs off a body that moved on lands under it as a +/// short-lived drip, not as a second pool or slag +/datum/unit_test/voidcrew_plasma_melt_moving + +/datum/unit_test/voidcrew_plasma_melt_moving/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent) + var/turf/start = get_turf(victim) + var/turf/next = get_step(start, EAST) + TEST_ASSERT(isopenturf(next), "The test room has no open floor east of the victim") + TEST_ASSERT(plasma_melt_limb(victim.get_bodypart(BODY_ZONE_L_LEG), SOUTH), "The leg did not melt off") + var/datum/plasma_melt/melt = victim.plasma_melts[BODY_ZONE_L_LEG] + melt.pose_melt() + + victim.forceMove(next) + melt.last_moved = world.time - 1 SECONDS // the body has finished sliding onto the next tile + melt.grow_pool(2) + TEST_ASSERT_NULL(melt.pool, "A pool started under a body that had moved off the melt's tile") + TEST_ASSERT(voidcrew_plasma_melt_count(next, /obj/effect/temp_visual/plasma_melt_drip_spot) > 0, "Melt running off a moved body left no drip under it") + TEST_ASSERT_EQUAL(voidcrew_plasma_melt_count(next, /obj/effect/decal/cleanable/plasma_slag), 0, "A drip made lasting slag") + TEST_ASSERT(melt.limb_light && get_turf(melt.limb_light) == next, "The limb's light did not follow the body") + + victim.forceMove(start) + melt.grow_pool(8) + TEST_ASSERT_NOTNULL(melt.pool, "The pool did not grow on the start tile") + TEST_ASSERT_EQUAL(get_turf(melt.pool), start, "The pool is not on the tile where the melt started") + victim.forceMove(next) + melt.pool_set() + TEST_ASSERT_EQUAL(voidcrew_plasma_melt_count(start, /obj/effect/decal/cleanable/plasma_slag), 1, "The full pool did not set into slag on the start tile") + TEST_ASSERT_EQUAL(voidcrew_plasma_melt_count(next, /obj/effect/decal/cleanable/plasma_slag), 0, "Slag followed the body") + + qdel(melt) + for(var/turf/where in list(start, next)) + for(var/obj/effect/thing in where) + if(istype(thing, /obj/effect/decal/cleanable) || istype(thing, /obj/effect/plasma_melt_pool) || istype(thing, /obj/effect/plasma_melt_light) || istype(thing, /obj/effect/temp_visual)) + qdel(thing) + +/// What tg drops off a melting limb really drops at once, but stays hidden until it falls out of the melt; +/// picking it up shows it straight away +/datum/unit_test/voidcrew_plasma_melt_dropped_items + +/datum/unit_test/voidcrew_plasma_melt_dropped_items/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent) + var/obj/item/clothing/shoes/boots = allocate(/obj/item/clothing/shoes/sneakers/black) + victim.equip_to_slot_or_del(boots, ITEM_SLOT_FEET) + var/obj/item/tool = allocate(/obj/item/crowbar) + victim.put_in_r_hand(tool) + TEST_ASSERT_EQUAL(victim.shoes, boots, "The test body could not wear shoes") + TEST_ASSERT_EQUAL(victim.get_item_for_held_index(victim.get_held_index_of_item(tool)), tool, "The test body could not hold the tool") + + TEST_ASSERT(plasma_melt_limb(victim.get_bodypart(BODY_ZONE_R_LEG), SOUTH), "The leg did not melt off") + TEST_ASSERT_NULL(victim.shoes, "tg's leg loss did not take the shoes off (the real drop must stay)") + TEST_ASSERT_EQUAL(boots.alpha, 0, "The dropped shoes show before the leg has melted away") + var/datum/plasma_melt/leg_melt = victim.plasma_melts[BODY_ZONE_R_LEG] + leg_melt.pose_stump("stump_bubble", TRUE) + TEST_ASSERT_EQUAL(boots.alpha, 255, "The shoes stayed hidden after the leg melted away") + boots.forceMove(get_step(get_turf(victim), WEST)) + TEST_ASSERT(boots.pixel_x == boots.base_pixel_x && boots.pixel_y == boots.base_pixel_y && boots.pixel_z == boots.base_pixel_z, "Moved shoes kept the offset of the spot they fell to") + + TEST_ASSERT(plasma_melt_limb(victim.get_bodypart(BODY_ZONE_R_ARM), SOUTH), "The arm did not melt off") + TEST_ASSERT(tool.loc != victim, "The melted hand still holds the tool") + TEST_ASSERT_EQUAL(tool.alpha, 0, "The dropped tool shows before the hand melts") + leg_melt = victim.plasma_melts[BODY_ZONE_R_ARM] + leg_melt.pose_melt() + TEST_ASSERT_EQUAL(tool.alpha, 0, "The tool fell out as the arm began to sag, before the hand ran off") + tool.forceMove(get_step(get_turf(victim), EAST)) + TEST_ASSERT_EQUAL(tool.alpha, 255, "Moving the hidden tool did not show it") + + for(var/zone in victim.plasma_melts) + qdel(victim.plasma_melts[zone]) + var/turf/where = get_turf(victim) + for(var/obj/effect/thing in where) + if(istype(thing, /obj/effect/decal/cleanable) || istype(thing, /obj/effect/plasma_melt_pool) || istype(thing, /obj/effect/plasma_melt_light) || istype(thing, /obj/effect/temp_visual)) + qdel(thing) + +/// A burnt sleeve never bares the chest: the arm sleeve masks leave out every pixel the chest draws, in every facing +/datum/unit_test/voidcrew_plasma_melt_sleeve_mask + +/datum/unit_test/voidcrew_plasma_melt_sleeve_mask/Run() + var/list/files = list( + BODY_ZONE_R_ARM = 'voidcrew/modules/plasma_weapons/icons/plasma_melt_r_arm.dmi', + BODY_ZONE_L_ARM = 'voidcrew/modules/plasma_weapons/icons/plasma_melt_l_arm.dmi', + ) + for(var/zone in files) + for(var/facing in GLOB.cardinals) + var/icon/mask = icon(files[zone], "[zone]_sleeve_mask", facing) + for(var/chest_state in list("human_chest_m", "human_chest_f")) + var/icon/chest = icon('icons/mob/human/bodyparts_greyscale.dmi', chest_state, facing) + for(var/x in 1 to 32) + for(var/y in 1 to 32) + if(mask.GetPixel(x, y) && chest.GetPixel(x, y)) + TEST_FAIL("The [zone] sleeve mask cuts the [chest_state] facing [dir2text(facing)] at [x],[y]") + return diff --git a/voidcrew/modules/unit_tests/voidcrew_plasma_pirates.dm b/voidcrew/modules/unit_tests/voidcrew_plasma_pirates.dm new file mode 100644 index 00000000000..d2fbfcc69de --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_plasma_pirates.dm @@ -0,0 +1,115 @@ +// Package: P6 NPCs. Unit tests for voidcrew/modules/plasma_weapons/. +// +// Fork defines are included after the tests, so zones appear as their literal numbers +// (1 is ZONE_GREEN, 2 is ZONE_YELLOW, 3 is ZONE_RED). + +/// Shared helpers; not a test itself +/datum/unit_test/voidcrew_plasma_pirates + abstract_type = /datum/unit_test/voidcrew_plasma_pirates + /// Mobs a test spawned through spawn_npc_in_zone(), deleted when it ends + var/list/spawned = list() + +/datum/unit_test/voidcrew_plasma_pirates/Destroy() + QDEL_LIST(spawned) + return ..() + +/// The floor `dx`, `dy` tiles from the test room's bottom-left corner (the room is 5x5) +/datum/unit_test/voidcrew_plasma_pirates/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) + +/// A `mob_type` spawned for `zone`, as a ship or ruin spawns it +/datum/unit_test/voidcrew_plasma_pirates/proc/spawn_in_zone(mob_type, zone, turf/where) + var/datum/npc_spawn_context/context = allocate(/datum/npc_spawn_context, src, zone) + var/mob/living/spawned_mob = spawn_npc_in_zone(mob_type, where, context) + spawned += spawned_mob + return spawned_mob + +/// A gunner handed a plasma loadout fires real plasma bolts, and its magazine is the loadout's +/datum/unit_test/voidcrew_plasma_pirates/loadouts + +/datum/unit_test/voidcrew_plasma_pirates/loadouts/Run() + var/datum/pirate_gun_loadout/pistol_loadout = GLOB.pirate_gun_loadouts[/datum/pirate_gun_loadout/plasma_pistol] + var/datum/pirate_gun_loadout/rifle_loadout = GLOB.pirate_gun_loadouts[/datum/pirate_gun_loadout/plasma_rifle] + TEST_ASSERT_NOTNULL(pistol_loadout, "The plasma pistol loadout has no shared copy") + TEST_ASSERT_NOTNULL(rifle_loadout, "The plasma rifle loadout has no shared copy") + TEST_ASSERT_EQUAL(pistol_loadout.magazine_size(), 15, "The plasma pistol's flask isn't 15 shots") + TEST_ASSERT_EQUAL(rifle_loadout.magazine_size(), 12, "The plasma rifle's flask isn't 12 shots") + var/obj/projectile/heavy = rifle_loadout.make_projectile(spot(4, 4)) + TEST_ASSERT(istype(heavy, /obj/projectile/energy/plasma_bolt/heavy), "The plasma rifle loadout fires [heavy?.type], not a heavy plasma bolt") + qdel(heavy) + + var/mob/living/basic/trooper/pirate/ranged/gunner = allocate(/mob/living/basic/trooper/pirate/ranged, spot(0, 2)) + TEST_ASSERT_EQUAL(pirate_crew_arm(gunner, /datum/pirate_gun_loadout/plasma_pistol), pistol_loadout, "A gunner couldn't be handed a plasma pistol") + var/datum/component/pirate_gun/gun = pirate_gun_of(gunner) + TEST_ASSERT_NOTNULL(gun, "A gunner handed a plasma pistol has no gun") + TEST_ASSERT_NULL(gunner.GetComponent(/datum/component/ranged_attacks), "A plasma gunner kept its stock shot") + TEST_ASSERT_EQUAL(gunner.r_hand, /obj/item/gun/energy/plasma_weapon/pistol, "A plasma gunner holds a different gun") + var/mob/living/carbon/human/consistent/mark = allocate(/mob/living/carbon/human/consistent, spot(4, 2)) + gun.aim_ref = WEAKREF(mark) + var/obj/projectile/shot = gun.shoot_once(gun.serial) + TEST_ASSERT(istype(shot, /obj/projectile/energy/plasma_bolt), "A plasma gunner fired [shot?.type], not a plasma bolt") + TEST_ASSERT_EQUAL(shot.firer, gunner, "A plasma bolt doesn't know who fired it") + qdel(shot) + var/full = gun.ammo + TEST_ASSERT(gun.try_fire(mark), "A loaded plasma pistol wouldn't fire") + TEST_ASSERT_EQUAL(gun.ammo, full - 2, "A plasma pistol pair didn't use two shots") + +/// Green-zone pirates never roll plasma; yellow adds the pistol; red adds both. The faction's own list is untouched. +/datum/unit_test/voidcrew_plasma_pirates/zone_weights + +/datum/unit_test/voidcrew_plasma_pirates/zone_weights/Run() + // By zone: green, yellow, red + var/list/expected = list( + list(), + list(/datum/pirate_gun_loadout/plasma_pistol), + list(/datum/pirate_gun_loadout/plasma_pistol, /datum/pirate_gun_loadout/plasma_rifle), + ) + var/list/plasma_types = list(/datum/pirate_gun_loadout/plasma_pistol, /datum/pirate_gun_loadout/plasma_rifle) + for(var/zone in 1 to 3) + var/mob/living/basic/trooper/pirate/faction/irs/ranged/gunner = spawn_in_zone(/mob/living/basic/trooper/pirate/faction/irs/ranged, zone, spot(0, zone)) + var/list/weights = pirate_gun_weights_for(gunner) + TEST_ASSERT(length(weights), "A zone [zone] IRS gunner has no gun weights") + for(var/plasma_type in plasma_types) + var/wanted = (plasma_type in expected[zone]) + TEST_ASSERT_EQUAL(!!weights[plasma_type], wanted, "A zone [zone] gunner's weights [wanted ? "lack" : "include"] [plasma_type]") + for(var/stock_type in gunner.crew_gun_weights) + TEST_ASSERT_EQUAL(weights[stock_type], gunner.crew_gun_weights[stock_type], "A zone [zone] gunner's weight for [stock_type] changed") + TEST_ASSERT_NULL(gunner.crew_gun_weights[/datum/pirate_gun_loadout/plasma_pistol], "Plasma weights were written into the IRS faction's own list") + var/mob/living/basic/trooper/pirate/ranged/generic = spawn_in_zone(/mob/living/basic/trooper/pirate/ranged, zone, spot(1, zone)) + var/list/generic_weights = pirate_gun_weights_for(generic) + TEST_ASSERT_EQUAL(!!generic_weights[/datum/pirate_gun_loadout/plasma_pistol], zone != 1, "A plain zone [zone] pirate's plasma pistol weight is wrong") + TEST_ASSERT_NULL(GLOB.pirate_gun_default_weights[/datum/pirate_gun_loadout/plasma_pistol], "Plasma weights were written into the default list") + var/mob/living/basic/trooper/pirate/faction/medieval/ranged/archer = spawn_in_zone(/mob/living/basic/trooper/pirate/faction/medieval/ranged, 3, spot(2, 1)) + TEST_ASSERT_NULL(pirate_gun_weights_for(archer), "A red-zone crossbowman would trade his crossbow for a gun") + +/// The red-zone Director Prime and Chief Auditor carry a Furnace; elsewhere they keep their stock shot. Both can drop the Old Furnace. +/datum/unit_test/voidcrew_plasma_pirates/bosses + +/datum/unit_test/voidcrew_plasma_pirates/bosses/Run() + var/list/boss_types = list(/mob/living/basic/trooper/pirate/faction/boss/interdyne, /mob/living/basic/trooper/pirate/faction/boss/irs) + var/row = 0 + for(var/boss_type in boss_types) + for(var/zone in 1 to 3) + var/mob/living/basic/trooper/pirate/faction/boss/boss = spawn_in_zone(boss_type, zone, spot(row, zone)) + var/datum/component/pirate_gun/gun = pirate_gun_of(boss) + TEST_ASSERT(/obj/item/gun/energy/plasma_weapon/rifle/old_furnace in boss.rare_loot_pool, "[boss_type] can't drop the Old Furnace") + if(zone == 3) + TEST_ASSERT_NOTNULL(gun, "A red-zone [boss_type] carries no Furnace") + TEST_ASSERT_EQUAL(gun.loadout.type, /datum/pirate_gun_loadout/plasma_rifle, "A red-zone [boss_type] carries [gun.loadout.type]") + TEST_ASSERT_NULL(boss.GetComponent(/datum/component/ranged_attacks), "A red-zone [boss_type] kept its stock shot as well as its Furnace") + TEST_ASSERT_EQUAL(boss.r_hand, /obj/item/gun/energy/plasma_weapon/rifle, "A red-zone [boss_type] holds a different gun from its Furnace") + boss.apply_npc_weapon_ladder() + TEST_ASSERT_EQUAL(pirate_gun_of(boss), gun, "A red-zone [boss_type] was armed twice") + else + TEST_ASSERT(!(/obj/item/gun/energy/plasma_weapon/rifle/old_furnace in boss.rolled_rare_loot), "A zone [zone] [boss_type] kept an Old Furnace roll") + TEST_ASSERT_NULL(gun, "A zone [zone] [boss_type] carries a pirate gun") + TEST_ASSERT_NOTNULL(boss.GetComponent(/datum/component/ranged_attacks), "A zone [zone] [boss_type] lost its stock shot") + row++ + + // The roll itself: kept by a red-zone boss, dropped by any other + for(var/zone in 1 to 3) + var/mob/living/basic/trooper/pirate/faction/boss/irs/roller = allocate(/mob/living/basic/trooper/pirate/faction/boss/irs, spot(4, zone)) + roller.rolled_rare_loot = list(/obj/item/gun/energy/plasma_weapon/rifle/old_furnace) + roller.weapon_loot_initialized = FALSE + roller.initialize_weapon_loot(zone) + TEST_ASSERT_EQUAL((/obj/item/gun/energy/plasma_weapon/rifle/old_furnace in roller.rolled_rare_loot), zone == 3, "A zone [zone] boss's Old Furnace roll was [zone == 3 ? "dropped" : "kept"]") diff --git a/voidcrew/modules/unit_tests/voidcrew_plasma_supply.dm b/voidcrew/modules/unit_tests/voidcrew_plasma_supply.dm new file mode 100644 index 00000000000..ac1a0bd90b8 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_plasma_supply.dm @@ -0,0 +1,77 @@ +// Package: P5 supply. Unit tests for voidcrew/modules/plasma_weapons/. + +/// The guns and the flask leave a crew's hands only by use: no cargo export, buyback or peddler line covers them. +/datum/unit_test/voidcrew_plasma_no_resale + +/datum/unit_test/voidcrew_plasma_no_resale/Run() + var/list/sold = list( + /obj/item/gun/energy/frontier_laser/pistol, + /obj/item/gun/energy/frontier_laser/smg, + /obj/item/gun/energy/plasma_weapon/pistol, + /obj/item/gun/energy/plasma_weapon/pistol/loot, + /obj/item/gun/energy/plasma_weapon/rifle, + /obj/item/gun/energy/plasma_weapon/rifle/loot, + /obj/item/gun/energy/plasma_weapon/rifle/old_furnace, + /obj/item/plasma_flask, + ) + if(!length(GLOB.exports_list)) + setupExports() + TEST_ASSERT(length(GLOB.exports_list), "No cargo exports were set up") + for(var/datum/export/export as anything in GLOB.exports_list) + for(var/item_path in sold) + if(!is_type_in_typecache(item_path, export.export_types)) + continue + if(export.include_subtypes && is_type_in_typecache(item_path, export.exclude_types)) + continue + TEST_FAIL("[export.type] exports [item_path]") + + for(var/datum/shop_buyback/buyback_path as anything in subtypesof(/datum/shop_buyback)) + var/bought = initial(buyback_path.item_path) + if(!bought) + continue + for(var/item_path in sold) + if(item_path == bought || (initial(buyback_path.match_subtypes) && ispath(item_path, bought))) + TEST_FAIL("[buyback_path] buys back [item_path]") + + for(var/datum/shop_sku/sku_path as anything in subtypesof(/datum/shop_sku/peddler)) + var/stocked = initial(sku_path.item_path) + if(stocked in sold) + TEST_FAIL("Peddler line [sku_path] stocks [stocked]") + +/// Shop lines sit at the gate and price the spec sets, and the loot pools carry each gun. +/datum/unit_test/voidcrew_plasma_supply_routes + +/datum/unit_test/voidcrew_plasma_supply_routes/Run() + // sku type = list(item, gate, credits, vouchers). Gates: 1 yellow, 2 advanced (fork defines follow test includes). + var/list/lines = list( + /datum/shop_sku/outfitter/kestrel = list(/obj/item/gun/energy/frontier_laser/pistol, 1, 900, 0), + /datum/shop_sku/outfitter/cinder = list(/obj/item/gun/energy/plasma_weapon/pistol, 1, 2000, 0), + /datum/shop_sku/outfitter/plasma_flask = list(/obj/item/plasma_flask, 1, 150, 0), + /datum/shop_sku/black_market/hornet = list(/obj/item/gun/energy/frontier_laser/smg, 1, 3500, 0), + /datum/shop_sku/black_market/rotating/furnace = list(/obj/item/gun/energy/plasma_weapon/rifle, 2, 5000, 1), + ) + for(var/datum/shop_sku/sku_path as anything in lines) + var/list/line = lines[sku_path] + TEST_ASSERT_EQUAL(initial(sku_path.item_path), line[1], "[sku_path] sells the wrong item") + TEST_ASSERT_EQUAL(initial(sku_path.zone_permission), line[2], "[sku_path] has the wrong gate") + TEST_ASSERT_EQUAL(initial(sku_path.price_credits), line[3], "[sku_path] has the wrong credit price") + TEST_ASSERT_EQUAL(initial(sku_path.price_vouchers), line[4], "[sku_path] has the wrong voucher price") + + // No Old Furnace on any shelf + for(var/datum/shop_sku/sku_path as anything in subtypesof(/datum/shop_sku)) + if(initial(sku_path.item_path) == /obj/item/gun/energy/plasma_weapon/rifle/old_furnace) + TEST_FAIL("[sku_path] sells the Old Furnace") + + // family, class (1 green, 2 yellow, 3 red), item, weight + var/list/pool_entries = list( + list("armory", "1", /obj/item/gun/energy/frontier_laser/pistol, 10), + list("freight", "1", /obj/item/gun/energy/frontier_laser/pistol, 10), + list("armory", "2", /obj/item/gun/energy/frontier_laser/smg, 10), + list("armory", "2", /obj/item/gun/energy/plasma_weapon/pistol/loot, 10), + list("plunder", "2", /obj/item/gun/energy/plasma_weapon/pistol/loot, 10), + list("plunder", "3", /obj/item/gun/energy/plasma_weapon/rifle/loot, 10), + list("plunder", "3", /obj/item/gun/energy/plasma_weapon/rifle/old_furnace, 1), + ) + for(var/list/entry as anything in pool_entries) + var/list/pool = GLOB.loot_gun_pools[entry[1]][entry[2]] + TEST_ASSERT_EQUAL(pool[entry[3]], entry[4], "[entry[3]] has the wrong weight in [entry[1]] class [entry[2]]") diff --git a/voidcrew/modules/unit_tests/voidcrew_plasma_wounds.dm b/voidcrew/modules/unit_tests/voidcrew_plasma_wounds.dm new file mode 100644 index 00000000000..971b4b5a811 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_plasma_wounds.dm @@ -0,0 +1,642 @@ +// Package: W5 plasma burn wounds. Unit tests for voidcrew/modules/plasma_weapons/plasma_wound*.dm. +// The tier subtypes, the surgery and its step are looked up with text2path so this file compiles +// against the W0 seams; a missing type fails the test that needs it. + +/// Shared helpers for the plasma burn wound tests. +/datum/unit_test/voidcrew_plasma_wounds + abstract_type = /datum/unit_test/voidcrew_plasma_wounds + +/datum/unit_test/voidcrew_plasma_wounds/proc/turf_at(dx, dy) + return locate(run_loc_floor_bottom_left.x + dx, run_loc_floor_bottom_left.y + dy, run_loc_floor_bottom_left.z) + +/// A bolt sitting on `where` that came from there, ready to be handed to bullet_act(). +/datum/unit_test/voidcrew_plasma_wounds/proc/make_bolt(bolt_type, turf/where, zone = BODY_ZONE_CHEST) + var/obj/projectile/energy/plasma_bolt/bolt = allocate(bolt_type, where) + bolt.starting = where + bolt.def_zone = zone + return bolt + +/// The plasma burn type for a tier name ("moderate", "severe", "critical"), or null with a failure. +/datum/unit_test/voidcrew_plasma_wounds/proc/tier_path(tier) + var/path = text2path("/datum/wound/burn/flesh/plasma/[tier]") + if(!path) + TEST_FAIL("/datum/wound/burn/flesh/plasma/[tier] does not exist") + return path + +/// Forces the plasma burn of `tier` onto `limb` and returns it. +/datum/unit_test/voidcrew_plasma_wounds/proc/give_burn(obj/item/bodypart/limb, tier) + var/path = tier_path(tier) + if(!path) + return null + limb.force_wound_upwards(path) + var/datum/wound/burn/flesh/plasma/burn = limb.get_wound_type(/datum/wound/burn/flesh/plasma) + if(!istype(burn, path)) + TEST_FAIL("could not give a [limb.plaintext_zone] a [tier] plasma burn") + return null + return burn + +/// The burn wounds (tg and plasma) on a limb. +/datum/unit_test/voidcrew_plasma_wounds/proc/burns_on(obj/item/bodypart/limb) + . = list() + for(var/datum/wound/burn/flesh/burn in limb.wounds) + . += burn + +/datum/unit_test/voidcrew_plasma_wounds/proc/clean_up(turf/around) + for(var/obj/item/plasma_slag/bead in range(5, around)) + qdel(bead) + for(var/obj/effect/decal/cleanable/leftover in range(5, around)) + qdel(leftover) + +/// 1. A plasma bolt rolls the plasma tier for its roll; the same roll with no source gives tg's burn. +/datum/unit_test/voidcrew_plasma_wounds/roll_tiers + +/datum/unit_test/voidcrew_plasma_wounds/roll_tiers/Run() + var/obj/projectile/energy/plasma_bolt/bolt = make_bolt(/obj/projectile/energy/plasma_bolt, turf_at(1, 1)) + // Damage 1 makes the base roll 1, so the wound bonus sets the tier: about 50, 100 and 200. + var/list/tiers = list("moderate" = 49, "severe" = 99, "critical" = 199) + var/list/tg_tiers = list("moderate" = /datum/wound/burn/flesh/moderate, "severe" = /datum/wound/burn/flesh/severe, "critical" = /datum/wound/burn/flesh/critical) + var/list/fresh_slag = list("moderate" = PLASMA_WOUND_SLAG_MODERATE, "severe" = PLASMA_WOUND_SLAG_SEVERE, "critical" = PLASMA_WOUND_SLAG_CRITICAL) + var/list/fresh_heat = list("moderate" = PLASMA_WOUND_HEAT_MODERATE, "severe" = PLASMA_WOUND_HEAT_SEVERE, "critical" = PLASMA_WOUND_HEAT_CRITICAL) + for(var/tier in tiers) + var/path = tier_path(tier) + TEST_ASSERT_NOTNULL(path, "no [tier] plasma burn type") + var/mob/living/carbon/human/consistent/shot = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + var/obj/item/bodypart/arm = shot.get_bodypart(BODY_ZONE_R_ARM) + var/datum/wound/rolled = arm.check_wounding(WOUND_BURN, 1, tiers[tier], 0, null, bolt, FALSE) + TEST_ASSERT_EQUAL(rolled?.type, path, "a plasma bolt's [tier] roll gave [rolled?.type]") + TEST_ASSERT_EQUAL(length(burns_on(arm)), 1, "a plasma bolt's [tier] roll left more than one burn on the arm") + var/datum/wound/burn/flesh/plasma/plasma_burn = rolled + TEST_ASSERT_EQUAL(plasma_burn.slag, fresh_slag[tier], "a fresh [tier] plasma burn holds [plasma_burn.slag] slag") + TEST_ASSERT_EQUAL(plasma_burn.heat, fresh_heat[tier], "a fresh [tier] plasma burn is hot for [plasma_burn.heat]") + + var/mob/living/carbon/human/consistent/burned = allocate(/mob/living/carbon/human/consistent, turf_at(2, 2)) + var/obj/item/bodypart/other_arm = burned.get_bodypart(BODY_ZONE_R_ARM) + var/datum/wound/plain = other_arm.check_wounding(WOUND_BURN, 1, tiers[tier], 0, null, null, FALSE) + TEST_ASSERT_EQUAL(plain?.type, tg_tiers[tier], "the same [tier] roll with no source gave [plain?.type]") + + // The real path: a rifle bolt on a bare arm leaves a plasma burn and no tg burn. + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, turf_at(2, 3)) + victim.bullet_act(make_bolt(/obj/projectile/energy/plasma_bolt/heavy, turf_at(1, 3), BODY_ZONE_R_ARM), BODY_ZONE_R_ARM) + var/obj/item/bodypart/hit_arm = victim.get_bodypart(BODY_ZONE_R_ARM) + TEST_ASSERT_NOTNULL(hit_arm, "one rifle bolt melted an unhurt arm") + var/list/hit_burns = burns_on(hit_arm) + TEST_ASSERT_EQUAL(length(hit_burns), 1, "a rifle bolt on a bare arm left [length(hit_burns)] burns") + TEST_ASSERT(istype(hit_burns[1], /datum/wound/burn/flesh/plasma), "a rifle bolt on a bare arm left [hit_burns[1]], not a plasma burn") + clean_up(turf_at(2, 2)) + +/// 2. A higher plasma roll replaces a tg burn; a higher tg burn over a slagged plasma burn stays plasma. +/datum/unit_test/voidcrew_plasma_wounds/replacement + +/datum/unit_test/voidcrew_plasma_wounds/replacement/Run() + var/obj/projectile/energy/plasma_bolt/bolt = make_bolt(/obj/projectile/energy/plasma_bolt, turf_at(1, 1)) + var/severe_path = tier_path("severe") + var/critical_path = tier_path("critical") + TEST_ASSERT(severe_path && critical_path, "plasma burn tiers are missing") + + var/mob/living/carbon/human/consistent/first = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + var/obj/item/bodypart/arm = first.get_bodypart(BODY_ZONE_R_ARM) + arm.force_wound_upwards(/datum/wound/burn/flesh/moderate) + TEST_ASSERT_NOTNULL(arm.get_wound_type(/datum/wound/burn/flesh/moderate), "could not give an arm a tg moderate burn") + // With the moderate's penalties this lands in the severe band. + var/datum/wound/rolled = arm.check_wounding(WOUND_BURN, 1, 49, 0, null, bolt, FALSE) + TEST_ASSERT_EQUAL(rolled?.type, severe_path, "a severe plasma roll over a tg moderate burn gave [rolled?.type]") + var/list/after = burns_on(arm) + TEST_ASSERT_EQUAL(length(after), 1, "a plasma burn sat beside the tg burn it replaced") + TEST_ASSERT(istype(after[1], severe_path), "the tg moderate burn was not replaced by a plasma severe") + + var/mob/living/carbon/human/consistent/second = allocate(/mob/living/carbon/human/consistent, turf_at(2, 2)) + var/obj/item/bodypart/other_arm = second.get_bodypart(BODY_ZONE_L_ARM) + var/datum/wound/burn/flesh/plasma/slagged = give_burn(other_arm, "severe") + TEST_ASSERT_NOTNULL(slagged, "no severe plasma burn") + var/old_slag = slagged.slag + TEST_ASSERT(old_slag > 0, "a fresh severe plasma burn holds no slag") + // Part-cooled, so a carried heat can be told from a fresh one. + slagged.heat = 7 + // A fire's critical roll, with no plasma source. + other_arm.check_wounding(WOUND_BURN, 1, 199, 0, null, null, FALSE) + var/list/fire_after = burns_on(other_arm) + TEST_ASSERT_EQUAL(length(fire_after), 1, "a fire roll left [length(fire_after)] burns on a slagged arm") + var/datum/wound/burn/flesh/plasma/upgraded = fire_after[1] + TEST_ASSERT(istype(upgraded, critical_path), "a tg critical over a slagged plasma burn became [upgraded.type]") + TEST_ASSERT_EQUAL(upgraded.slag, old_slag, "a tg burn over a plasma burn changed the slag from [old_slag] to [upgraded.slag]") + TEST_ASSERT_EQUAL(upgraded.heat, 7, "a tg burn over a plasma burn changed its heat from 7 to [upgraded.heat]") + +/// 3. tg's wound picker never hands out a plasma burn. +/datum/unit_test/voidcrew_plasma_wounds/never_picked + +/datum/unit_test/voidcrew_plasma_wounds/never_picked/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + var/obj/item/bodypart/arm = victim.get_bodypart(BODY_ZONE_R_ARM) + var/list/severities = list(WOUND_SEVERITY_MODERATE, WOUND_SEVERITY_SEVERE, WOUND_SEVERITY_CRITICAL) + for(var/i in 1 to 200) + var/severity = severities[(i % 3) + 1] + var/datum/wound/given = victim.cause_wound_of_type_and_severity(WOUND_BURN, arm, severity) + TEST_ASSERT_NOTNULL(given, "cause_wound_of_type_and_severity gave no burn at severity [severity]") + TEST_ASSERT(!istype(given, /datum/wound/burn/flesh/plasma), "cause_wound_of_type_and_severity gave [given.type] on try [i]") + qdel(given) + +/// 4. Cooking: a severe on a chest takes its burn over its heat and none after; wet stops it; cold cools +/// four times as fast; a burning body doesn't cool. +/datum/unit_test/voidcrew_plasma_wounds/cooking + +/datum/unit_test/voidcrew_plasma_wounds/cooking/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + var/obj/item/bodypart/chest = victim.get_bodypart(BODY_ZONE_CHEST) + var/datum/wound/burn/flesh/plasma/burn = give_burn(chest, "severe") + TEST_ASSERT_NOTNULL(burn, "no severe plasma burn") + TEST_ASSERT_EQUAL(burn.heat, PLASMA_WOUND_HEAT_SEVERE, "a fresh severe is hot for [burn.heat]") + var/multiplier = chest.wound_damage_multiplier + var/start_burn = chest.burn_dam + for(var/tick in 1 to PLASMA_WOUND_HEAT_SEVERE + 2) + burn.handle_process(1, tick) + TEST_ASSERT(!QDELETED(burn), "a slagged severe closed while cooking") + TEST_ASSERT(burn.heat <= 0, "a severe was still hot after [PLASMA_WOUND_HEAT_SEVERE + 2] seconds at body heat") + var/cooked = chest.burn_dam - start_burn + var/expected = PLASMA_WOUND_HEAT_SEVERE * PLASMA_WOUND_COOK_SEVERE * multiplier + TEST_ASSERT(abs(cooked - expected) <= PLASMA_WOUND_COOK_SEVERE * multiplier + 0.05, "a severe cooked [cooked] burn over its heat, expected [expected]") + var/cold_burn = chest.burn_dam + for(var/tick in 1 to 10) + burn.handle_process(1, tick) + TEST_ASSERT_EQUAL(chest.burn_dam, cold_burn, "a cold severe kept cooking") + + // Wet skin quenches it in one tick and nothing cooks that tick. + var/mob/living/carbon/human/consistent/wet = allocate(/mob/living/carbon/human/consistent, turf_at(2, 2)) + var/obj/item/bodypart/wet_chest = wet.get_bodypart(BODY_ZONE_CHEST) + var/datum/wound/burn/flesh/plasma/wet_burn = give_burn(wet_chest, "severe") + TEST_ASSERT_NOTNULL(wet_burn, "no severe plasma burn") + wet.adjust_wet_stacks(5) + var/wet_start = wet_chest.burn_dam + wet_burn.handle_process(1, 1) + TEST_ASSERT(wet_burn.heat <= 0, "wet skin left a severe hot at [wet_burn.heat]") + TEST_ASSERT_EQUAL(wet_chest.burn_dam, wet_start, "a severe cooked on the tick it was quenched") + wet.remove_status_effect(/datum/status_effect/fire_handler/wet_stacks) + + // One tick at body heat, then one in a cold body. + var/mob/living/carbon/human/consistent/warm = allocate(/mob/living/carbon/human/consistent, turf_at(2, 3)) + var/datum/wound/burn/flesh/plasma/warm_burn = give_burn(warm.get_bodypart(BODY_ZONE_CHEST), "severe") + TEST_ASSERT_NOTNULL(warm_burn, "no severe plasma burn") + warm_burn.handle_process(1, 1) + var/warm_loss = PLASMA_WOUND_HEAT_SEVERE - warm_burn.heat + TEST_ASSERT(abs(warm_loss - 1) < 0.01, "a severe at body heat lost [warm_loss] heat in a second") + + var/mob/living/carbon/human/consistent/cold = allocate(/mob/living/carbon/human/consistent, turf_at(3, 1)) + var/datum/wound/burn/flesh/plasma/cold_burn_wound = give_burn(cold.get_bodypart(BODY_ZONE_CHEST), "severe") + TEST_ASSERT_NOTNULL(cold_burn_wound, "no severe plasma burn") + cold.bodytemperature = BODYTEMP_COLD_DAMAGE_LIMIT - 20 + cold_burn_wound.handle_process(1, 1) + var/cold_loss = PLASMA_WOUND_HEAT_SEVERE - cold_burn_wound.heat + TEST_ASSERT(abs(cold_loss - PLASMA_WOUND_COLD_BODY_FACTOR) < 0.01, "a severe in a cold body lost [cold_loss] heat in a second, expected [PLASMA_WOUND_COLD_BODY_FACTOR]") + cold.bodytemperature = BODYTEMP_NORMAL + + var/mob/living/carbon/human/consistent/burning = allocate(/mob/living/carbon/human/consistent, turf_at(3, 2)) + var/datum/wound/burn/flesh/plasma/fire_burn = give_burn(burning.get_bodypart(BODY_ZONE_CHEST), "severe") + TEST_ASSERT_NOTNULL(fire_burn, "no severe plasma burn") + burning.adjust_fire_stacks(5) + burning.ignite_mob() + TEST_ASSERT(burning.on_fire, "could not set the test body alight") + fire_burn.handle_process(1, 1) + TEST_ASSERT_EQUAL(fire_burn.heat, PLASMA_WOUND_HEAT_SEVERE, "a severe on a burning body cooled") + burning.extinguish_mob() + +/// 5. Cooking never adds or upgrades a wound, and never melts a limb. +/datum/unit_test/voidcrew_plasma_wounds/cooking_is_inert + +/datum/unit_test/voidcrew_plasma_wounds/cooking_is_inert/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + var/obj/item/bodypart/arm = victim.get_bodypart(BODY_ZONE_R_ARM) + var/datum/wound/burn/flesh/plasma/severe = give_burn(arm, "severe") + TEST_ASSERT_NOTNULL(severe, "no severe plasma burn") + var/severe_type = severe.type + for(var/tick in 1 to PLASMA_WOUND_HEAT_SEVERE + 2) + severe.handle_process(1, tick) + TEST_ASSERT_EQUAL(length(arm.wounds), 1, "cooking added a wound to the arm") + TEST_ASSERT(!QDELETED(severe) && severe.type == severe_type, "cooking replaced or upgraded a severe plasma burn") + + // A critical on a leg already near its limit cooks it to the cap and the leg stays on. + var/obj/item/bodypart/leg = victim.get_bodypart(BODY_ZONE_L_LEG) + leg.receive_damage(burn = leg.max_damage - 5, wound_bonus = CANT_WOUND) + var/datum/wound/burn/flesh/plasma/critical = give_burn(leg, "critical") + TEST_ASSERT_NOTNULL(critical, "no critical plasma burn") + for(var/tick in 1 to PLASMA_WOUND_HEAT_CRITICAL + 2) + critical.handle_process(1, tick) + TEST_ASSERT_EQUAL(victim.get_bodypart(BODY_ZONE_L_LEG), leg, "cooking melted a leg") + TEST_ASSERT_EQUAL(length(leg.wounds), 1, "cooking added a wound to the leg") + TEST_ASSERT(!QDELETED(critical), "cooking removed the critical plasma burn") + TEST_ASSERT_EQUAL(length(victim.all_wounds), 2, "cooking added a wound somewhere on the body") + +/// 6. With slag in, ointment, mesh, miner's salve, stasis and cryo do not close the wound; stored healing +/// is capped; once the slag is out the wound closes on tg's clock. +/datum/unit_test/voidcrew_plasma_wounds/blocked_healing + +/datum/unit_test/voidcrew_plasma_wounds/blocked_healing/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + var/mob/living/carbon/human/consistent/medic = allocate(/mob/living/carbon/human/consistent, turf_at(1, 1)) + var/obj/item/bodypart/arm = victim.get_bodypart(BODY_ZONE_R_ARM) + var/datum/wound/burn/flesh/plasma/burn = give_burn(arm, "severe") + TEST_ASSERT_NOTNULL(burn, "no severe plasma burn") + burn.quench(TRUE) + TEST_ASSERT(burn.heat <= 0, "quench left the burn hot") + var/start_flesh = burn.flesh_damage + + var/obj/item/stack/medical/ointment/ointment = allocate(/obj/item/stack/medical/ointment) + var/obj/item/stack/medical/mesh/mesh = allocate(/obj/item/stack/medical/mesh) + mesh.is_open = TRUE + for(var/i in 1 to 6) + ointment.heal_carbon(victim, medic, BODY_ZONE_R_ARM) + mesh.heal_carbon(victim, medic, BODY_ZONE_R_ARM) + burn.handle_process(1, i) + victim.reagents.add_reagent(/datum/reagent/medicine/mine_salve, 20) + for(var/tick in 1 to 60) + burn.handle_process(1, tick) + TEST_ASSERT(!QDELETED(burn), "a slagged burn closed under ointment, mesh and salve") + TEST_ASSERT_EQUAL(burn.flesh_damage, start_flesh, "a slagged burn healed flesh") + TEST_ASSERT(burn.stored_healing > 0, "treatment given with slag in was not stored") + TEST_ASSERT(burn.stored_healing <= burn.flesh_damage + 0.01, "stored healing [burn.stored_healing] went past the flesh damage [burn.flesh_damage]") + + ADD_TRAIT(victim, TRAIT_STASIS, "voidcrew_plasma_wounds_test") + for(var/tick in 1 to 200) + burn.on_stasis(1, tick) + REMOVE_TRAIT(victim, TRAIT_STASIS, "voidcrew_plasma_wounds_test") + TEST_ASSERT(!QDELETED(burn), "stasis closed a slagged burn") + TEST_ASSERT_EQUAL(burn.flesh_damage, start_flesh, "stasis healed flesh on a slagged burn") + + burn.on_xadone(burn.get_xadone_progress_to_qdel() * 3) + TEST_ASSERT(!QDELETED(burn), "cryo closed a slagged burn") + TEST_ASSERT(burn.cryo_progress <= burn.get_xadone_progress_to_qdel(), "cryo progress on a slagged burn ran past the closing point") + + // The last of the slag comes out: stored healing is released and the burn closes as tg's would. + var/had_slag = burn.slag + TEST_ASSERT_EQUAL(burn.remove_slag(had_slag + 5, medic), had_slag, "remove_slag did not return the amount it took out") + TEST_ASSERT_EQUAL(burn.slag, 0, "removing all the slag left [burn.slag]") + TEST_ASSERT_EQUAL(burn.remove_slag(3, medic), 0, "remove_slag took slag out of an empty wound") + TEST_ASSERT_EQUAL(burn.stored_healing, 0, "stored healing was kept after the slag came out") + TEST_ASSERT(burn.flesh_healing > 0, "stored healing was not released when the slag came out") + for(var/tick in 1 to 120) + if(QDELETED(burn)) + break + burn.handle_process(1, tick) + TEST_ASSERT(QDELETED(burn), "a clean, treated burn did not close in two minutes") + clean_up(turf_at(2, 1)) + +/// 6b. On a stasis bed the slag cools with no cooking and a moderate still sheds; cryo quenches only in a +/// frozen body. +/datum/unit_test/voidcrew_plasma_wounds/stasis_and_cryo_cooling + +/datum/unit_test/voidcrew_plasma_wounds/stasis_and_cryo_cooling/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + ADD_TRAIT(victim, TRAIT_STASIS, "voidcrew_plasma_wounds_test") + var/obj/item/bodypart/chest = victim.get_bodypart(BODY_ZONE_CHEST) + var/datum/wound/burn/flesh/plasma/severe = give_burn(chest, "severe") + TEST_ASSERT_NOTNULL(severe, "no severe plasma burn") + var/start_burn = chest.burn_dam + for(var/tick in 1 to PLASMA_WOUND_HEAT_SEVERE + 2) + severe.on_stasis(1, tick) + TEST_ASSERT(severe.heat <= 0, "a severe on stasis was still hot after [PLASMA_WOUND_HEAT_SEVERE + 2] seconds") + TEST_ASSERT_EQUAL(chest.burn_dam, start_burn, "a severe cooked on a stasis bed") + TEST_ASSERT_EQUAL(severe.slag, PLASMA_WOUND_SLAG_SEVERE, "a severe lost slag on a stasis bed") + + var/datum/wound/burn/flesh/plasma/moderate = give_burn(victim.get_bodypart(BODY_ZONE_L_ARM), "moderate") + TEST_ASSERT_NOTNULL(moderate, "no moderate plasma burn") + var/shed_ticks = PLASMA_WOUND_HEAT_MODERATE + CEILING(PLASMA_WOUND_SLAG_MODERATE / PLASMA_WOUND_SHED_RATE, 1) + 5 + for(var/tick in 1 to shed_ticks) + if(QDELETED(moderate) || moderate.slag <= 0) + break + moderate.on_stasis(1, tick) + TEST_ASSERT(QDELETED(moderate) || moderate.slag <= 0, "a moderate on stasis still held [moderate.slag] slag after [shed_ticks] seconds") + TEST_ASSERT_NULL(locate(/obj/item/plasma_slag) in get_turf(victim), "a moderate that shed on stasis dropped a bead") + REMOVE_TRAIT(victim, TRAIT_STASIS, "voidcrew_plasma_wounds_test") + + // Pyroxadone on a warm body leaves the slag hot; cryoxadone in a frozen body quenches it. + var/mob/living/carbon/human/consistent/warm = allocate(/mob/living/carbon/human/consistent, turf_at(2, 2)) + var/datum/wound/burn/flesh/plasma/warm_burn = give_burn(warm.get_bodypart(BODY_ZONE_CHEST), "severe") + TEST_ASSERT_NOTNULL(warm_burn, "no severe plasma burn") + warm_burn.on_xadone(1) + TEST_ASSERT_EQUAL(warm_burn.heat, PLASMA_WOUND_HEAT_SEVERE, "xadone on a warm body cooled the slag") + + var/mob/living/carbon/human/consistent/frozen = allocate(/mob/living/carbon/human/consistent, turf_at(2, 3)) + var/datum/wound/burn/flesh/plasma/frozen_burn = give_burn(frozen.get_bodypart(BODY_ZONE_CHEST), "severe") + TEST_ASSERT_NOTNULL(frozen_burn, "no severe plasma burn") + frozen.bodytemperature = T0C - 20 + frozen_burn.on_xadone(1) + frozen.bodytemperature = BODYTEMP_NORMAL + TEST_ASSERT(frozen_burn.heat <= 0, "cryo in a frozen body left the slag hot at [frozen_burn.heat]") + TEST_ASSERT(!QDELETED(frozen_burn), "cryo closed a slagged burn") + +/// 7. Field picks with each tool: time, slag removed, brute dealt, one bead for a severe or critical only. +/datum/unit_test/voidcrew_plasma_wounds/pick_slag + +/// Picks every bit of slag out of `burn`; returns list(elapsed deciseconds, brute the tool dealt). +/// The brute is summed from the tool's own damage events, not read off the limb, because the body heals +/// a little brute on its own between picks. +/datum/unit_test/voidcrew_plasma_wounds/pick_slag/proc/pick_all(datum/wound/burn/flesh/plasma/burn, mob/living/carbon/human/user, obj/item/tool) + user.put_in_active_hand(tool, forced = TRUE) + var/mob/living/carbon/patient = burn.victim + var/start_time = world.time + tool_brute = 0 + picking_tool = tool + RegisterSignal(patient, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(count_tool_brute)) + burn.pick_slag(tool, user) + UnregisterSignal(patient, COMSIG_MOB_AFTER_APPLY_DAMAGE) + picking_tool = null + return list(world.time - start_time, tool_brute) + +/datum/unit_test/voidcrew_plasma_wounds/pick_slag/var/tool_brute = 0 +/datum/unit_test/voidcrew_plasma_wounds/pick_slag/var/obj/item/picking_tool + +/datum/unit_test/voidcrew_plasma_wounds/pick_slag/proc/count_tool_brute(mob/source, damage_dealt, damagetype, def_zone, blocked, wound_bonus, exposed_wound_bonus, sharpness, attack_direction, attacking_item) + SIGNAL_HANDLER + if(damagetype == BRUTE && attacking_item == picking_tool) + tool_brute += damage_dealt + +/datum/unit_test/voidcrew_plasma_wounds/pick_slag/Run() + // Moderate, scalpel, someone else: one pick, no bead. + var/mob/living/carbon/human/consistent/first = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + var/mob/living/carbon/human/consistent/medic = allocate(/mob/living/carbon/human/consistent, turf_at(1, 1)) + var/obj/item/bodypart/arm = first.get_bodypart(BODY_ZONE_R_ARM) + var/datum/wound/burn/flesh/plasma/moderate = give_burn(arm, "moderate") + TEST_ASSERT_NOTNULL(moderate, "no moderate plasma burn") + var/multiplier = arm.wound_damage_multiplier + var/list/result = pick_all(moderate, medic, allocate(/obj/item/scalpel)) + var/expected_time = PLASMA_WOUND_PICK_TIME * PLASMA_WOUND_IMPROVISED_TIME + TEST_ASSERT_EQUAL(moderate.slag, 0, "one scalpel pick left [moderate.slag] slag in a moderate") + TEST_ASSERT(result[1] >= expected_time - 1 && result[1] <= expected_time + 5, "one scalpel pick took [result[1]], expected [expected_time]") + TEST_ASSERT(abs(result[2] - PLASMA_WOUND_PICK_BRUTE_IMPROVISED * multiplier) < 0.1, "one scalpel pick dealt [result[2]] brute") + TEST_ASSERT_NULL(locate(/obj/item/plasma_slag) in get_turf(first), "a moderate dropped a slag bead") + + // Severe, wirecutters, someone else: two picks, one bead. + var/mob/living/carbon/human/consistent/second = allocate(/mob/living/carbon/human/consistent, turf_at(2, 2)) + var/mob/living/carbon/human/consistent/engineer = allocate(/mob/living/carbon/human/consistent, turf_at(1, 2)) + var/obj/item/bodypart/leg = second.get_bodypart(BODY_ZONE_L_LEG) + var/datum/wound/burn/flesh/plasma/severe = give_burn(leg, "severe") + TEST_ASSERT_NOTNULL(severe, "no severe plasma burn") + multiplier = leg.wound_damage_multiplier + result = pick_all(severe, engineer, allocate(/obj/item/wirecutters)) + var/picks = CEILING(PLASMA_WOUND_SLAG_SEVERE / PLASMA_WOUND_PICK_SLAG, 1) + expected_time = picks * PLASMA_WOUND_PICK_TIME * PLASMA_WOUND_IMPROVISED_TIME + TEST_ASSERT_EQUAL(severe.slag, 0, "wirecutters left [severe.slag] slag in a severe") + TEST_ASSERT(result[1] >= expected_time - 1 && result[1] <= expected_time + picks * 5, "[picks] wirecutter picks took [result[1]], expected [expected_time]") + TEST_ASSERT(abs(result[2] - picks * PLASMA_WOUND_PICK_BRUTE_IMPROVISED * multiplier) < 0.1, "[picks] wirecutter picks dealt [result[2]] brute") + var/beads = 0 + for(var/obj/item/plasma_slag/bead in get_turf(second)) + beads++ + // Worthless by design: nothing to recycle, grind, juice or melt down + TEST_ASSERT(!length(bead.custom_materials), "the slag bead has materials") + TEST_ASSERT(!length(bead.grind_results) && !bead.juice_typepath, "the slag bead grinds or juices into something") + TEST_ASSERT(!bead.reagents?.total_volume, "the slag bead holds reagents") + TEST_ASSERT_EQUAL(beads, 1, "a picked severe dropped [beads] beads") + + // Critical on your own chest, hemostat, scanned: four picks at x1.5 x0.5, one bead. + var/mob/living/carbon/human/consistent/third = allocate(/mob/living/carbon/human/consistent, turf_at(2, 3)) + var/obj/item/bodypart/chest = third.get_bodypart(BODY_ZONE_CHEST) + var/datum/wound/burn/flesh/plasma/critical = give_burn(chest, "critical") + TEST_ASSERT_NOTNULL(critical, "no critical plasma burn") + ADD_TRAIT(critical, TRAIT_WOUND_SCANNED, ANALYZER_TRAIT) + multiplier = chest.wound_damage_multiplier + result = pick_all(critical, third, allocate(/obj/item/hemostat)) + picks = CEILING(PLASMA_WOUND_SLAG_CRITICAL / PLASMA_WOUND_PICK_SLAG, 1) + expected_time = picks * PLASMA_WOUND_PICK_TIME * 1.5 * 0.5 + TEST_ASSERT_EQUAL(critical.slag, 0, "a hemostat left [critical.slag] slag in a critical") + TEST_ASSERT(result[1] >= expected_time - 1 && result[1] <= expected_time + picks * 5, "[picks] scanned self picks took [result[1]], expected [expected_time]") + TEST_ASSERT(abs(result[2] - picks * PLASMA_WOUND_PICK_BRUTE * multiplier) < 0.1, "[picks] hemostat picks dealt [result[2]] brute") + beads = 0 + for(var/obj/item/plasma_slag/bead in get_turf(third)) + beads++ + TEST_ASSERT_EQUAL(beads, 1, "a picked critical dropped [beads] beads") + + // A moderate left alone sheds its flecks once cool, and drops nothing. + var/mob/living/carbon/human/consistent/fourth = allocate(/mob/living/carbon/human/consistent, turf_at(3, 1)) + var/datum/wound/burn/flesh/plasma/shedding = give_burn(fourth.get_bodypart(BODY_ZONE_L_ARM), "moderate") + TEST_ASSERT_NOTNULL(shedding, "no moderate plasma burn") + var/shed_ticks = PLASMA_WOUND_HEAT_MODERATE + CEILING(PLASMA_WOUND_SLAG_MODERATE / PLASMA_WOUND_SHED_RATE, 1) + 5 + for(var/tick in 1 to shed_ticks) + if(QDELETED(shedding) || shedding.slag <= 0) + break + shedding.handle_process(1, tick) + TEST_ASSERT(QDELETED(shedding) || shedding.slag <= 0, "a moderate still held [shedding.slag] slag after [shed_ticks] seconds") + TEST_ASSERT_NULL(locate(/obj/item/plasma_slag) in get_turf(fourth), "a moderate that shed its slag dropped a bead") + clean_up(turf_at(2, 2)) + +/// 8. The surgery starts only with slag, takes it out without damage and ends in tg's dressing step. +/datum/unit_test/voidcrew_plasma_wounds/surgery + +/datum/unit_test/voidcrew_plasma_wounds/surgery/Run() + var/surgery_type = text2path("/datum/surgery/excise_slag") + var/step_type = text2path("/datum/surgery_step/excise_slag") + TEST_ASSERT_NOTNULL(surgery_type, "/datum/surgery/excise_slag does not exist") + TEST_ASSERT_NOTNULL(step_type, "/datum/surgery_step/excise_slag does not exist") + + var/mob/living/carbon/human/consistent/patient = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + var/mob/living/carbon/human/consistent/surgeon = allocate(/mob/living/carbon/human/consistent, turf_at(1, 1)) + surgeon.zone_selected = BODY_ZONE_R_ARM + var/obj/item/bodypart/arm = patient.get_bodypart(BODY_ZONE_R_ARM) + var/datum/wound/burn/flesh/plasma/burn = give_burn(arm, "critical") + TEST_ASSERT_NOTNULL(burn, "no critical plasma burn") + + var/datum/surgery/surgery = new surgery_type(patient, BODY_ZONE_R_ARM, arm) + TEST_ASSERT(surgery.can_start(surgeon, patient), "the slag surgery could not start on a slagged burn") + TEST_ASSERT_EQUAL(surgery.operated_wound, burn, "the slag surgery is not working on the plasma burn") + TEST_ASSERT_EQUAL(surgery.steps[1], step_type, "the slag surgery does not start with its own step") + TEST_ASSERT_EQUAL(surgery.steps[length(surgery.steps)], /datum/surgery_step/dress, "the slag surgery does not end in tg's dressing step") + + var/datum/surgery_step/excise = new step_type + TEST_ASSERT(excise.repeatable, "the slag step can't be repeated") + var/obj/item/hemostat/hemostat = allocate(/obj/item/hemostat) + var/start_slag = burn.slag + var/start_brute = arm.brute_dam + excise.success(surgeon, patient, BODY_ZONE_R_ARM, hemostat, surgery) + TEST_ASSERT_EQUAL(burn.slag, start_slag - PLASMA_WOUND_SURGERY_SLAG, "a slag step took out [start_slag - burn.slag] slag") + TEST_ASSERT_EQUAL(arm.brute_dam, start_brute, "a successful slag step did brute damage") + excise.failure(surgeon, patient, BODY_ZONE_R_ARM, hemostat, surgery) + TEST_ASSERT(arm.brute_dam > start_brute, "a failed slag step did no damage") + var/steps_left = CEILING(burn.slag / PLASMA_WOUND_SURGERY_SLAG, 1) + 1 + while(burn.slag > 0 && steps_left-- > 0) + excise.success(surgeon, patient, BODY_ZONE_R_ARM, hemostat, surgery) + TEST_ASSERT_EQUAL(burn.slag, 0, "the slag step did not take the last of the slag out") + TEST_ASSERT(!QDELETED(burn), "taking the slag out by surgery deleted the burn before dressing") + qdel(excise) + qdel(surgery) + + // No slag, no surgery. + var/datum/surgery/check = new surgery_type + TEST_ASSERT(!check.can_start(surgeon, patient), "the slag surgery could start on a burn with no slag") + qdel(check) + clean_up(turf_at(2, 1)) + +/// 9. A fresh critical tops blood plasma up to its dose, and a second adds nothing while it's there. +/datum/unit_test/voidcrew_plasma_wounds/blood_plasma + +/datum/unit_test/voidcrew_plasma_wounds/blood_plasma/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + TEST_ASSERT_NOTNULL(give_burn(victim.get_bodypart(BODY_ZONE_R_ARM), "critical"), "no critical plasma burn") + var/dose = victim.reagents.get_reagent_amount(/datum/reagent/toxin/plasma) + TEST_ASSERT(abs(dose - PLASMA_WOUND_BLOOD_PLASMA) < 0.05, "a fresh critical left [dose]u of plasma in the blood") + TEST_ASSERT_NOTNULL(give_burn(victim.get_bodypart(BODY_ZONE_L_LEG), "critical"), "no second critical plasma burn") + dose = victim.reagents.get_reagent_amount(/datum/reagent/toxin/plasma) + TEST_ASSERT(abs(dose - PLASMA_WOUND_BLOOD_PLASMA) < 0.05, "a second critical took the blood to [dose]u of plasma") + + var/mob/living/carbon/human/consistent/dosed = allocate(/mob/living/carbon/human/consistent, turf_at(2, 2)) + dosed.reagents.add_reagent(/datum/reagent/toxin/plasma, 1) + TEST_ASSERT_NOTNULL(give_burn(dosed.get_bodypart(BODY_ZONE_R_ARM), "critical"), "no critical plasma burn") + dose = dosed.reagents.get_reagent_amount(/datum/reagent/toxin/plasma) + TEST_ASSERT(abs(dose - PLASMA_WOUND_BLOOD_PLASMA) < 0.05, "a critical on 1u of plasma left [dose]u, not a top-up") + + var/mob/living/carbon/human/consistent/severe = allocate(/mob/living/carbon/human/consistent, turf_at(2, 3)) + TEST_ASSERT_NOTNULL(give_burn(severe.get_bodypart(BODY_ZONE_R_ARM), "severe"), "no severe plasma burn") + TEST_ASSERT_EQUAL(severe.reagents.get_reagent_amount(/datum/reagent/toxin/plasma), 0, "a severe put plasma in the blood") + +/// 10. The rifle melts a limb with a slagged critical in one bolt, and not once the slag is out; a tg +/// critical no longer sets it up; the worn-through rule is unchanged. A hit that melts a limb plays burst C. +/datum/unit_test/voidcrew_plasma_wounds/burn_through_melt + +/datum/unit_test/voidcrew_plasma_wounds/burn_through_melt/Run() + var/turf/open/from = turf_at(1, 1) + var/mob/living/carbon/human/consistent/medic = allocate(/mob/living/carbon/human/consistent, turf_at(1, 2)) + + var/mob/living/carbon/human/consistent/slagged = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + var/obj/item/bodypart/leg = slagged.get_bodypart(BODY_ZONE_R_LEG) + TEST_ASSERT_NOTNULL(give_burn(leg, "critical"), "no critical plasma burn") + leg.heal_damage(brute = 200, burn = 200) + TEST_ASSERT(plasma_burn_through_ready(leg), "a slagged critical is not ready to burn through") + var/obj/projectile/energy/plasma_bolt/bolt = make_bolt(/obj/projectile/energy/plasma_bolt/heavy, from, BODY_ZONE_R_LEG) + bolt.damage = 5 + slagged.bullet_act(bolt, BODY_ZONE_R_LEG) + TEST_ASSERT_NULL(slagged.get_bodypart(BODY_ZONE_R_LEG), "a rifle bolt left a leg with a slagged critical attached") + var/obj/effect/temp_visual/plasma_burst/melt_burst = locate() in get_turf(slagged) + TEST_ASSERT_EQUAL(melt_burst?.look, "C", "the hit that melted a leg did not play the burn-through") + + var/mob/living/carbon/human/consistent/cleaned = allocate(/mob/living/carbon/human/consistent, turf_at(2, 2)) + var/obj/item/bodypart/clean_leg = cleaned.get_bodypart(BODY_ZONE_R_LEG) + var/datum/wound/burn/flesh/plasma/critical = give_burn(clean_leg, "critical") + TEST_ASSERT_NOTNULL(critical, "no critical plasma burn") + critical.remove_slag(critical.slag, medic) + clean_leg.heal_damage(brute = 200, burn = 200) + TEST_ASSERT(!plasma_burn_through_ready(clean_leg), "a critical with its slag out is still ready to burn through") + TEST_ASSERT(plasma_limb_burned_through(clean_leg), "a critical with its slag out no longer counts as burned through for the burst") + bolt = make_bolt(/obj/projectile/energy/plasma_bolt/heavy, from, BODY_ZONE_R_LEG) + bolt.damage = 5 + cleaned.bullet_act(bolt, BODY_ZONE_R_LEG) + TEST_ASSERT_NOTNULL(cleaned.get_bodypart(BODY_ZONE_R_LEG), "a rifle bolt melted a leg whose slag was cut out") + + var/mob/living/carbon/human/consistent/fire_burned = allocate(/mob/living/carbon/human/consistent, turf_at(2, 3)) + var/obj/item/bodypart/fire_leg = fire_burned.get_bodypart(BODY_ZONE_R_LEG) + fire_leg.force_wound_upwards(/datum/wound/burn/flesh/critical) + fire_leg.heal_damage(brute = 200, burn = 200) + TEST_ASSERT_NOTNULL(fire_leg.get_wound_type(/datum/wound/burn/flesh/critical), "could not give a leg a tg critical burn") + TEST_ASSERT(!plasma_burn_through_ready(fire_leg), "a tg critical burn is ready for the rifle's burn-through") + bolt = make_bolt(/obj/projectile/energy/plasma_bolt/heavy, from, BODY_ZONE_R_LEG) + bolt.damage = 5 + fire_burned.bullet_act(bolt, BODY_ZONE_R_LEG) + TEST_ASSERT_NOTNULL(fire_burned.get_bodypart(BODY_ZONE_R_LEG), "a rifle bolt melted a leg with only a tg critical burn") + + // Worn through: 15 before + 32 = 47 of 50, inside the pistol's margin. + var/mob/living/carbon/human/consistent/hurt = allocate(/mob/living/carbon/human/consistent, turf_at(3, 1)) + var/obj/item/bodypart/hurt_arm = hurt.get_bodypart(BODY_ZONE_L_ARM) + hurt_arm.receive_damage(brute = 15, wound_bonus = CANT_WOUND) + hurt.bullet_act(make_bolt(/obj/projectile/energy/plasma_bolt, from, BODY_ZONE_L_ARM), BODY_ZONE_L_ARM) + TEST_ASSERT_NULL(hurt.get_bodypart(BODY_ZONE_L_ARM), "a pistol bolt left a hurt arm at 47/50 attached") + var/obj/effect/temp_visual/plasma_burst/worn_burst = locate() in get_turf(hurt) + TEST_ASSERT_EQUAL(worn_burst?.look, "C", "the hit that melted a worn-through arm did not play the burn-through") + clean_up(from) + +/// 11. A bolt sears the cuts on the limb it hits: a moderate cut closes, a critical drops to severe. +/datum/unit_test/voidcrew_plasma_wounds/sear_cuts + +/datum/unit_test/voidcrew_plasma_wounds/sear_cuts/Run() + var/turf/open/from = turf_at(1, 1) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + var/obj/item/bodypart/arm = victim.get_bodypart(BODY_ZONE_R_ARM) + arm.force_wound_upwards(/datum/wound/slash/flesh/moderate) + TEST_ASSERT_NOTNULL(arm.get_wound_type(/datum/wound/slash/flesh/moderate), "could not give an arm a moderate cut") + var/obj/projectile/energy/plasma_bolt/bolt = make_bolt(/obj/projectile/energy/plasma_bolt, from, BODY_ZONE_R_ARM) + bolt.damage = 5 + victim.bullet_act(bolt, BODY_ZONE_R_ARM) + TEST_ASSERT_NULL(arm.get_wound_type(/datum/wound/slash), "a plasma bolt did not close a moderate cut on the arm it hit") + + var/obj/item/bodypart/leg = victim.get_bodypart(BODY_ZONE_L_LEG) + leg.force_wound_upwards(/datum/wound/slash/flesh/critical) + TEST_ASSERT_NOTNULL(leg.get_wound_type(/datum/wound/slash/flesh/critical), "could not give a leg a critical cut") + bolt = make_bolt(/obj/projectile/energy/plasma_bolt, from, BODY_ZONE_L_LEG) + bolt.damage = 5 + victim.bullet_act(bolt, BODY_ZONE_L_LEG) + var/datum/wound/slash/flesh/seared = leg.get_wound_type(/datum/wound/slash) + TEST_ASSERT_EQUAL(seared?.type, /datum/wound/slash/flesh/severe, "a plasma bolt on a critical cut left [seared?.type]") + clean_up(from) + +/// 12. Robotic, skeleton and plasmaman limbs get no plasma burn. +/datum/unit_test/voidcrew_plasma_wounds/no_flesh + +/datum/unit_test/voidcrew_plasma_wounds/no_flesh/Run() + var/obj/projectile/energy/plasma_bolt/bolt = make_bolt(/obj/projectile/energy/plasma_bolt, turf_at(1, 1)) + + var/mob/living/carbon/human/consistent/cyborg_arm = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + var/obj/item/bodypart/arm/right/robot/robot_arm = new + robot_arm.replace_limb(cyborg_arm, TRUE) + var/obj/item/bodypart/fitted = cyborg_arm.get_bodypart(BODY_ZONE_R_ARM) + TEST_ASSERT_EQUAL(fitted, robot_arm, "could not fit a robot arm") + fitted.check_wounding(WOUND_BURN, 1, 199, 0, null, bolt, FALSE) + TEST_ASSERT_NULL(fitted.get_wound_type(/datum/wound/burn/flesh/plasma), "a robot arm got a plasma burn") + + for(var/species_type in list(/datum/species/skeleton, /datum/species/plasmaman)) + var/mob/living/carbon/human/consistent/bony = allocate(/mob/living/carbon/human/consistent, turf_at(2, 2)) + bony.set_species(species_type) + for(var/zone in list(BODY_ZONE_R_ARM, BODY_ZONE_CHEST)) + var/obj/item/bodypart/part = bony.get_bodypart(zone) + TEST_ASSERT_NOTNULL(part, "a [species_type] has no [zone]") + part.check_wounding(WOUND_BURN, 1, 199, 0, null, bolt, FALSE) + TEST_ASSERT_NULL(part.get_wound_type(/datum/wound/burn/flesh/plasma), "a [species_type] [zone] got a plasma burn") + +/// 13. The burn-through burst plays on the hit that gives the limb a critical, and not on the next hit. +/// The existing burst test in voidcrew_plasma_effects.dm covers the killing hit. +/datum/unit_test/voidcrew_plasma_wounds/burst_on_critical + +/datum/unit_test/voidcrew_plasma_wounds/burst_on_critical/Run() + var/turf/open/from = turf_at(1, 1) + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + var/obj/item/bodypart/chest = victim.get_bodypart(BODY_ZONE_CHEST) + TEST_ASSERT(!plasma_limb_burned_through(chest), "an unhurt chest counts as burned through") + var/obj/projectile/energy/plasma_bolt/heavy/bolt = make_bolt(/obj/projectile/energy/plasma_bolt/heavy, from, BODY_ZONE_CHEST) + bolt.damage = 10 + bolt.wound_bonus = 400 + victim.bullet_act(bolt, BODY_ZONE_CHEST) + TEST_ASSERT(victim.stat != DEAD, "the test hit killed the body") + var/datum/wound/burn/flesh/plasma/critical = chest.get_wound_type(/datum/wound/burn/flesh/plasma) + TEST_ASSERT(critical?.severity == WOUND_SEVERITY_CRITICAL, "a huge-bonus rifle bolt left no plasma critical on the chest") + TEST_ASSERT(plasma_limb_burned_through(chest), "a chest with a plasma critical does not count as burned through") + var/obj/effect/temp_visual/plasma_burst/first = locate() in get_turf(victim) + TEST_ASSERT_EQUAL(first?.look, "C", "the hit that gave a critical did not play the burn-through") + + var/obj/projectile/energy/plasma_bolt/heavy/again = make_bolt(/obj/projectile/energy/plasma_bolt/heavy, from, BODY_ZONE_CHEST) + again.damage = 10 + victim.bullet_act(again, BODY_ZONE_CHEST) + var/obj/effect/temp_visual/plasma_burst/second + for(var/obj/effect/temp_visual/plasma_burst/burst in get_turf(victim)) + if(burst != first) + second = burst + TEST_ASSERT_NOTNULL(second, "the second hit made no burst") + if(victim.stat != DEAD) + TEST_ASSERT_NOTEQUAL(second.look, "C", "a hit on a chest already critical played the burn-through again") + clean_up(from) + +/// 14. Healing each tier leaves a scar whose text comes from the plasma scar file. +/datum/unit_test/voidcrew_plasma_wounds/scars + +/datum/unit_test/voidcrew_plasma_wounds/scars/Run() + var/list/scar_lines = json_decode(file2text("strings/[PLASMA_SCAR_FILE]")) + TEST_ASSERT(islist(scar_lines), "the plasma scar file could not be read") + var/mob/living/carbon/human/consistent/medic = allocate(/mob/living/carbon/human/consistent, turf_at(1, 1)) + for(var/tier in list("moderate", "severe", "critical")) + var/keyword = "plasma[tier]" + var/list/lines = scar_lines[keyword] + TEST_ASSERT(length(lines), "the plasma scar file has no [keyword] lines") + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent, turf_at(2, 1)) + var/obj/item/bodypart/arm = victim.get_bodypart(BODY_ZONE_R_ARM) + var/datum/wound/burn/flesh/plasma/burn = give_burn(arm, tier) + TEST_ASSERT_NOTNULL(burn, "no [tier] plasma burn") + TEST_ASSERT_EQUAL(burn.scar_keyword, keyword, "a [tier] plasma burn uses scar keyword [burn.scar_keyword]") + burn.remove_slag(burn.slag, medic) + var/scars_before = LAZYLEN(arm.scars) + qdel(burn) + TEST_ASSERT_EQUAL(LAZYLEN(arm.scars), scars_before + 1, "a healed [tier] plasma burn left no scar") + var/datum/scar/scar = arm.scars[LAZYLEN(arm.scars)] + TEST_ASSERT(scar.description in lines, "a healed [tier] plasma burn left the scar \"[scar.description]\", not one from the plasma file") + clean_up(turf_at(2, 1)) diff --git a/wiki/content/plasma-and-laser-guns.md b/wiki/content/plasma-and-laser-guns.md new file mode 100644 index 00000000000..45cd712749f --- /dev/null +++ b/wiki/content/plasma-and-laser-guns.md @@ -0,0 +1,103 @@ +--- +title: Plasma & Frontier Lasers +category: Danger +order: 1.2 +blurb: The Kestrel and Hornet lasers, the Cinder and Furnace plasma guns, plasma flasks, plasma burns and keeping a Furnace cool. +--- + +Four frontier guns turn up at traders and in loot. The Kestrel and Hornet are ordinary laser guns that charge in a recharger. The Cinder pistol and Furnace rifle fire bolts of burning plasma, and you feed them by hand with plasma sheets or ore. See [Weapons & Gear](weapons-and-gear.md) for the rest of the armoury. + +## The guns + +| Gun | Kind | Ammunition | Shots | +|---|---|---|---| +| Kestrel laser pistol | Light laser pistol, fits a belt | Recharger | 20 | +| Hornet laser SMG | Fully automatic laser, worn on the back | Recharger | 20 | +| Cinder plasma pistol | Plasma sidearm, fits a belt | Plasma flask | 15 a flask | +| Furnace plasma rifle | Heavy plasma rifle, worn on the back, needs both hands | Plasma flask | 12 a flask | + +The Kestrel hits lighter than a standard laser gun. The Hornet fires the standard laser beam for as long as you hold the trigger. + +The **Old Furnace** is a Furnace with a hand-wound coil. It runs cooler and shoots straighter. Nobody sells it. + +## Where to get them + +| Item | Counter | Price | Reputation required | +|---|---|---|---| +| Kestrel laser pistol | Sarge, Quartermain | 900 cr | 4 | +| Cinder plasma pistol | Sarge, Quartermain | 2,000 cr | 4 | +| Plasma flask (empty) | Sarge, Quartermain | 150 cr | 4 | +| Hornet laser SMG | Vex, Undertow | 3,500 cr | 4 | +| Furnace plasma rifle, when stocked | Vex, Undertow | 5,000 cr + 1 voucher | 11 | + +Each line restores one unit per hour. Guns bought at a counter come with a full flask seated. See [Trader Outposts](trader-outposts.md) and [Crews & Reputation](zone-reputation.md). + +In loot, Kestrels turn up in green armory and freight caches, Hornets and Cinders in yellow caches, and Furnaces in red plunder, where an Old Furnace is a rare find. A plasma gun found in loot carries a flask that is at most half full. Pirates in yellow and red zones fire plasma guns, and in red zones Director Prime and the Chief Auditor carry Furnaces and can drop the Old Furnace. They never drop it anywhere else. See [Pirates & NPC Ships](pirates-and-npc-ships.md). + +Plasma and frontier guns cannot be crafted, printed or ordered through cargo, and no one buys them back. + +## Plasma flasks + +A plasma gun fires from the flask seated behind its grip. + +- **Load:** use a flask on the gun. Any flask already seated comes out first. +- **Unload:** Alt-click the gun to take the flask out. The plasma left in the gun goes back into the flask. +- **Fill:** use plasma sheets or plasma ore on a flask, or on the gun to fill the seated flask. One sheet or ore goes in at a time while you stand still. A full flask takes **3 sheets** or **6 ore**. +- **Check:** examine a flask to see roughly how full it is. + +A flask only takes plasma sheets and ore. Liquid plasma, plasma gas and other flasks do nothing. Plasma never comes back out of a flask: it leaves only as shots or vented gas. Rechargers do not work on plasma guns, and an EMP does not drain them. + +Plasma ore turns up in every zone, so a crew that mines can keep a plasma gun fed. One sheet holds as much as two ore. + +## Plasma bolts + +Plasma bolts are slower than lasers and bullets, so a target far away can step out of the way. + +- **Armour:** bolts are energy hits. Ablative armour and reflective vests cannot reflect them. +- **Burns:** bolts leave plasma burns that get worse with every hit on the same spot. They do not set people on fire. See [Plasma burns](#plasma-burns) below. +- **Cuts:** a bolt sears the cuts on the limb it hits, so they bleed less. +- **Limbs:** an arm or leg that is already badly hurt melts away under a bolt. A rifle bolt also melts an arm or leg with a Plasma Burn-Through that still has slag in it. One bolt never melts a healthy limb. Heads and chests never melt. +- **Glass and doors:** bolts stop at windows and break them. They break windows, tables, other structures and doors faster than other guns: a shuttle window takes two rifle bolts or four pistol bolts, an airlock about four rifle bolts. Grilles and blast doors shrug them off, and mechs and machines take no extra damage. +- **Fuel gas:** a bolt passing through plasma, tritium or hydrogen in the air can light it. Clean air never lights. +- **Canisters:** bolts do not damage gas canisters. + +Hits leave scorch marks on the floor that stay until they are cleaned. Scorch marks on a ship are gone after a ship save. + +## Plasma burns + +A plasma bolt that wounds leaves a plasma burn instead of an ordinary burn. Molten slag sticks in the wound. + +| Burn | What it is | +|---|---| +| Slag Spatter Burns | Flecks of slag on the skin. They fall off by themselves a little while after they cool. | +| Embedded Slag Burns | Slag fused into the flesh. It stays until someone cuts it out. | +| Plasma Burn-Through | Slag fused down to the bone. A leg limps and an arm fumbles until the slag is out, and a little plasma gets into the blood. | + +- **Hot slag** keeps burning the limb for up to half a minute after the hit. It doesn't cool while the person is on fire. +- **A burn with slag in it won't heal.** Ointment, mesh, salves, cryo and rest can't close it. Treatment given early isn't wasted: it takes effect once the slag is out. +- **It still gets infected** like any burn left alone, so don't leave it for long. + +### Treating a plasma burn + +1. **Cool it.** Water stops the burning at once: a fire extinguisher, a shower, a splash from a sink or bucket, or anything wet. Cold air, a cryo tube or a stasis bed cool it more slowly. +2. **Cut the slag out.** Aim at the burned limb and use a hemostat on the patient, with combat mode off. Wirecutters or a scalpel work too, but slower and rougher. Each cut does a little brute damage, and cutting hot slag makes the patient scream, so cool it first. A deep burn takes a few cuts. Scanning the wound with a health or wound analyzer first makes the job quicker, and working on yourself is slower. Take off a hardsuit or MODsuit covering the limb first. +3. **Cover it.** Then treat it like any other burn: ointment, regenerative mesh, synthflesh or gauze. + +In surgery, **Excise plasma slag** takes the slag out of a resting patient without the brute damage, and dresses the burn in the same operation. + +Slag cut out of a deep burn comes out as a small bead of black glass. It's worth nothing. + +Cutting the slag out of a Plasma Burn-Through also saves the limb from the rifle's next bolt. + +## Heat and venting (Furnace only) + +The Furnace and the Old Furnace heat up as they fire, and their coils glow brighter as they do. They cool by themselves, fastest in cold air at normal pressure, slower in thin air, and slowest in vacuum. Standing in fire or on lava heats them up. + +- A full flask fired as fast as you can in ship air will not overheat a Furnace. In vacuum it will. +- An overheated rifle **hisses and spits gas**: it dumps some plasma onto your tile and the gun takes damage. A rifle that keeps overheating eventually breaks. +- **Vent:** use the rifle in your hand to dump some plasma and cool it quickly. You can't fire for a moment afterwards, and you have to wait a few seconds between vents. A rifle with no plasma left has nothing to vent. + +!!! warning "Vented gas is hot" + Vented and spat plasma comes out hot, and hot plasma can catch fire in breathable air. Vent in vacuum when you can. + +The Cinder does not heat up.