diff --git a/code/__DEFINES/role_preferences.dm b/code/__DEFINES/role_preferences.dm index c2f4cf86570..c58be435d43 100644 --- a/code/__DEFINES/role_preferences.dm +++ b/code/__DEFINES/role_preferences.dm @@ -63,6 +63,7 @@ #define ROLE_HIVE "Hivemind Host" //Role removed, left here for safety. #define ROLE_SENTIENCE "Sentience Potion Spawn" #define ROLE_PYROCLASTIC_SLIME "Pyroclastic Anomaly Slime" +#define ROLE_ANOMALY_GHOST "Ectoplasmic Anomaly Ghost" #define ROLE_MIND_TRANSFER "Mind Transfer Potion" #define ROLE_POSIBRAIN "Posibrain" #define ROLE_DRONE "Drone" diff --git a/code/game/objects/effects/anomalies/anomalies_ectoplasm.dm b/code/game/objects/effects/anomalies/anomalies_ectoplasm.dm new file mode 100644 index 00000000000..7a072310fac --- /dev/null +++ b/code/game/objects/effects/anomalies/anomalies_ectoplasm.dm @@ -0,0 +1,212 @@ +/obj/effect/anomaly/ectoplasm + name = "ectoplasm anomaly" + desc = "It looks like the souls of the damned are trying to break into the realm of the living again. How upsetting." + icon_state = "ectoplasm" + aSignal = /obj/item/assembly/signaler/anomaly/ectoplasm + lifespan = 100 SECONDS //This one takes slightly longer, because it can run away. + + ///Blocks the anomaly from updating ghost count. Used in case an admin wants to rig the anomaly to be a certain size or intensity. + var/override_ghosts = FALSE + ///The numerical power of the anomaly. Calculated in anomalyEffect. Also used in determining the category of detonation effects. + var/effect_power = 0 + ///The actual number of ghosts orbiting the anomaly. + var/ghosts_orbiting = 0 + +/obj/effect/anomaly/ectoplasm/Initialize(mapload, new_lifespan, drops_core) + . = ..() + + AddComponent(/datum/component/deadchat_control/cardinal_movement, _deadchat_mode = ANARCHY_MODE, _inputs = list(), _input_cooldown = 7 SECONDS) + +/obj/effect/anomaly/ectoplasm/examine(mob/user) + . = ..() + + if(isobserver(user)) + . += span_info("Orbiting this anomaly will increase the size and intensity of its effects.") + +/obj/effect/anomaly/ectoplasm/examine_more(mob/user) + . = ..() + + switch(effect_power) + if(0 to 25) + . += span_notice("The space around the anomaly faintly resonates. It doesn't seem very powerful at the moment.") + if(26 to 49) + . += span_notice("The space around the anomaly seems to vibrate, letting out a noise that sounds like ghastly moaning. Someone should probably do something about that.") + if(50 to 100) + . += span_alert("The anomaly pulsates heavily, about to burst with unearthly energy. This can't be good.") + +/obj/effect/anomaly/ectoplasm/anomalyEffect(delta_time) + . = ..() + + if(override_ghosts) + return + + ghosts_orbiting = 0 + for(var/mob/dead/observer/orbiter in orbiters?.orbiter_list) + ghosts_orbiting++ + + if(ghosts_orbiting) + var/total_dead = length(GLOB.dead_player_list) + length(GLOB.current_observers_list) + effect_power = ghosts_orbiting / total_dead * 100 + else + effect_power = 0 + + intensity_update() + +/obj/effect/anomaly/ectoplasm/detonate() + . = ..() + + if(effect_power < 10) //Under 10% participation, we do nothing more than a small visual *poof*. + new /obj/effect/temp_visual/revenant/cracks(get_turf(src)) + return + + if(effect_power >= 10) //Performs something akin to a revenant defile spell. + var/effect_range = ghosts_orbiting + 3 + var/effect_area = range(effect_range, src) + + for(var/impacted_thing in effect_area) + if(isfloorturf(impacted_thing)) + if(prob(5)) + new /obj/effect/decal/cleanable/blood(get_turf(impacted_thing)) + else if(prob(10)) + new /obj/effect/decal/cleanable/greenglow/ecto(get_turf(impacted_thing)) + else if(prob(10)) + new /obj/effect/decal/cleanable/dirt/dust(get_turf(impacted_thing)) + + if(!isplatingturf(impacted_thing)) + var/turf/open/floor/floor_to_break = impacted_thing + if(floor_to_break.overfloor_placed && floor_to_break.floor_tile && prob(20)) + new floor_to_break.floor_tile(floor_to_break) + floor_to_break.make_plating(TRUE) + floor_to_break.broken = TRUE + floor_to_break.burnt = TRUE + + if(ishuman(impacted_thing)) + var/mob/living/carbon/human/mob_to_infect = impacted_thing + mob_to_infect.ForceContractDisease(new /datum/disease/revblight(), FALSE, TRUE) + new /obj/effect/temp_visual/revenant(get_turf(mob_to_infect)) + to_chat(mob_to_infect, span_revenminor("A cacophony of ghostly wailing floods your ears for a moment. The noise subsides, but a distant whispering continues echoing inside of your head...")) + + if(istype(impacted_thing, /obj/structure/window)) + var/obj/structure/window/window_to_damage = impacted_thing + window_to_damage.take_damage(rand(60, 90)) + if(window_to_damage?.fulltile) + new /obj/effect/temp_visual/revenant/cracks(get_turf(window_to_damage)) + + if(effect_power >= 35) + var/effect_range = ghosts_orbiting + 3 + haunt_outburst(epicenter = get_turf(src), range = effect_range, haunt_chance = 45, duration = 2 MINUTES) + + if(effect_power >= 50) //Summon a ghost swarm! + var/list/candidate_list = list() + for(var/mob/dead/observer/orbiter in orbiters?.orbiter_list) + candidate_list += orbiter + + new /obj/structure/ghost_portal(get_turf(src), candidate_list) + + priority_announce("Anomaly has reached critical mass. Ectoplasmic outburst detected.", "Anomaly Alert") + +/** + * Manages updating the sprite for the anomaly based on how many orbiters it has. + * + * + * A check that is run to determine which sprite the anoamly should currently be displaying. + * With 50% or more participation, the "heavy" sprite is used. Otherwise, it is reverted to the normal anomaly sprite. + */ + +/obj/effect/anomaly/ectoplasm/proc/intensity_update() + if(effect_power >= 50) //If we're at the threshold for the highest tier effect, we change sprites in preparation for the spooks. + icon_state = "ectoplasm_heavy" + update_icon_state() + else + icon_state = "ectoplasm" + update_icon_state() + + + +// Ghost Portal. Used to bring anomaly orbiters into the playing field as ghosts. Destroys itself and all of its associated ghosts after two minutes. +// Can be destroyed early to the same effect. + +/obj/structure/ghost_portal + name = "Spooky Portal" + desc = "A portal between our dimension and who-knows-where? It's emitting an absolutely ungodly wailing sound." + icon = 'icons/obj/objects.dmi' + icon_state = "anom" + anchored = TRUE + var/static/list/spooky_noises = list( + 'sound/hallucinations/growl1.ogg', + 'sound/hallucinations/growl2.ogg', + 'sound/hallucinations/growl3.ogg', + 'sound/hallucinations/veryfar_noise.ogg', + 'sound/hallucinations/wail.ogg' + ) + var/list/ghosts_spawned = list() + +/obj/structure/ghost_portal/Initialize(mapload, candidate_list) + . = ..() + + START_PROCESSING(SSobj, src) + INVOKE_ASYNC(src, PROC_REF(make_ghost_swarm), candidate_list) + playsound(src, pick(spooky_noises), 100, TRUE) + QDEL_IN(src, 2 MINUTES) + +/obj/structure/ghost_portal/process(delta_time) + . = ..() + + if(prob(5)) + playsound(src, pick(spooky_noises), 100) + +/obj/structure/ghost_portal/play_attack_sound(damage_amount, damage_type = BRUTE, damage_flag = 0) + playsound(loc, 'sound/effects/empulse.ogg', 75, TRUE) + if(prob(40)) + playsound(src, pick(spooky_noises), 50) + +/obj/structure/ghost_portal/Destroy() + . = ..() + + STOP_PROCESSING(SSobj, src) + INVOKE_ASYNC(GLOBAL_PROC, GLOBAL_PROC_REF(cleanup_ghosts), ghosts_spawned) + ghosts_spawned = null + +/** + * Generates a poll for observers, spawning anyone who signs up in a large group of ghost mobs + * + * Generates a poll that asks anyone observing for participation. Spawns a bunch of basicmob ghosts with the keys of candidates who have signed up. + * Ghosts are deleted two minutes after being made, and exist to punch stuff until it breaks. + */ + +/obj/structure/ghost_portal/proc/make_ghost_swarm(list/candidate_list) + if(!length(candidate_list)) //If we are not passed a candidate list we just poll everyone who is dead, meaning these can also be spawned directly. + candidate_list += GLOB.current_observers_list + candidate_list += GLOB.dead_player_list + + var/list/candidates = poll_candidates("Would you like to participate in a spooky ghost swarm? (Warning: you will not be able to return to your body!)", ROLE_SENTIENCE, FALSE, 10 SECONDS, group = candidate_list) + for(var/mob/dead/observer/candidate_ghost as anything in candidates) + var/mob/living/basic/ghost/swarm/new_ghost = new(get_turf(src)) + ghosts_spawned += new_ghost + new_ghost.ghostize(FALSE) + new_ghost.key = candidate_ghost.key + var/policy = get_policy(ROLE_ANOMALY_GHOST) + if(policy) + to_chat(new_ghost, policy) + else + to_chat(new_ghost, span_revenboldnotice("You are a lost soul, brought back to the realm of the living. Your time on this plane is limited, and you will soon be dragged back into the void!")) + new_ghost.log_message("was returned to the living world as a ghost by an ectoplasmic anomaly.", LOG_GAME) + +/** + * Gives a farewell message and deletes the ghosts produced by a ghost portal structure. + * + * Handles cleanup of all ghost mobs spawned a ghost portal. Iterates through the list + * and calls qdel on its contents, gives a short message, and leaves behind some goop. + * Stored as a global, as it is called immediately after the portal deletes itself. + * + * * delete_list - The list of entities to be deleted by this proc. + */ + +/proc/cleanup_ghosts(list/delete_list) + for(var/mob/living/mob_to_delete as anything in delete_list) + mob_to_delete.visible_message(span_alert("The [mob_to_delete] wails as it is torn back into the void!"), span_alert("You let out one last wail as you are sucked back into the realm of the dead. Then suddenly, you're back in the comforting embrace of the afterlife."), span_hear("You hear ethereal wailing.")) + playsound(mob_to_delete, pick(delete_list), 50) + new /obj/effect/temp_visual/revenant/cracks(get_turf(mob_to_delete)) + new /obj/effect/decal/cleanable/greenglow/ecto(get_turf(mob_to_delete)) + mob_to_delete.ghostize(FALSE) //So we don't throw an alert for deleting a mob with a key inside. + qdel(mob_to_delete) diff --git a/code/modules/antagonists/revenant/haunted_item.dm b/code/modules/antagonists/revenant/haunted_item.dm index f21d36fb8af..a9eac99ddc2 100644 --- a/code/modules/antagonists/revenant/haunted_item.dm +++ b/code/modules/antagonists/revenant/haunted_item.dm @@ -99,3 +99,28 @@ attacker.visible_message(span_warning("[attacker] dispells the ghostly energy from [source]!"), span_warning("You dispel the ghostly energy from [source]!")) clear_haunting() return COMPONENT_NO_AFTERATTACK + +/** + * Takes a given area and chance, applying the haunted_item component to objects in the area. + * + * Takes an epicenter, and within the range around it, runs a haunt_chance percent chance of + * applying the haunted_item component to nearby objects. + * + * * epicenter - The center of the outburst area. + * * range - The range of the outburst, centered around the epicenter. + * * haunt_chance - The percent chance that an object caught in the epicenter will be haunted. + * * duration - How long the haunting will remain for. + */ + +/proc/haunt_outburst(epicenter, range, haunt_chance, duration = 1 MINUTES) + var/effect_area = range(range, epicenter) + for(var/obj/item/object_to_possess in effect_area) + if(!prob(haunt_chance)) + continue + object_to_possess.AddComponent(/datum/component/haunted_item, \ + haunt_color = "#52336e", \ + haunt_duration = duration, \ + aggro_radius = range, \ + spawn_message = span_revenwarning("[object_to_possess] slowly rises upward, hanging menacingly in the air..."), \ + despawn_message = span_revenwarning("[object_to_possess] settles to the floor, lifeless and unmoving."), \ + ) diff --git a/code/modules/clothing/suits/reactive_armour.dm b/code/modules/clothing/suits/reactive_armour.dm index 39190066b52..67fdd64bd96 100644 --- a/code/modules/clothing/suits/reactive_armour.dm +++ b/code/modules/clothing/suits/reactive_armour.dm @@ -14,6 +14,7 @@ /obj/effect/anomaly/bioscrambler = /obj/item/clothing/suit/armor/reactive/bioscrambling, /obj/effect/anomaly/hallucination = /obj/item/clothing/suit/armor/reactive/hallucinating, /obj/effect/anomaly/dimensional = /obj/item/clothing/suit/armor/reactive/barricade, + /obj/effect/anomaly/ectoplasm = /obj/item/clothing/suit/armor/reactive/ectoplasm, ) if(istype(weapon, /obj/item/assembly/signaler/anomaly)) @@ -491,3 +492,22 @@ qdel(theme) reactivearmor_cooldown = world.time + reactivearmor_cooldown_duration return FALSE + +/obj/item/clothing/suit/armor/reactive/ectoplasm + name = "reactive possession armor" + desc = "An experimental suit of armor that animates nearby objects with a ghostly possession." + emp_message = span_warning("The reactive armor lets out a horrible noise, and ghostly whispers fill your ears...") + cooldown_message = span_danger("Ectoplasmic Matrix out of balance. Please wait for calibration to complete!") + reactivearmor_cooldown_duration = 40 SECONDS + +/obj/item/clothing/suit/armor/reactive/ectoplasm/reactive_activation(mob/living/carbon/human/owner, atom/movable/hitby, attack_text = "the attack", final_block_chance = 0, damage = 0, attack_type = MELEE_ATTACK) + playsound(get_turf(owner),'sound/hallucinations/veryfar_noise.ogg', 100, TRUE) + owner.visible_message(span_danger("The [src] lets loose a burst of otherworldly energy!")) + + haunt_outburst(epicenter = get_turf(owner), range = 5, haunt_chance = 85, duration = 30 SECONDS) + + reactivearmor_cooldown = world.time + reactivearmor_cooldown_duration + return TRUE + +/obj/item/clothing/suit/armor/reactive/ectoplasm/emp_activation(mob/living/carbon/human/owner, atom/movable/hitby, attack_text = "the attack", final_block_chance = 0, damage = 0, attack_type = MELEE_ATTACK) + owner.reagents?.add_reagent(/datum/reagent/inverse/helgrasp, 20) diff --git a/code/modules/events/anomaly/anomaly_ectoplasm.dm b/code/modules/events/anomaly/anomaly_ectoplasm.dm new file mode 100644 index 00000000000..7ac27000681 --- /dev/null +++ b/code/modules/events/anomaly/anomaly_ectoplasm.dm @@ -0,0 +1,76 @@ +#define ANOMALY_INTENSITY_MINOR "Minor Intensity" +#define ANOMALY_INTENSITY_MODERATE "Moderate Intensity" +#define ANOMALY_INTENSITY_MAJOR "Major Intensity" + +/datum/round_event_control/anomaly/anomaly_ectoplasm + name = "Anomaly: Ectoplasmic Outburst" + description = "Anomaly that produces an effect of varying intensity based on how many ghosts are orbiting it." + typepath = /datum/round_event/anomaly/anomaly_ectoplasm + min_players = 30 + max_occurrences = 2 + weight = 4 //Rare because of it's wacky and silly nature + category = EVENT_CATEGORY_ANOMALIES + admin_setup = /datum/event_admin_setup/anomaly/anomaly_ectoplasm + +/datum/round_event/anomaly/anomaly_ectoplasm + anomaly_path = /obj/effect/anomaly/ectoplasm + start_when = 3 + announce_when = 20 + ///The admin-set impact effect intensity override + var/effect_override + ///The admin-set number of ghosts, for use in calculating impact size. + var/orbit_override + +/datum/round_event/anomaly/anomaly_ectoplasm/start() + if(!effect_override || !orbit_override) + return ..() //If we provide no override, just run the usual startup. + + var/turf/anomaly_turf = placer.findValidTurf(impact_area) + var/obj/effect/anomaly/ectoplasm/newAnomaly + if(anomaly_turf) + newAnomaly = new anomaly_path(anomaly_turf) + newAnomaly.override_ghosts = TRUE + newAnomaly.effect_power = effect_override + newAnomaly.ghosts_orbiting = orbit_override + newAnomaly.intensity_update() + if(newAnomaly) + announce_to_ghosts(newAnomaly) + +/datum/round_event/anomaly/anomaly_ectoplasm/announce(fake) + priority_announce("Localized ectoplasmic outburst detected on long range scanners. Expected location of impact: [impact_area.name].", "Anomaly Alert") + +/datum/event_admin_setup/anomaly/anomaly_ectoplasm + ///The admin-selected intensity + var/chosen_effect + ///The number of ghosts the admin has selected to simulate orbiting the anomaly. + var/ghost_override + +/datum/event_admin_setup/anomaly/anomaly_ectoplasm/prompt_admins() + . = ..() + + if(tgui_alert(usr, "Override the anomaly effect and power?", "You'll be ruining the authenticity.", list("Yes", "No")) == "Yes") + var/list/power_values = list(ANOMALY_INTENSITY_MINOR, ANOMALY_INTENSITY_MODERATE, ANOMALY_INTENSITY_MAJOR) + chosen_effect = tgui_input_list(usr, "Provide effect override", "Criiiiinge.", power_values) + if(!chosen_effect) + return ADMIN_CANCEL_EVENT + + ghost_override = tgui_input_number(usr, "How many ghosts do you want simulate orbiting your anomaly? (determines the effect radius).", "Seriously, CRINGE.", 0, 20, 1) + if(!ghost_override) + return ADMIN_CANCEL_EVENT + + switch(chosen_effect) //Converts the text choice into a number for the anomaly to use + if(ANOMALY_INTENSITY_MINOR) + chosen_effect = 10 + if(ANOMALY_INTENSITY_MODERATE) + chosen_effect = 35 + if(ANOMALY_INTENSITY_MAJOR) + chosen_effect = 50 + +/datum/event_admin_setup/anomaly/anomaly_ectoplasm/apply_to_event(datum/round_event/anomaly/anomaly_ectoplasm/event) + . = ..() + event.effect_override = chosen_effect + event.orbit_override = ghost_override + +#undef ANOMALY_INTENSITY_MINOR +#undef ANOMALY_INTENSITY_MODERATE +#undef ANOMALY_INTENSITY_MAJOR diff --git a/code/modules/mob/living/basic/retaliate/ghost.dm b/code/modules/mob/living/basic/retaliate/ghost.dm index ca6aef5e381..e8f65ae4737 100644 --- a/code/modules/mob/living/basic/retaliate/ghost.dm +++ b/code/modules/mob/living/basic/retaliate/ghost.dm @@ -107,3 +107,18 @@ /datum/ai_behavior/basic_melee_attack/ghost action_cooldown = 2 SECONDS + + +/// Weaker variant of ghosts that can smash structures. Meant to be summoned in swarms via the ectoplasmic anomaly and associated ghost portal. +/mob/living/basic/ghost/swarm + name = "vengeful spirit" + desc = "Back from the grave, and not happy about it." + maxHealth = 30 + health = 30 + attack_verb_continuous = "smashes" + attack_verb_simple = "smash" + melee_damage_lower = 10 + melee_damage_upper = 10 + death_message = "wails as it is torn back to the realm from which it came!" + random_identity = FALSE + environment_smash = ENVIRONMENT_SMASH_STRUCTURES diff --git a/code/modules/research/anomaly/anomaly_core.dm b/code/modules/research/anomaly/anomaly_core.dm index ef5270075be..56220956182 100644 --- a/code/modules/research/anomaly/anomaly_core.dm +++ b/code/modules/research/anomaly/anomaly_core.dm @@ -81,3 +81,9 @@ desc = "The neutralized core of a dimensional anomaly. Objects reflected on its surface don't look quite right. It'd probably be valuable for research." icon_state = "dimensional_core" anomaly_type = /obj/effect/anomaly/dimensional + +/obj/item/assembly/signaler/anomaly/ectoplasm + name = "\improper ectoplasm anomaly core" + desc = "The neutralized core of an ectoplasmic anomaly. When you hold it close, you can hear faint murmuring from inside. It'd probably be valuable for research." + icon_state = "dimensional_core" + anomaly_type = /obj/effect/anomaly/ectoplasm diff --git a/code/modules/research/anomaly/raw_anomaly.dm b/code/modules/research/anomaly/raw_anomaly.dm index 30dc586c0b1..966f3e9435b 100644 --- a/code/modules/research/anomaly/raw_anomaly.dm +++ b/code/modules/research/anomaly/raw_anomaly.dm @@ -68,6 +68,12 @@ anomaly_type = /obj/item/assembly/signaler/anomaly/dimensional icon_state = "rawcore_dimensional" +/obj/item/raw_anomaly_core/ectoplasm //Has no cargo order option, but can sometimes be a roundstart pick + name = "\improper ectoplasm anomaly core" + desc = "The raw core of an ectoplasmic anomaly. It wants to share its secrets with you." + anomaly_type = /obj/item/assembly/signaler/anomaly/ectoplasm + icon_state = "dimensional_core" + /obj/item/raw_anomaly_core/random/Initialize(mapload) . = ..() var/path = pick(subtypesof(/obj/item/raw_anomaly_core)) diff --git a/icons/effects/anomalies.dmi b/icons/effects/anomalies.dmi index 451a1a8cf49..d3d0233f28b 100644 Binary files a/icons/effects/anomalies.dmi and b/icons/effects/anomalies.dmi differ diff --git a/tgstation.dme b/tgstation.dme index e4c2cf3c6ae..e455ef19a53 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -1718,6 +1718,7 @@ #include "code\game\objects\effects\anomalies\anomalies_bluespace.dm" #include "code\game\objects\effects\anomalies\anomalies_dimensional.dm" #include "code\game\objects\effects\anomalies\anomalies_dimensional_themes.dm" +#include "code\game\objects\effects\anomalies\anomalies_ectoplasm.dm" #include "code\game\objects\effects\anomalies\anomalies_flux.dm" #include "code\game\objects\effects\anomalies\anomalies_gravity.dm" #include "code\game\objects\effects\anomalies\anomalies_hallucination.dm" @@ -3241,6 +3242,7 @@ #include "code\modules\events\anomaly\anomaly_bioscrambler.dm" #include "code\modules\events\anomaly\anomaly_bluespace.dm" #include "code\modules\events\anomaly\anomaly_dimensional.dm" +#include "code\modules\events\anomaly\anomaly_ectoplasm.dm" #include "code\modules\events\anomaly\anomaly_flux.dm" #include "code\modules\events\anomaly\anomaly_grav.dm" #include "code\modules\events\anomaly\anomaly_hallucination.dm"