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/tgstation.dme b/tgstation.dme index 34c6bdf341f..5164b8c5c11 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -36,6 +36,7 @@ #include "voidcrew\_DEFINES\core_integration.dm" // VOIDCREW EDIT: shared hooks must precede upstream macro use. #include "voidcrew\_DEFINES\hyperviolence.dm" // VOIDCREW EDIT: shared hooks must precede upstream macro use (hit lines in code/datums/wounds). #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" @@ -6933,6 +6934,7 @@ #include "voidcrew\edits\modules\power\turbine\turbine.dm" #include "voidcrew\edits\modules\projectiles\boxes_magazines\_box_magazine.dm" #include "voidcrew\edits\modules\projectiles\projectile_npc_allies.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" @@ -7611,6 +7613,20 @@ #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\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 30e11abdea1..539cad18e42 100644 --- a/tools/gear_catalog/ratings/guns.json +++ b/tools/gear_catalog/ratings/guns.json @@ -1419,6 +1419,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", @@ -1701,6 +1721,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..6e478a926aa --- /dev/null +++ b/voidcrew/_DEFINES/plasma_weapons.dm @@ -0,0 +1,97 @@ +// 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" 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 caba26f11c7..bc5907b8d08 100644 --- a/voidcrew/modules/loot/loot_guns.dm +++ b/voidcrew/modules/loot/loot_guns.dm @@ -49,6 +49,7 @@ GLOBAL_LIST_INIT(loot_gun_pools, list( /obj/item/gun/ballistic/rifle/boltaction/surplus = 10, /obj/item/gun/ballistic/rifle/nt_m96 = 10, /obj/item/gun/ballistic/automatic/battle_rifle = 10, + /obj/item/gun/energy/frontier_laser/pistol = 10, ), LOOT_WEAPON_YELLOW = list( /obj/item/gun/energy/laser = 10, @@ -57,6 +58,8 @@ GLOBAL_LIST_INIT(loot_gun_pools, list( /obj/item/gun/ballistic/shotgun/automatic/dual_tube/deadly = 10, /obj/item/gun/ballistic/automatic/resolution = 10, /obj/item/gun/ballistic/automatic/woodsman = 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, @@ -99,12 +102,15 @@ GLOBAL_LIST_INIT(loot_gun_pools, list( /obj/item/gun/ballistic/automatic/skm = 10, /obj/item/gun/ballistic/shotgun/flaming_arrow/absolution = 10, /obj/item/gun/ballistic/shotgun/flaming_arrow/conflagration = 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/ballistic/automatic/heater/scorcher = 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( @@ -113,6 +119,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..51d590b85a7 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 @@ -205,6 +206,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 diff --git a/voidcrew/modules/npc_ships/code/pirate_guns.dm b/voidcrew/modules/npc_ships/code/pirate_guns.dm index 80ba300a4c7..ec36ee285b8 100644 --- a/voidcrew/modules/npc_ships/code/pirate_guns.dm +++ b/voidcrew/modules/npc_ships/code/pirate_guns.dm @@ -18,6 +18,8 @@ GLOBAL_LIST_INIT(pirate_gun_handlings, init_pirate_gun_handlings()) . = list() // Most specific first: pistols are automatic guns too, and every gun type is a /obj/item/gun. for(var/handling_type in list( + /datum/pirate_gun_handling/plasma/rifle, // plasma_weapons module (plasma_pirates.dm) + /datum/pirate_gun_handling/plasma, /datum/pirate_gun_handling/shotgun, /datum/pirate_gun_handling/pistol, /datum/pirate_gun_handling/smg, 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..aa5f9f224ce --- /dev/null +++ b/voidcrew/modules/plasma_weapons/icons/attributions.txt @@ -0,0 +1,29 @@ +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). 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..beb1bd4e9d7 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/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..78ed30c977c --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_ash.dm @@ -0,0 +1,22 @@ +// 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. 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 critical burn. 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 in GLOB.limb_zones) || limb.body_zone != ash_zone) + 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 + plasma_melt_limb(limb, attack_direction) // tg's WOUND_BURN loss, plus the melt picture (plasma_melt.dm) diff --git a/voidcrew/modules/plasma_weapons/plasma_bolt.dm b/voidcrew/modules/plasma_weapons/plasma_bolt.dm new file mode 100644 index 00000000000..e0b4f8c42af --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_bolt.dm @@ -0,0 +1,130 @@ +// 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 = 'sound/items/weapons/effects/searwall.ogg' + 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 + +/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) + 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) + animate(src, pixel_x = pixel_x + dx * stop, pixel_y = pixel_y + dy * stop, 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) + . = ..() + 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, and check for a melt after it. + var/obj/item/bodypart/limb = victim.get_bodypart(check_zone(def_zone)) + if(!limb || !(limb.body_zone in GLOB.limb_zones)) + return + ash_zone = limb.body_zone + ash_prior_damage = limb.get_damage() + ash_prior_critical = !!limb.get_wound_type(/datum/wound/burn/flesh/critical) + RegisterSignal(victim, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(plasma_check_ash), override = TRUE) + +/// 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' 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..aae21e258f1 --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_effects.dm @@ -0,0 +1,766 @@ +// 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 far up a shield splash behind a standing body sits, so it shows over the shoulders +#define PLASMA_SHIELD_BACK_RAISE 11 + +/// 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 + var/at_shield = FALSE + if(ishuman(target)) + var/mob/living/carbon/human/victim = target + var/side = REVERSE_DIR(angle2dir_cardinal(angle)) + if(blocked < 100) + new /obj/effect/temp_visual/plasma_burst(target_turf, victim, src, side) + return + plasma_take_back_block_star(target_turf) + behind_body = (side == NORTH && !victim.lying_angle) + // A held shield takes it on the shooter's side of the body + var/obj/item/shield/shield = locate() in victim.held_items + if(shield) + at_shield = TRUE + // Facing a shot from the south, the shield is out to one side of the chest, on the hand it is in + var/list/hit = plasma_burst_hit(victim, (side == SOUTH && victim.dir == SOUTH && !victim.lying_angle) ? (IS_LEFT_INDEX(victim.get_held_index_of_item(shield)) ? EAST : WEST) : side, "c") + splash_x = hit[4] - 16 + splash_y = hit[5] - 16 + // Behind a standing body, at the shoulders, so it shows over them + if(behind_body) + splash_y += PLASMA_SHIELD_BACK_RAISE + // The bolt's sprite stops on the splash (above the head on a back hit, as a burst does), and the splash + // waits for it there + 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, at_shield), 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" + playsound(target_turf, 'sound/effects/glass/glasshit.ogg', 50, TRUE, -1) + + 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, at_shield) + if(!where) + return + var/obj/effect/temp_visual/plasma_splash/splash = new(where, px, py, "splash_armour", turn_angle, behind_body) + if(at_shield && !QDELETED(splash)) + // pixel_w/pixel_z keep it on the body's screen row, so the layer alone decides front or back + splash.pixel_w = splash.pixel_x + splash.pixel_z = splash.pixel_y + splash.pixel_x = 0 + splash.pixel_y = 0 + +/// 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, 'sound/items/weapons/plasma_cutter.ogg', 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 + +/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 = !!limb?.get_wound_type(/datum/wound/burn/flesh/critical) + 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 && !!limb.get_wound_type(/datum/wound/burn/flesh/critical) + 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() + look = burned_through ? PLASMA_BURST_THROUGH : plasma_burst_pick(hit_body) + // The burn-through sizzles on top of the hit + if(burned_through) + playsound(hit_body, pick('voidcrew/modules/plasma_weapons/sound/plasma_burnthrough_1.ogg', 'voidcrew/modules/plasma_weapons/sound/plasma_burnthrough_2.ogg'), 60, TRUE) + addtimer(CALLBACK(src, PROC_REF(show), WEAKREF(hit_body)), PLASMA_BURST_DELAY) + +/// 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_BACK_RAISE diff --git a/voidcrew/modules/plasma_weapons/plasma_flask.dm b/voidcrew/modules/plasma_weapons/plasma_flask.dm new file mode 100644 index 00000000000..cb02770bd20 --- /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, 'sound/items/tools/welder.ogg', 20, TRUE) + update_appearance() + if(added) + playsound(src, 'sound/machines/click.ogg', 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..2ac62ed888c --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_guns.dm @@ -0,0 +1,229 @@ +// 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, 'sound/items/tools/welder.ogg', 20, TRUE) + if(added) + playsound(src, 'sound/machines/click.ogg', 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) + 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..40299272fa9 --- /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, 'sound/effects/gas_hissing.ogg', 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, 'sound/machines/hiss.ogg', 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..4b45f0abf7f --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_melt.dm @@ -0,0 +1,846 @@ +// 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 + GLOB.plasma_melt_no_splatter = WEAKREF(victim) + // Hyperviolence (#512): a melt is dry. hv_sever_spray holds back the spray a lost limb throws and the reel + // a body gives (wire_seams.dm), and the stump is drawn seared, as a hot wire's is. TRAIT_HV_CLEAN_CUT + // isn't used: mid clean cut the wire's own apply_dismember() would take the limb instead of the melt. + var/old_sever_spray = victim.hv_sever_spray + victim.hv_sever_spray = 0 + LAZYSET(victim.hv_stump_burnt, zone, TRUE) + melt.apply_dismember(limb, WOUND_BURN, FALSE, attack_direction) + GLOB.plasma_melt_no_splatter = null + if(!QDELETED(victim)) + victim.hv_sever_spray = old_sever_spray + if(victim.get_bodypart(zone) == limb) + LAZYREMOVE(victim.hv_stump_burnt, zone) + else + // drop_limb() marked the stump fresh (wet); a seared stump never was + LAZYREMOVE(victim.hv_stump_torn_at, zone) + victim.hv_update_gore_overlays() + // 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(ishuman(victim)) + // Hyperviolence (#512) cut the sleeve the moment the arm came off; it stays until the melt burns it + victim.hv_update_gore_overlays() + 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 + +/// 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)) + // with the melt gone, #512 masks the arm like any other lost arm (or not at all, if it came back) + sleeve_burned = FALSE + if(ishuman(victim) && !QDELETED(victim)) + victim.hv_update_gore_overlays() + 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. Hyperviolence (#512) does the cutting, with the mask it +/// uses for every lost arm (hv_mask_sleeves()); that mask leaves the chest's own pixels alone, so no chest is bared. +/datum/plasma_melt/proc/burn_sleeve() + if(sleeve_burned || !ishuman(victim)) + return + sleeve_burned = TRUE + var/mob/living/carbon/human/wearer = victim + wearer.hv_update_gore_overlays() + +// Hyperviolence (#512) cuts a lost arm's sleeve off the jumpsuit and suit at once (sleeve_mask.dm). A melting arm keeps +// its sleeve until the sleeve burns (burn_sleeve()), so the melt plays on the arm as it was dressed. Under #512 this is the +// only sleeve cut: #540's own jumpsuit and coat masks are left out here, and the redraws go through +// hv_update_gore_overlays() so #512's record of what it last cut stays true. +/mob/living/carbon/human/hv_sleeve_mask_zones() + . = ..() + for(var/zone in plasma_melts) + var/datum/plasma_melt/melt = plasma_melts[zone] + if(melt.is_arm() && !melt.sleeve_burned) + . -= zone + +/// 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 + +// ===== 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..c104359b442 --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_pinch.dm @@ -0,0 +1,5 @@ +// Package: P9 pinch. The barrel pinch when a plasma gun fires. + +/// The plasma ball, ring and flash at the barrel as `user` fires at `target` +/obj/item/gun/energy/plasma_weapon/proc/plasma_pinch(mob/living/user, atom/target) + return diff --git a/voidcrew/modules/plasma_weapons/plasma_pirates.dm b/voidcrew/modules/plasma_weapons/plasma_pirates.dm new file mode 100644 index 00000000000..44c32f902e4 --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_pirates.dm @@ -0,0 +1,381 @@ +// Package: P6 NPCs. Plasma pirates on human NPC bodies (PR #512). +// +// Human pirates hold real guns chosen by their faction's outfit table (human_npc_outfits_pirates.dm) and +// fire them through /datum/pirate_gun_handling (npc_ships/code/pirate_guns.dm). Here: +// - yellow- and red-zone gunners of the six gun-carrying factions can be dressed with a Cinder, red-zone +// ones with a Furnace (weights as S12: Cinder 2, Furnace 1 next to the faction's own guns); green-zone +// pirates never carry plasma; +// - the red-zone Director Prime and Chief Auditor carry a Furnace; both may drop the Old Furnace, but +// only when met in a red zone; +// - a plasma gunner changes flasks for real (load_flask(), a do_after), carrying a spare; +// - a Furnace runs the real heat model in its hands, so the pirate minds it: it vents by hand only where +// the gas can't catch (near-vacuum or no oxygen), and anywhere else it holds fire and backs off once the +// coils are hot, until they cool. It never boils indoors. +// NPC gear is locked to the body (human_npc_gear.dm), so none of this becomes loot. + +/// Coil heat at which a pirate stops firing its Furnace where it can't vent +#define PIRATE_PLASMA_HOLD_HEAT 80 +/// Coil heat at which it starts firing again +#define PIRATE_PLASMA_RESUME_HEAT 40 +/// Coil heat at which it vents where venting is safe +#define PIRATE_PLASMA_VENT_HEAT 60 +/// Below this pressure (kPa), or this oxygen partial pressure, vented plasma can't catch +#define PIRATE_PLASMA_VENT_PRESSURE 1 +#define PIRATE_PLASMA_VENT_OXYGEN 1 +/// Blackboard: TRUE while it waits for its coils to cool +#define BB_PIRATE_PLASMA_COOLING "pirate_plasma_cooling" +/// It backs away from a target closer than this while its coils cool +#define PIRATE_PLASMA_BACK_OFF_RANGE 5 + +// ===== HANDLING ===== + +/// The Cinder: a pair every six seconds. +/datum/pirate_gun_handling/plasma + gun_type = /obj/item/gun/energy/plasma_weapon + shots = 2 + shot_gap = 0.5 SECONDS + volley_gap = 6 SECONDS + reload_lines = list("Changing flasks!", "Cover me!") + dry_lines = list("Flask's dry!", "Out of plasma!") + +/// The Furnace: one bolt every four seconds. +/datum/pirate_gun_handling/plasma/rifle + gun_type = /obj/item/gun/energy/plasma_weapon/rifle + shots = 1 + volley_gap = 4 SECONDS + +// ===== ARMING: flasks and heat ===== + +/// A plasma gun it holds, or null +/proc/pirate_plasma_held_gun(mob/living/carbon/npc) + for(var/obj/item/gun/energy/plasma_weapon/gun in npc.held_items) + return gun + return null + +/// A spare flask it carries with at least a shot in it for `gun`, or null +/proc/pirate_plasma_spare_flask(mob/living/carbon/npc, obj/item/gun/energy/plasma_weapon/gun) + var/obj/item/ammo_casing/energy/shot = length(gun.ammo_type) ? gun.ammo_type[gun.select] : null + var/needed = max(shot?.e_cost, 1) + for(var/obj/item/plasma_flask/flask in npc.get_all_contents_type(/obj/item/plasma_flask)) + if(flask == gun.flask || flask.loc == gun || flask.stored < needed) + continue + if(npc.is_holding(flask) || human_npc_can_draw(npc, flask)) + return flask + return null + +/// Whether plasma vented at `spot` can't catch: next to no air, or no oxygen in it +/proc/pirate_plasma_safe_to_vent(turf/spot) + var/datum/gas_mixture/air = spot?.return_air_readonly() + if(isnull(air)) + return TRUE + var/pressure = air.return_pressure() + if(pressure < PIRATE_PLASMA_VENT_PRESSURE) + return TRUE + return human_npc_partial_pressure(air, /datum/gas/oxygen, pressure) < PIRATE_PLASMA_VENT_OXYGEN + +/// Says one of `lines`, if it can talk +/proc/pirate_plasma_shout(mob/living/carbon/npc, list/lines) + if(length(lines) && human_npc_can_talk(npc)) + INVOKE_ASYNC(npc, TYPE_PROC_REF(/atom/movable, say), pick(lines)) + +// A plasma gunner minds its coils and changes its own flasks before the base arming (draw a knife when dry) +/datum/ai_planning_subtree/human_npc_arm/pirate_crew/SelectBehaviors(datum/ai_controller/controller, seconds_per_tick) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/energy/plasma_weapon/gun = pirate_plasma_held_gun(npc) + if(isnull(gun) || HAS_TRAIT(npc, TRAIT_HANDS_BLOCKED) || LAZYLEN(npc.do_afters)) + controller.clear_blackboard_key(BB_PIRATE_PLASMA_COOLING) + return ..() + . = pirate_plasma_mind_heat(controller, npc, gun) + if(.) + return + if(gun.can_shoot() || world.time < controller.blackboard[BB_HUMAN_NPC_ARMING_RETRY_AT]) + return ..() + if(!gun.can_fire_heat()) + // The second after a vent: it waits for its gun + controller.queue_behavior(/datum/ai_behavior/npc_combat_sense_hold) + return SUBTREE_RETURN_FINISH_PLANNING + if(isnull(pirate_plasma_spare_flask(npc, gun))) + return ..() + controller.queue_behavior(/datum/ai_behavior/pirate_plasma_reload) + if(prob(PIRATE_CREW_RELOAD_BARK_CHANCE)) + var/datum/pirate_gun_handling/handling = pirate_gun_handling_for(gun) + pirate_plasma_shout(npc, handling.reload_lines) + return SUBTREE_RETURN_FINISH_PLANNING + +/** + * A Furnace's heat, as its holder sees the coils glow. Where the gas can't catch it vents by hand once + * the coils are warm. Anywhere else it never vents: past PIRATE_PLASMA_HOLD_HEAT it stops firing and backs + * off from whoever it is fighting until the coils are down to PIRATE_PLASMA_RESUME_HEAT. Returns + * SUBTREE_RETURN_FINISH_PLANNING while it is busy with its gun. + */ +/datum/ai_planning_subtree/human_npc_arm/pirate_crew/proc/pirate_plasma_mind_heat(datum/ai_controller/controller, mob/living/carbon/npc, obj/item/gun/energy/plasma_weapon/gun) + if(!gun.uses_heat) + return + var/heat = gun.barrel_heat + var/cooling = controller.blackboard[BB_PIRATE_PLASMA_COOLING] + if(cooling && heat <= PIRATE_PLASMA_RESUME_HEAT) + controller.clear_blackboard_key(BB_PIRATE_PLASMA_COOLING) + cooling = FALSE + if(heat >= PIRATE_PLASMA_VENT_HEAT && world.time >= gun.next_vent_at && gun.chamber_mol() > 0 && pirate_plasma_safe_to_vent(get_turf(npc))) + controller.queue_behavior(/datum/ai_behavior/pirate_plasma_vent) + return SUBTREE_RETURN_FINISH_PLANNING + if(!cooling && heat < PIRATE_PLASMA_HOLD_HEAT) + return + if(!cooling) + controller.set_blackboard_key(BB_PIRATE_PLASMA_COOLING, TRUE) + pirate_plasma_shout(npc, list("She's cooking!", "Too hot!", "Cover me, she's cooking!")) + var/atom/target = controller.blackboard[BB_BASIC_MOB_CURRENT_TARGET] + if(!QDELETED(target) && get_dist(npc, target) < PIRATE_PLASMA_BACK_OFF_RANGE) + controller.queue_behavior(/datum/ai_behavior/step_away, BB_BASIC_MOB_CURRENT_TARGET) + else + controller.queue_behavior(/datum/ai_behavior/npc_combat_sense_hold) + return SUBTREE_RETURN_FINISH_PLANNING + +/// A player's vent: the gun in the active hand, used in hand (attack_self(), plasma_heat.dm). +/datum/ai_behavior/pirate_plasma_vent + action_cooldown = 0.5 SECONDS + +/datum/ai_behavior/pirate_plasma_vent/perform(seconds_per_tick, datum/ai_controller/controller) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/energy/plasma_weapon/gun = pirate_plasma_held_gun(npc) + if(isnull(gun) || !human_npc_ready_item(npc, gun)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + pirate_plasma_shout(npc, list("Venting!")) + if(!gun.attack_self(npc)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY | AI_BEHAVIOR_SUCCEEDED + +/** + * A player's flask change, one step per action: take the spare in the free hand, click the gun with it + * (load_flask(): a do_after, and the spent flask comes out). Fails, and the base arming draws a knife, if + * it has no spare or no hand free. + */ +/datum/ai_behavior/pirate_plasma_reload + action_cooldown = 0.8 SECONDS + /// Gives up after this long, so a flask that won't go in can't hold the NPC forever + var/give_up_after = 8 SECONDS + +/datum/ai_behavior/pirate_plasma_reload/setup(datum/ai_controller/controller) + . = ..() + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/energy/plasma_weapon/gun = pirate_plasma_held_gun(npc) + if(isnull(gun)) + return FALSE + controller.set_blackboard_key(BB_HUMAN_NPC_RELOAD_UNTIL, world.time + give_up_after) + npc.visible_message(span_notice("[npc] changes the flask in [gun].")) + +/datum/ai_behavior/pirate_plasma_reload/perform(seconds_per_tick, datum/ai_controller/controller) + var/mob/living/carbon/npc = controller.pawn + var/obj/item/gun/energy/plasma_weapon/gun = pirate_plasma_held_gun(npc) + if(isnull(gun) || world.time >= controller.blackboard[BB_HUMAN_NPC_RELOAD_UNTIL]) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(gun.can_shoot()) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_SUCCEEDED + var/obj/item/plasma_flask/flask = pirate_plasma_spare_flask(npc, gun) + if(isnull(flask)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + if(!npc.is_holding(flask)) + if(!human_npc_take_in_hand(npc, flask)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + return AI_BEHAVIOR_DELAY + if(!human_npc_ready_item(npc, flask)) + return AI_BEHAVIOR_INSTANT | AI_BEHAVIOR_FAILED + controller.ai_interact(target = gun, combat_mode = FALSE) + return AI_BEHAVIOR_DELAY + +/datum/ai_behavior/pirate_plasma_reload/finish_action(datum/ai_controller/controller, succeeded) + . = ..() + // A change that got nowhere isn't tried again straight away; the NPC fights with what it has. + controller.set_blackboard_key(BB_HUMAN_NPC_ARMING_RETRY_AT, succeeded ? 0 : world.time + 10 SECONDS) + +// ===== DRESSING ===== + +/// A spare flask, filled, for a pirate's pocket or belt +/obj/item/plasma_flask/full + stored = PLASMA_FLASK_CAPACITY + +/obj/item/plasma_flask/full/Initialize(mapload) + . = ..() + update_appearance() + +/datum/human_npc_outfit_table/pirate + /// This faction's Cinder gunner, for yellow- and red-zone crews. Null: the faction carries no plasma. + var/plasma_pistol_outfit + /// This faction's Furnace gunner, for red-zone crews + var/plasma_rifle_outfit + /// What its boss wears in a red zone, if that differs. Null: `boss` everywhere. + var/list/red_boss + /// Choice lists with the plasma gunners added, by "list ref-zone"; built on first use + var/list/plasma_choices + +// Yellow and red: a regular gunner may be a plasma gunner, and a red-zone boss may carry its Furnace. +/datum/human_npc_outfit_table/pirate/outfits_for(mob/living/carbon/human/npc/npc, zone) + . = ..() + if(zone != ZONE_YELLOW && zone != ZONE_RED) + return + var/rank = npc?.npc_rank || HUMAN_NPC_RANK_REGULAR + if(rank >= HUMAN_NPC_RANK_BOSS) + if(zone == ZONE_RED && length(red_boss)) + return red_boss + return + if(rank >= HUMAN_NPC_RANK_ELITE || !plasma_pistol_outfit) + return + var/list/choices = . + // A crew's duelists stay duelists + if(!length(choices) || choices == melee) + return + return plasma_gunner_choices(choices, zone) + +/// `choices` with this faction's plasma gunners added for `zone`. Never changes `choices`. +/datum/human_npc_outfit_table/pirate/proc/plasma_gunner_choices(list/choices, zone) + var/key = "[REF(choices)]-[zone]" + var/list/cached = LAZYACCESS(plasma_choices, key) + if(cached) + return cached + cached = choices.Copy() + cached[plasma_pistol_outfit] = 2 + if(zone == ZONE_RED && plasma_rifle_outfit) + cached[plasma_rifle_outfit] = 1 + LAZYSET(plasma_choices, key, cached) + return cached + +/datum/human_npc_outfit_table/pirate/silverscale + plasma_pistol_outfit = /datum/outfit/piratecorpse/faction/silverscale/ranged/cinder + plasma_rifle_outfit = /datum/outfit/piratecorpse/faction/silverscale/ranged/furnace + +/datum/human_npc_outfit_table/pirate/skeleton + plasma_pistol_outfit = /datum/outfit/piratecorpse/faction/skeleton/ranged/cinder + plasma_rifle_outfit = /datum/outfit/piratecorpse/faction/skeleton/ranged/furnace + +/datum/human_npc_outfit_table/pirate/grey + plasma_pistol_outfit = /datum/outfit/piratecorpse/faction/grey/ranged/cinder + plasma_rifle_outfit = /datum/outfit/piratecorpse/faction/grey/ranged/furnace + +/datum/human_npc_outfit_table/pirate/lustrous + plasma_pistol_outfit = /datum/outfit/piratecorpse/faction/lustrous/ranged/cinder + plasma_rifle_outfit = /datum/outfit/piratecorpse/faction/lustrous/ranged/furnace + +/datum/human_npc_outfit_table/pirate/interdyne + plasma_pistol_outfit = /datum/outfit/piratecorpse/faction/interdyne/ranged/cinder + plasma_rifle_outfit = /datum/outfit/piratecorpse/faction/interdyne/ranged/furnace + red_boss = list(/datum/outfit/piratecorpse/faction/interdyne/prime/armed/furnace = 1) + +/datum/human_npc_outfit_table/pirate/irs + plasma_pistol_outfit = /datum/outfit/piratecorpse/faction/irs/ranged/cinder + plasma_rifle_outfit = /datum/outfit/piratecorpse/faction/irs/ranged/furnace + red_boss = list(/datum/outfit/piratecorpse/faction/irs/chief/armed/furnace = 1) + +// The gunners: the gun, a spare flask, and a knife for when the plasma runs out. + +/datum/outfit/piratecorpse/faction/silverscale/ranged/cinder + name = "Human NPC: Silverscale Marksman (Cinder)" + r_hand = /obj/item/gun/energy/plasma_weapon/pistol + l_pocket = /obj/item/plasma_flask/full + r_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/silverscale/ranged/furnace + name = "Human NPC: Silverscale Marksman (Furnace)" + r_hand = /obj/item/gun/energy/plasma_weapon/rifle + l_pocket = /obj/item/plasma_flask/full + r_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/skeleton/ranged/cinder + name = "Human NPC: Skeleton Gunner (Cinder)" + r_hand = /obj/item/gun/energy/plasma_weapon/pistol + l_pocket = /obj/item/plasma_flask/full + r_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/skeleton/ranged/furnace + name = "Human NPC: Skeleton Gunner (Furnace)" + r_hand = /obj/item/gun/energy/plasma_weapon/rifle + l_pocket = /obj/item/plasma_flask/full + r_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/grey/ranged/cinder + name = "Human NPC: Grey Tider Gunner (Cinder)" + r_hand = /obj/item/gun/energy/plasma_weapon/pistol + l_pocket = /obj/item/plasma_flask/full + r_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/grey/ranged/furnace + name = "Human NPC: Grey Tider Gunner (Furnace)" + r_hand = /obj/item/gun/energy/plasma_weapon/rifle + l_pocket = /obj/item/plasma_flask/full + r_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/lustrous/ranged/cinder + name = "Human NPC: Lustrous Coruscant (Cinder)" + r_hand = /obj/item/gun/energy/plasma_weapon/pistol + l_pocket = /obj/item/plasma_flask/full + r_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/lustrous/ranged/furnace + name = "Human NPC: Lustrous Coruscant (Furnace)" + r_hand = /obj/item/gun/energy/plasma_weapon/rifle + l_pocket = /obj/item/plasma_flask/full + r_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/interdyne/ranged/cinder + name = "Human NPC: Interdyne Pharmacist (Cinder)" + r_hand = /obj/item/gun/energy/plasma_weapon/pistol + l_pocket = /obj/item/plasma_flask/full + r_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/interdyne/ranged/furnace + name = "Human NPC: Interdyne Pharmacist (Furnace)" + r_hand = /obj/item/gun/energy/plasma_weapon/rifle + l_pocket = /obj/item/plasma_flask/full + r_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/irs/ranged/cinder + name = "Human NPC: IRS Agent (Cinder)" + r_hand = /obj/item/gun/energy/plasma_weapon/pistol + l_pocket = /obj/item/plasma_flask/full + r_pocket = /obj/item/knife/combat/survival + +/datum/outfit/piratecorpse/faction/irs/ranged/furnace + name = "Human NPC: IRS Agent (Furnace)" + r_hand = /obj/item/gun/energy/plasma_weapon/rifle + l_pocket = /obj/item/plasma_flask/full + r_pocket = /obj/item/knife/combat/survival + +// The red-zone bosses: a Furnace in place of the laser, the spare flask on the belt. + +/datum/outfit/piratecorpse/faction/interdyne/prime/armed/furnace + name = "Human NPC: Interdyne Director Prime (Furnace)" + r_hand = /obj/item/gun/energy/plasma_weapon/rifle + belt = /obj/item/plasma_flask/full + +/datum/outfit/piratecorpse/faction/irs/chief/armed/furnace + name = "Human NPC: IRS Chief Auditor (Furnace)" + r_hand = /obj/item/gun/energy/plasma_weapon/rifle + belt = /obj/item/plasma_flask/full + +// ===== THE OLD FURNACE ===== + +/mob/living/carbon/human/npc/faction_pirate/interdyne/boss + rare_loot_pool = list(/obj/item/gun/energy/plasma_weapon/rifle/old_furnace) + +/mob/living/carbon/human/npc/faction_pirate/irs/boss + rare_loot_pool = list(/obj/item/gun/energy/plasma_weapon/rifle/old_furnace) + +// The Old Furnace is a red-zone prize: a boss met anywhere else never drops it +/mob/living/carbon/human/npc/faction_pirate/npc_roll_death_loot() + . = ..() + if(npc_home_zone() != ZONE_RED) + var/list/rolled = . + rolled -= /obj/item/gun/energy/plasma_weapon/rifle/old_furnace + +// The same for the legacy basic-mob bosses (faction_pirate_bosses.dm) +/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 ..() + +#undef PIRATE_PLASMA_HOLD_HEAT +#undef PIRATE_PLASMA_RESUME_HEAT +#undef PIRATE_PLASMA_VENT_HEAT +#undef PIRATE_PLASMA_VENT_PRESSURE +#undef PIRATE_PLASMA_VENT_OXYGEN +#undef BB_PIRATE_PLASMA_COOLING +#undef PIRATE_PLASMA_BACK_OFF_RANGE 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..28e44d5a80a --- /dev/null +++ b/voidcrew/modules/plasma_weapons/plasma_types.dm @@ -0,0 +1,159 @@ +// 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 critical burn + var/ash_critical_burns = FALSE + /// Zone, damage and critical-burn 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 + /// 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_ball" 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 56c5ba96c27..6dc207b6fe8 100644 --- a/voidcrew/modules/trade/shop_catalog_black_market.dm +++ b/voidcrew/modules/trade/shop_catalog_black_market.dm @@ -82,6 +82,7 @@ /datum/shop_sku/black_market/slammer, /datum/shop_sku/black_market/absolution, /datum/shop_sku/black_market/conflagration, + /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, @@ -161,6 +162,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 d6ef62da5e9..3b46dd8982d 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 499ec70cafb..1eb05c3703f 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -122,6 +122,7 @@ #include "voidcrew_exploration_sweep.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" @@ -129,6 +130,7 @@ #include "voidcrew_gun_feel.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_human_npc_gc.dm" @@ -248,6 +250,13 @@ #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_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 242a65edb83..eab163a2bb7 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(3, 6, 5), "syndicate" = list(2, 4, 4), "plunder" = list(5, 6, 4), "freight" = list(4, 2)) + var/list/main_counts = list("armory" = list(4, 8, 5), "syndicate" = list(2, 4, 4), "plunder" = list(5, 7, 6), "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] @@ -718,6 +719,7 @@ /obj/item/gun/ballistic/shotgun/musket, /obj/item/gun/ballistic/shotgun/toy, /obj/item/gun/ballistic/shotgun/toy/crossbow, + /obj/item/gun/energy/frontier_laser/pistol, /obj/item/hatchet, /obj/item/knife/combat, /obj/item/knife/combat/bone, @@ -783,12 +785,14 @@ /obj/item/gun/energy/e_gun/dragnet, /obj/item/gun/energy/e_gun/mini, /obj/item/gun/energy/e_gun/old, + /obj/item/gun/energy/frontier_laser/smg, /obj/item/gun/energy/ionrifle, /obj/item/gun/energy/ionrifle/carbine, /obj/item/gun/energy/laser, /obj/item/gun/energy/laser/luxurypaywall, /obj/item/gun/energy/laser/retro, /obj/item/gun/energy/laser/retro/old, + /obj/item/gun/energy/plasma_weapon/pistol/loot, /obj/item/gun/magic/hook/marlinspike, /obj/item/gun/syringe/rapidsyringe, /obj/item/gun/syringe/syndicate, @@ -851,6 +855,8 @@ /obj/item/gun/energy/laser/hellgun, /obj/item/gun/energy/laser/scatter, /obj/item/gun/energy/lasercannon/unrestricted, + /obj/item/gun/energy/plasma_weapon/rifle/loot, + /obj/item/gun/energy/plasma_weapon/rifle/old_furnace, /obj/item/gun/energy/recharge/ebow, /obj/item/gun/energy/temperature/unrestricted, /obj/item/gun/energy/xray, diff --git a/voidcrew/modules/unit_tests/voidcrew_planetary_factions.dm b/voidcrew/modules/unit_tests/voidcrew_planetary_factions.dm index e44eb64220b..0e7b3dc8199 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..bc7294d4ef3 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_plasma_bolt.dm @@ -0,0 +1,208 @@ +// 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 an arm +/// already carrying a critical burn and a pistol bolt does not; 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 critical burn 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/critical) + leg.heal_damage(brute = 200, burn = 200) + TEST_ASSERT_NOTNULL(leg.get_wound_type(/datum/wound/burn/flesh/critical), "could not give a leg a critical burn") + 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 critically burnt leg attached") + else + TEST_ASSERT_NOTNULL(burned.get_bodypart(BODY_ZONE_R_LEG), "a pistol bolt melted a critically burnt leg with no damage") + + 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") 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..d97af3d286d --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_plasma_effects.dm @@ -0,0 +1,188 @@ +// 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 hit splashes off it. +/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() + victim.bullet_act(make_bolt(/obj/projectile/energy/plasma_bolt/heavy, turf_at(1, 1)), 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 get_turf(victim), "a shield block made a flesh burst") + TEST_ASSERT_NULL(locate(/obj/effect/temp_visual/block) in get_turf(victim), "a shield block of a plasma bolt left tg's block star") + // The splash waits a tick for the bolt's sprite to reach the shield + sleep(world.tick_lag * 3) + var/obj/effect/temp_visual/plasma_splash/splash = locate() in get_turf(victim) + TEST_ASSERT_EQUAL(splash?.icon_state, "splash_armour", "a shield block made no armour splash") + +/// 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..9416beebf97 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_plasma_melt.dm @@ -0,0 +1,244 @@ +// 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)) + .++ + +/// How many sleeve masks (Hyperviolence's or any other) cut `overlay` +/proc/voidcrew_plasma_sleeve_filters(mutable_appearance/overlay) + . = 0 + for(var/i in 1 to length(overlay?.filters)) + var/sleeve_filter = overlay.filters[i] + if(findtext("[sleeve_filter:render_source]", "sleeve")) + .++ + +/// 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 and coat sleeves away once it runs, cut by Hyperviolence's (#512) lost-limb mask and +/// nothing else; a melted leg burns no sleeve. Putting the arm back brings the sleeves back and takes the stump off. +/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) + victim.equip_to_slot_or_del(new /obj/item/clothing/suit/costume/irs(victim), ITEM_SLOT_OCLOTHING) + TEST_ASSERT_NOTNULL(victim.overlays_standing[UNIFORM_LAYER], "The test body has no jumpsuit drawn") + TEST_ASSERT_NOTNULL(victim.overlays_standing[SUIT_LAYER], "The test body has no coat drawn") + + 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") + TEST_ASSERT(!(BODY_ZONE_R_ARM in victim.hv_sleeve_mask_zones()), "The sleeves burned before the arm started melting") + TEST_ASSERT_EQUAL(voidcrew_plasma_sleeve_filters(victim.overlays_standing[UNIFORM_LAYER]), 0, "The jumpsuit sleeve was cut before the arm started melting") + TEST_ASSERT_EQUAL(voidcrew_plasma_sleeve_filters(victim.overlays_standing[SUIT_LAYER]), 0, "The coat sleeve was cut before the arm started melting") + arm_melt.pose_melt() + TEST_ASSERT(arm_melt.sleeve_burned, "The melting arm did not burn its sleeves") + TEST_ASSERT(BODY_ZONE_R_ARM in victim.hv_sleeve_mask_zones(), "The burned sleeves are not masked") + TEST_ASSERT_EQUAL(voidcrew_plasma_sleeve_filters(victim.overlays_standing[UNIFORM_LAYER]), 1, "The jumpsuit sleeve is not cut by exactly one mask") + TEST_ASSERT_EQUAL(voidcrew_plasma_sleeve_filters(victim.overlays_standing[SUIT_LAYER]), 1, "The coat sleeve is not cut by exactly one mask") + victim.update_worn_undersuit() + victim.update_worn_oversuit() + TEST_ASSERT_EQUAL(voidcrew_plasma_sleeve_filters(victim.overlays_standing[UNIFORM_LAYER]), 1, "Redrawing the jumpsuit lost or doubled the sleeve mask") + TEST_ASSERT_EQUAL(voidcrew_plasma_sleeve_filters(victim.overlays_standing[SUIT_LAYER]), 1, "Redrawing the coat lost or doubled the sleeve mask") + + 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") + + 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") + TEST_ASSERT(!(BODY_ZONE_R_ARM in victim.hv_sleeve_mask_zones()), "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) + +/// Hyperviolence (#512): a melted limb throws no blood, and its stump is seared shut, never wet +/datum/unit_test/voidcrew_plasma_melt_dry + +/datum/unit_test/voidcrew_plasma_melt_dry/Run() + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent) + var/turf/where = get_turf(victim) + TEST_ASSERT(plasma_melt_limb(victim.get_bodypart(BODY_ZONE_L_ARM), EAST), "The arm did not melt off") + var/blood = 0 + for(var/turf/near in range(3, where)) + blood += voidcrew_plasma_melt_count(near, /obj/effect/decal/cleanable/blood) + TEST_ASSERT_EQUAL(blood, 0, "The melt threw blood") + TEST_ASSERT(LAZYACCESS(victim.hv_stump_burnt, BODY_ZONE_L_ARM), "The melted arm's stump isn't seared") + TEST_ASSERT(!victim.hv_stump_is_fresh(BODY_ZONE_L_ARM), "The melted arm's stump is wet") + TEST_ASSERT_NULL(victim.hv_sever_spray, "The melt left the body's sever spray held back") + + for(var/zone in victim.plasma_melts) + qdel(victim.plasma_melts[zone]) + for(var/turf/near in range(3, where)) + for(var/obj/effect/thing in near) + if(istype(thing, /obj/effect/decal/cleanable) || istype(thing, /obj/effect/plasma_melt_pool) || istype(thing, /obj/effect/plasma_melt_light)) + 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..fddcb2921b4 --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_plasma_pirates.dm @@ -0,0 +1,182 @@ +// Package: P6 NPCs. Unit tests for voidcrew/modules/plasma_weapons/plasma_pirates.dm (human NPC pirates, PR #512). +// +// Fork defines are included after the tests, so their values are written out: zones 1 (green), 2 (yellow) +// and 3 (red); ranks HUMAN_NPC_RANK_BOSS 2; blackboard keys by their strings. + +/// Shared helpers; not a test itself +/datum/unit_test/voidcrew_plasma_pirates + abstract_type = /datum/unit_test/voidcrew_plasma_pirates + /// Turfs a test changed, put back to plain floor when it ends + var/list/turf/changed_turfs = list() + +/datum/unit_test/voidcrew_plasma_pirates/Destroy() + for(var/turf/changed as anything in changed_turfs) + changed.ChangeTurf(/turf/open/floor/iron) + changed_turfs = null + 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 human NPC built now at `where`, dressed in `outfit` +/datum/unit_test/voidcrew_plasma_pirates/proc/build_npc(npc_type, turf/where, outfit) + var/datum/human_npc_order/order = spawn_human_npc(npc_type, where, outfit = outfit, immediate = TRUE) + var/mob/living/carbon/human/npc/npc = order?.spawned_ref?.resolve() + if(npc) + allocated += npc + return npc + +/// The Cinder and the Furnace have their own handling; the Old Furnace is handled as a Furnace. +/datum/unit_test/voidcrew_plasma_pirates/handling + +/datum/unit_test/voidcrew_plasma_pirates/handling/Run() + var/list/expected = list( + /obj/item/gun/energy/plasma_weapon/pistol = /datum/pirate_gun_handling/plasma, + /obj/item/gun/energy/plasma_weapon/rifle = /datum/pirate_gun_handling/plasma/rifle, + /obj/item/gun/energy/plasma_weapon/rifle/old_furnace = /datum/pirate_gun_handling/plasma/rifle, + ) + for(var/gun_type in expected) + var/obj/item/gun/gun = allocate(gun_type, spot(4, 4)) + var/datum/pirate_gun_handling/handling = pirate_gun_handling_for(gun) + TEST_ASSERT_EQUAL(handling.type, expected[gun_type], "[gun_type] is handled as [handling.type]") + +/// Green-zone pirates never carry plasma; yellow adds the Cinder; red adds both. The faction's own lists are untouched. +/datum/unit_test/voidcrew_plasma_pirates/zone_outfits + +/datum/unit_test/voidcrew_plasma_pirates/zone_outfits/Run() + for(var/faction in list("silverscale", "skeleton", "grey", "lustrous", "interdyne", "irs")) + var/datum/human_npc_outfit_table/pirate/table = human_npc_singleton(text2path("/datum/human_npc_outfit_table/pirate/[faction]")) + TEST_ASSERT_NOTNULL(table.plasma_pistol_outfit, "[faction] has no Cinder gunner") + TEST_ASSERT_NOTNULL(table.plasma_rifle_outfit, "[faction] has no Furnace gunner") + var/list/green_before = table.green.Copy() + for(var/zone in 1 to 3) + var/list/choices = table.outfits_for(null, zone) + TEST_ASSERT_EQUAL(!!choices[table.plasma_pistol_outfit], zone >= 2, "A zone [zone] [faction] crew's Cinder gunner is wrong") + TEST_ASSERT_EQUAL(!!choices[table.plasma_rifle_outfit], zone == 3, "A zone [zone] [faction] crew's Furnace gunner is wrong") + for(var/outfit_type in green_before) + TEST_ASSERT_EQUAL(choices[outfit_type], green_before[outfit_type], "A zone [zone] [faction] crew's weight for [outfit_type] changed") + TEST_ASSERT_EQUAL(length(table.green), length(green_before), "Plasma gunners were written into [faction]'s own list") + TEST_ASSERT_NULL(table.gunners[table.plasma_pistol_outfit], "Plasma gunners were written into [faction]'s gunners") + for(var/table_type in list(/datum/human_npc_outfit_table/pirate/medieval, /datum/human_npc_outfit_table/pirate/nt_customs)) + var/datum/human_npc_outfit_table/pirate/table = human_npc_singleton(table_type) + TEST_ASSERT_NULL(table.plasma_pistol_outfit, "[table_type] carries plasma") + +/// Every plasma outfit holds its gun, loaded, with a full spare flask it can reach. +/datum/unit_test/voidcrew_plasma_pirates/dressed + +/datum/unit_test/voidcrew_plasma_pirates/dressed/Run() + var/list/outfits = list() + for(var/faction in list("silverscale", "skeleton", "grey", "lustrous", "interdyne", "irs")) + var/datum/human_npc_outfit_table/pirate/table = human_npc_singleton(text2path("/datum/human_npc_outfit_table/pirate/[faction]")) + outfits += table.plasma_pistol_outfit + outfits += table.plasma_rifle_outfit + for(var/outfit_type in table.red_boss) + outfits += outfit_type + for(var/outfit_type in outfits) + var/mob/living/carbon/human/npc/pirate = build_npc(/mob/living/carbon/human/npc/sample, run_loc_floor_bottom_left, outfit_type) + TEST_ASSERT_NOTNULL(pirate, "[outfit_type] built nobody") + var/obj/item/gun/energy/plasma_weapon/gun = pirate_plasma_held_gun(pirate) + TEST_ASSERT_NOTNULL(gun, "[outfit_type] holds no plasma gun") + TEST_ASSERT(gun.can_shoot(), "[outfit_type]'s gun isn't loaded") + var/obj/item/plasma_flask/spare = pirate_plasma_spare_flask(pirate, gun) + TEST_ASSERT_NOTNULL(spare, "[outfit_type] carries no spare flask it can reach") + TEST_ASSERT_EQUAL(spare.stored, PLASMA_FLASK_CAPACITY, "[outfit_type]'s spare flask isn't full") + qdel(pirate) + +/// The red-zone Director Prime and Chief Auditor carry a Furnace, elsewhere their laser; both can drop the +/// Old Furnace, but only in a red zone. +/datum/unit_test/voidcrew_plasma_pirates/bosses + +/datum/unit_test/voidcrew_plasma_pirates/bosses/Run() + var/list/boss_types = list( + /mob/living/carbon/human/npc/faction_pirate/interdyne/boss = /datum/human_npc_outfit_table/pirate/interdyne, + /mob/living/carbon/human/npc/faction_pirate/irs/boss = /datum/human_npc_outfit_table/pirate/irs, + ) + for(var/boss_type in boss_types) + var/datum/human_npc_outfit_table/pirate/table = human_npc_singleton(boss_types[boss_type]) + var/mob/living/carbon/human/npc/faction_pirate/boss = build_npc(boss_type, spot(1, 1), table.boss[1]) + TEST_ASSERT_NOTNULL(boss, "[boss_type] built nobody") + TEST_ASSERT_EQUAL(boss.npc_rank, 2, "Test fixture: [boss_type] isn't a 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") + for(var/zone in 1 to 3) + var/list/choices = table.outfits_for(boss, zone) + var/carries_furnace = FALSE + for(var/outfit_type in choices) + var/datum/outfit/outfit = outfit_type + if(ispath(initial(outfit.r_hand), /obj/item/gun/energy/plasma_weapon/rifle)) + carries_furnace = TRUE + TEST_ASSERT_EQUAL(carries_furnace, zone == 3, "A zone [zone] [boss_type] [zone == 3 ? "carries no" : "carries a"] Furnace") + boss.rolled_rare_loot = list(/obj/item/gun/energy/plasma_weapon/rifle/old_furnace) + boss.npc_dressed_zone = zone + var/list/drops = boss.npc_roll_death_loot() + if(boss.npc_home_zone() == zone) + TEST_ASSERT_EQUAL((/obj/item/gun/energy/plasma_weapon/rifle/old_furnace in drops), zone == 3, "A zone [zone] [boss_type]'s Old Furnace roll was [zone == 3 ? "dropped" : "kept"]") + qdel(boss) + +/// A dry gunner with a spare changes flasks. +/datum/unit_test/voidcrew_plasma_pirates/flask_change + +/datum/unit_test/voidcrew_plasma_pirates/flask_change/Run() + var/mob/living/carbon/human/npc/faction_pirate/gunner = build_npc(/mob/living/carbon/human/npc/faction_pirate/irs/ranged, spot(0, 0), /datum/outfit/piratecorpse/faction/irs/ranged/cinder) + var/obj/item/gun/energy/plasma_weapon/gun = pirate_plasma_held_gun(gunner) + TEST_ASSERT_NOTNULL(gun, "Test fixture: the gunner holds no Cinder") + gun.cell.charge = 0 + gun.chamber_changed() + TEST_ASSERT(!gun.can_shoot(), "Test fixture: an empty Cinder still fires") + var/datum/ai_controller/controller = gunner.ai_controller + controller.set_blackboard_key("human_npc_arming_retry_at", 0) + var/datum/ai_planning_subtree/arm = GLOB.ai_subtrees[/datum/ai_planning_subtree/human_npc_arm/pirate_crew] + TEST_ASSERT_NOTNULL(arm, "Test fixture: no pirate arming subtree") + controller.planned_behaviors = list() + TEST_ASSERT_EQUAL(arm.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "A dry plasma gunner with a spare did nothing") + TEST_ASSERT(controller.planned_behaviors[GET_AI_BEHAVIOR(/datum/ai_behavior/pirate_plasma_reload)], "A dry plasma gunner with a spare didn't change flasks") + var/datum/ai_behavior/pirate_plasma_reload/reload = GET_AI_BEHAVIOR(/datum/ai_behavior/pirate_plasma_reload) + var/obj/item/plasma_flask/spare = pirate_plasma_spare_flask(gunner, gun) + reload.perform(1, controller) + TEST_ASSERT(gunner.is_holding(spare), "The gunner didn't take its spare flask in hand") + TEST_ASSERT(gun.load_flask(spare, null), "The spare flask wouldn't go in") + TEST_ASSERT(gun.can_shoot(), "A changed flask didn't load the Cinder") + +/// A hot Furnace in air: it holds fire and backs off until the coils cool, and never vents. In vacuum it vents. +/datum/unit_test/voidcrew_plasma_pirates/heat + +/datum/unit_test/voidcrew_plasma_pirates/heat/Run() + var/mob/living/carbon/human/npc/faction_pirate/gunner = build_npc(/mob/living/carbon/human/npc/faction_pirate/irs/ranged, spot(1, 1), /datum/outfit/piratecorpse/faction/irs/ranged/furnace) + var/obj/item/gun/energy/plasma_weapon/gun = pirate_plasma_held_gun(gunner) + TEST_ASSERT(gun?.uses_heat, "Test fixture: the gunner holds no Furnace") + var/mob/living/carbon/human/consistent/mark = allocate(/mob/living/carbon/human/consistent, spot(3, 1)) + var/datum/ai_controller/controller = gunner.ai_controller + controller.set_blackboard_key(BB_BASIC_MOB_CURRENT_TARGET, mark) + var/datum/ai_planning_subtree/arm = GLOB.ai_subtrees[/datum/ai_planning_subtree/human_npc_arm/pirate_crew] + TEST_ASSERT(!pirate_plasma_safe_to_vent(get_turf(gunner)), "Plasma vented in a room's air would be safe") + var/mol = gun.chamber_mol() + gun.barrel_heat = 85 + controller.planned_behaviors = list() + TEST_ASSERT_EQUAL(arm.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "A gunner with a hot Furnace kept firing") + TEST_ASSERT(controller.blackboard["pirate_plasma_cooling"], "A gunner with a hot Furnace isn't waiting for it to cool") + TEST_ASSERT(!controller.planned_behaviors[GET_AI_BEHAVIOR(/datum/ai_behavior/pirate_plasma_vent)], "A gunner vented its Furnace indoors") + TEST_ASSERT_EQUAL(gun.chamber_mol(), mol, "Plasma left the Furnace indoors") + gun.barrel_heat = 60 + controller.planned_behaviors = list() + TEST_ASSERT_EQUAL(arm.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "A gunner went back to firing before its Furnace cooled") + gun.barrel_heat = 35 + controller.planned_behaviors = list() + arm.SelectBehaviors(controller, 1) + TEST_ASSERT(!controller.blackboard["pirate_plasma_cooling"], "A gunner kept waiting on a cool Furnace") + + // Where there is next to no air it vents by hand + var/turf/airless = spot(1, 3) + changed_turfs |= airless + airless.ChangeTurf(/turf/open/floor/iron/airless) + gunner.forceMove(airless) + TEST_ASSERT(pirate_plasma_safe_to_vent(airless), "Plasma vented on an airless floor would catch") + gun.barrel_heat = 70 + gun.next_vent_at = 0 + controller.planned_behaviors = list() + TEST_ASSERT_EQUAL(arm.SelectBehaviors(controller, 1), SUBTREE_RETURN_FINISH_PLANNING, "A gunner with a warm Furnace in vacuum did nothing") + TEST_ASSERT(controller.planned_behaviors[GET_AI_BEHAVIOR(/datum/ai_behavior/pirate_plasma_vent)], "A gunner in vacuum didn't vent its warm Furnace") + var/datum/ai_behavior/pirate_plasma_vent/vent = GET_AI_BEHAVIOR(/datum/ai_behavior/pirate_plasma_vent) + vent.perform(1, controller) + TEST_ASSERT(gun.barrel_heat < 70, "Venting didn't cool the Furnace") + TEST_ASSERT(gun.chamber_mol() < mol, "Venting released no plasma") 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..85bb590bfed --- /dev/null +++ b/voidcrew/modules/unit_tests/voidcrew_plasma_supply.dm @@ -0,0 +1,83 @@ +// 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 + // The outfitter's listed rows (#516) take any item, then only the exact types in their wanted list + var/list/wanted + if(ispath(buyback_path, /datum/shop_buyback/outfitter/listed)) + var/datum/shop_buyback/outfitter/listed/listed = new buyback_path + wanted = listed.wanted_cache || list() + qdel(listed) + for(var/item_path in sold) + if(wanted ? wanted[item_path] : (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/wiki/content/plasma-and-laser-guns.md b/wiki/content/plasma-and-laser-guns.md new file mode 100644 index 00000000000..d728409c8d5 --- /dev/null +++ b/wiki/content/plasma-and-laser-guns.md @@ -0,0 +1,76 @@ +--- +title: Plasma & Frontier Lasers +category: Danger +order: 1.2 +blurb: The Kestrel and Hornet lasers, the Cinder and Furnace plasma guns, plasma flasks 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 deep burns that get worse with every hit on the same spot. They do not set people on fire. Treat them like any other burn. +- **Limbs:** an arm or leg that is already badly hurt melts away under a bolt. A rifle bolt also melts an arm or leg that already carries a critical burn. 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. + +## 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.