From 5c8f46a2d9d281f34db26c8442deefc04e9c6727 Mon Sep 17 00:00:00 2001 From: Geeves Date: Thu, 22 Oct 2020 17:22:18 +0200 Subject: [PATCH] Ported Map Effects (#10295) --- aurorastation.dme | 7 + code/game/sound.dm | 66 ++++ .../modules/effects/map_effects/beam_point.dm | 176 +++++++++ .../effects/map_effects/effect_emitter.dm | 54 +++ .../effects/map_effects/map_effects.dm | 69 ++++ .../effects/map_effects/perma_light.dm | 28 ++ code/modules/effects/map_effects/portal.dm | 345 ++++++++++++++++++ .../effects/map_effects/screen_shaker.dm | 18 + .../effects/map_effects/sound_emitter.dm | 85 +++++ code/modules/mob/living/say.dm | 4 +- .../geeves-thinking_with_portals.yml | 6 + icons/effects/map_effects.dmi | Bin 0 -> 9181 bytes 12 files changed, 856 insertions(+), 2 deletions(-) create mode 100644 code/modules/effects/map_effects/beam_point.dm create mode 100644 code/modules/effects/map_effects/effect_emitter.dm create mode 100644 code/modules/effects/map_effects/map_effects.dm create mode 100644 code/modules/effects/map_effects/perma_light.dm create mode 100644 code/modules/effects/map_effects/portal.dm create mode 100644 code/modules/effects/map_effects/screen_shaker.dm create mode 100644 code/modules/effects/map_effects/sound_emitter.dm create mode 100644 html/changelogs/geeves-thinking_with_portals.yml create mode 100644 icons/effects/map_effects.dmi diff --git a/aurorastation.dme b/aurorastation.dme index 6d0dfec5fdf..0742a4a3b09 100644 --- a/aurorastation.dme +++ b/aurorastation.dme @@ -1536,6 +1536,13 @@ #include "code\modules\effects\effect_system.dm" #include "code\modules\effects\ion_trail_follow.dm" #include "code\modules\effects\visual_effect.dm" +#include "code\modules\effects\map_effects\beam_point.dm" +#include "code\modules\effects\map_effects\effect_emitter.dm" +#include "code\modules\effects\map_effects\map_effects.dm" +#include "code\modules\effects\map_effects\perma_light.dm" +#include "code\modules\effects\map_effects\portal.dm" +#include "code\modules\effects\map_effects\screen_shaker.dm" +#include "code\modules\effects\map_effects\sound_emitter.dm" #include "code\modules\effects\sparks\procs.dm" #include "code\modules\effects\sparks\spawner.dm" #include "code\modules\effects\sparks\visuals.dm" diff --git a/code/game/sound.dm b/code/game/sound.dm index 7be83a774de..36554906d67 100644 --- a/code/game/sound.dm +++ b/code/game/sound.dm @@ -593,3 +593,69 @@ 'sound/weapons/grab/grab4.ogg', 'sound/weapons/grab/grab5.ogg' ) + +/decl/sound_category/gunshots + sounds = list( + 'sound/weapons/gunshot/bolter.ogg', + 'sound/weapons/laser1.ogg', + 'sound/weapons/Laser2.ogg', + 'sound/weapons/laser3.ogg', + 'sound/weapons/lasercannonfire.ogg', + 'sound/weapons/marauder.ogg', + 'sound/weapons/laserdeep.ogg', + 'sound/weapons/laserstrong.ogg', + 'sound/weapons/gunshot/gunshot_dmr.ogg', + 'sound/weapons/gunshot/gunshot_light.ogg', + 'sound/weapons/gunshot/gunshot_mateba.ogg', + 'sound/weapons/gunshot/gunshot_pistol.ogg', + 'sound/weapons/gunshot/gunshot_revolver.ogg', + 'sound/weapons/gunshot/gunshot_rifle.ogg', + 'sound/weapons/gunshot/gunshot_saw.ogg', + 'sound/weapons/gunshot/gunshot_shotgun.ogg', + 'sound/weapons/gunshot/gunshot_shotgun2.ogg', + 'sound/weapons/gunshot/gunshot_smg.ogg', + 'sound/weapons/gunshot/gunshot_strong.ogg', + 'sound/weapons/gunshot/gunshot_suppressed.ogg', + 'sound/weapons/gunshot/gunshot_svd.ogg', + 'sound/weapons/gunshot/gunshot_tommygun.ogg', + 'sound/weapons/gunshot/gunshot1.ogg', + 'sound/weapons/gunshot/gunshot2.ogg', + 'sound/weapons/gunshot/gunshot3.ogg', + 'sound/weapons/gunshot/musket.ogg', + 'sound/weapons/gunshot/slammer.ogg' +) + +/decl/sound_category/gunshots/ballistic + sounds = list( + 'sound/weapons/gunshot/gunshot_dmr.ogg', + 'sound/weapons/gunshot/gunshot_light.ogg', + 'sound/weapons/gunshot/gunshot_mateba.ogg', + 'sound/weapons/gunshot/gunshot_pistol.ogg', + 'sound/weapons/gunshot/gunshot_revolver.ogg', + 'sound/weapons/gunshot/gunshot_rifle.ogg', + 'sound/weapons/gunshot/gunshot_saw.ogg', + 'sound/weapons/gunshot/gunshot_shotgun.ogg', + 'sound/weapons/gunshot/gunshot_shotgun2.ogg', + 'sound/weapons/gunshot/gunshot_smg.ogg', + 'sound/weapons/gunshot/gunshot_strong.ogg', + 'sound/weapons/gunshot/gunshot_suppressed.ogg', + 'sound/weapons/gunshot/gunshot_svd.ogg', + 'sound/weapons/gunshot/gunshot_tommygun.ogg', + 'sound/weapons/gunshot/gunshot1.ogg', + 'sound/weapons/gunshot/gunshot2.ogg', + 'sound/weapons/gunshot/gunshot3.ogg', + 'sound/weapons/gunshot/musket.ogg', + 'sound/weapons/gunshot/slammer.ogg' +) + +/decl/sound_category/gunshots/energy + sounds = list( + 'sound/weapons/gunshot/bolter.ogg', + 'sound/weapons/laser1.ogg', + 'sound/weapons/Laser2.ogg', + 'sound/weapons/laser3.ogg', + 'sound/weapons/lasercannonfire.ogg', + 'sound/weapons/marauder.ogg', + 'sound/weapons/laserdeep.ogg', + 'sound/weapons/laserstrong.ogg' +) \ No newline at end of file diff --git a/code/modules/effects/map_effects/beam_point.dm b/code/modules/effects/map_effects/beam_point.dm new file mode 100644 index 00000000000..cca04178e48 --- /dev/null +++ b/code/modules/effects/map_effects/beam_point.dm @@ -0,0 +1,176 @@ +var/global/list/all_beam_points + +// Creates and manages a beam attached to itself and another beam_point. +// You can do cool things with these such as moving the beam_point to move the beam, turning them on and off on a timer, triggered by external input, and more. +/obj/effect/map_effect/beam_point + name = "beam point" + icon_state = "beam_point" + + // General variables. + var/list/my_beams = list() // Instances of beams. Deleting one will kill the beam. + var/id = "A" // Two beam_points must share the same ID to be connected to each other. + var/max_beams = 10 // How many concurrent beams to seperate beam_points to have at once. Set to zero to only act as targets for other beam_points. + var/seek_range = 7 // How far to look for an end beam_point when not having a beam. Defaults to screen height/width. Make sure this is below beam_max_distance. + + // Controls how and when the beam is created. + var/make_beams_on_init = FALSE + var/use_timer = FALSE // Sadly not the /tg/ timers. + var/list/on_duration = list(2 SECONDS, 2 SECONDS, 2 SECONDS) // How long the beam should stay on for, if use_timer is true. Alternates between each duration in the list. + var/list/off_duration = list(3 SECONDS, 0.5 SECOND, 0.5 SECOND) // How long it should stay off for. List length is not needed to be the same as on_duration. + var/timer_on_index = 1 // Index to use for on_duration list. + var/timer_off_index = 1// Ditto, for off_duration list. + var/initial_delay = 0 // How long to wait before first turning on the beam, to sync beam times or create a specific pattern. + var/beam_creation_sound = null // Optional sound played when one or more beams are created. + var/beam_destruction_sound = null // Optional sound played when a beam is destroyed. + + // Beam datum arguments. + var/beam_icon = 'icons/effects/beam.dmi' // Icon file to use for beam visuals. + var/beam_icon_state = "b_beam" // Icon state to use for visuals. + var/beam_time = -1 // How long the beam lasts. By default it will last forever until destroyed. + var/beam_max_distance = 10 // If the beam is farther than this, it will be destroyed. Make sure it's higher than seek_range. + var/beam_type = /obj/effect/ebeam // The type of beam. Default has no special properties. Some others may do things like hurt things touching it. + var/beam_sleep_time = 3 // How often the beam updates visually. Suggested to leave this alone, 3 is already fast. + +/obj/effect/map_effect/beam_point/Initialize() + LAZYADD(all_beam_points, src) + if(make_beams_on_init) + create_beams() + if(use_timer) + addtimer(CALLBACK(src, .proc/handle_beam_timer), initial_delay) + return ..() + +/obj/effect/map_effect/beam_point/Destroy() + destroy_all_beams() + use_timer = FALSE + LAZYREMOVE(all_beam_points, src) + return ..() + +// This is the top level proc to make the magic happen. +/obj/effect/map_effect/beam_point/proc/create_beams() + if(length(my_beams) >= max_beams) + return + var/beams_to_fill = max_beams - length(my_beams) + for(var/i = 1 to beams_to_fill) + var/obj/effect/map_effect/beam_point/point = seek_beam_point() + if(!point) + break // No more points could be found, no point checking repeatively. + build_beam(point) + +// Finds a suitable beam point. +/obj/effect/map_effect/beam_point/proc/seek_beam_point() + for(var/obj/effect/map_effect/beam_point/point in all_beam_points) + if(id != point.id) + continue // Not linked together by ID. + if(has_active_beam(point)) + continue // Already got one. + if(point.z != src.z) + continue // Not on same z-level. get_dist() ignores z-levels by design according to docs. + if(get_dist(src, point) > seek_range) + continue // Too far. + return point + +// Checks if the two points have an active beam between them. +// Used to make sure two points don't have more than one beam. +/obj/effect/map_effect/beam_point/proc/has_active_beam(var/obj/effect/map_effect/beam_point/them) + // First, check our beams. + for(var/datum/beam/B in my_beams) + if(B.target == them) + return TRUE + if(B.origin == them) // This shouldn't be needed unless the beam gets built backwards but why not. + return TRUE + + // Now check theirs, to see if they have a beam on us. + for(var/datum/beam/B in them.my_beams) + if(B.target == src) + return TRUE + if(B.origin == src) // Same story as above. + return TRUE + + return FALSE + +/obj/effect/map_effect/beam_point/proc/build_beam(var/atom/beam_target) + if(!beam_target) + log_debug("[src] ([src.type] \[[x],[y],[z]\]) failed to build its beam due to not having a target.") + return FALSE + + var/datum/beam/new_beam = Beam(beam_target, beam_icon_state, beam_icon, beam_time, beam_max_distance, beam_type, beam_sleep_time) + my_beams += new_beam + if(beam_creation_sound) + playsound(src, beam_creation_sound, 70, 1) + + return TRUE + +/obj/effect/map_effect/beam_point/proc/destroy_beam(var/datum/beam/B) + if(!B) + log_debug("[src] ([src.type] \[[x],[y],[z]\]) was asked to destroy a beam that does not exist.") + return FALSE + + if(!(B in my_beams)) + log_debug("[src] ([src.type] \[[x],[y],[z]\]) was asked to destroy a beam it did not own.") + return FALSE + + my_beams -= B + qdel(B) + if(beam_destruction_sound) + playsound(src, beam_destruction_sound, 70, 1) + + return TRUE + +/obj/effect/map_effect/beam_point/proc/destroy_all_beams() + for(var/datum/beam/B in my_beams) + destroy_beam(B) + return TRUE + +// This code makes me sad. +/obj/effect/map_effect/beam_point/proc/handle_beam_timer() + if(!use_timer || QDELETED(src)) + return + + if(length(my_beams)) // Currently on. + destroy_all_beams() + color = "#FF0000" + + timer_off_index++ + if(timer_off_index > off_duration.len) + timer_off_index = 1 + + spawn(off_duration[timer_off_index]) + .() + + else // Currently off. + // If nobody's around, keep the beams off to avoid wasteful beam process(), if they have one. + if(!always_run && !check_for_player_proximity(src, proximity_needed, ignore_ghosts, ignore_afk)) + spawn(retry_delay) + .() + return + + create_beams() + color = "#00FF00" + + timer_on_index++ + if(timer_on_index > on_duration.len) + timer_on_index = 1 + + spawn(on_duration[timer_on_index]) + .() + + +// Subtypes to use in maps and adminbuse. +// Remember, beam_points ONLY connect to other beam_points with the same id variable. + +// Creates the beam when instantiated and stays on until told otherwise. +/obj/effect/map_effect/beam_point/instant + make_beams_on_init = TRUE + +// Turns on and off on a timer. +/obj/effect/map_effect/beam_point/timer + use_timer = TRUE + +// Is only a target for other beams to connect to. +/obj/effect/map_effect/beam_point/end + max_beams = 0 + +// Can only have one beam. +/obj/effect/map_effect/beam_point/mono + make_beams_on_init = TRUE + max_beams = 1 \ No newline at end of file diff --git a/code/modules/effects/map_effects/effect_emitter.dm b/code/modules/effects/map_effects/effect_emitter.dm new file mode 100644 index 00000000000..11fe6d24eb7 --- /dev/null +++ b/code/modules/effects/map_effects/effect_emitter.dm @@ -0,0 +1,54 @@ +// Creates effects like smoke clouds every so often. +/obj/effect/map_effect/interval/effect_emitter + var/datum/effect/effect/system/effect_system = null + var/effect_system_type = null // Which effect system to attach. + + var/effect_amount = 10 // How many effect objects to create on each interval. Note that there's a hard cap on certain effect_systems. + var/effect_cardinals_only = FALSE // If true, effects only move in cardinal directions. + var/effect_forced_dir = null // If set, effects emitted will always move in this direction. + +/obj/effect/map_effect/interval/effect_emitter/Initialize() + effect_system = new effect_system_type() + effect_system.attach(src) + configure_effects() + return ..() + +/obj/effect/map_effect/interval/effect_emitter/interval/Destroy() + QDEL_NULL(effect_system) + return ..() + +/obj/effect/map_effect/interval/effect_emitter/proc/configure_effects() + effect_system.set_up(effect_amount, effect_cardinals_only, src.loc, effect_forced_dir) + +/obj/effect/map_effect/interval/effect_emitter/trigger() + configure_effects() // We do this every interval in case it changes. + effect_system.start() + ..() + +// Creates smoke clouds every so often. +/obj/effect/map_effect/interval/effect_emitter/smoke + name = "smoke emitter" + icon_state = "smoke_emitter" + effect_system_type = /datum/effect/effect/system/smoke_spread + + interval_lower_bound = 1 SECOND + interval_upper_bound = 1 SECOND + effect_amount = 2 + +/obj/effect/map_effect/interval/effect_emitter/smoke/bad + name = "bad smoke emitter" + effect_system_type = /datum/effect/effect/system/smoke_spread/bad + +// Makes sparks. +/obj/effect/map_effect/interval/effect_emitter/sparks + name = "spark emitter" + icon_state = "spark_emitter" + effect_system_type = /datum/effect_system/sparks + + interval_lower_bound = 3 SECONDS + interval_upper_bound = 7 SECONDS + +/obj/effect/map_effect/interval/effect_emitter/sparks/frequent + effect_amount = 4 // Otherwise it caps out fast. + interval_lower_bound = 1 + interval_upper_bound = 3 SECONDS \ No newline at end of file diff --git a/code/modules/effects/map_effects/map_effects.dm b/code/modules/effects/map_effects/map_effects.dm new file mode 100644 index 00000000000..656110698de --- /dev/null +++ b/code/modules/effects/map_effects/map_effects.dm @@ -0,0 +1,69 @@ +// These are objects you can use inside special maps (like PoIs), or for adminbuse. +// Players cannot see or interact with these. +/obj/effect/map_effect + anchored = TRUE + invisibility = 99 // So a badmin can go view these by changing their see_invisible. + icon = 'icons/effects/map_effects.dmi' + + // Below vars concern check_for_player_proximity() and is used to not waste effort if nobody is around to appreciate the effects. + var/always_run = FALSE // If true, the game will not try to suppress this from firing if nobody is around to see it. + var/proximity_needed = 12 // How many tiles a mob with a client must be for this to run. + var/ignore_ghosts = FALSE // If true, ghosts won't satisfy the above requirement. + var/ignore_afk = TRUE // If true, AFK people (5 minutes) won't satisfy it as well. + var/retry_delay = 5 SECONDS // How long until we check for players again. + var/next_attempt = 0 // Next time we're going to do ACTUAL WORK + +/obj/effect/map_effect/ex_act() + return + +/obj/effect/map_effect/singularity_pull() + return + +/obj/effect/map_effect/singularity_act() + return + +// Base type for effects that run on variable intervals. +/obj/effect/map_effect/interval + var/interval_lower_bound = 5 SECONDS // Lower number for how often the map_effect will trigger. + var/interval_upper_bound = 5 SECONDS // Higher number for above. + +/obj/effect/map_effect/interval/Initialize() + . = ..() + START_PROCESSING(SSprocessing, src) + +/obj/effect/map_effect/interval/Destroy() + STOP_PROCESSING(SSprocessing, src) + return ..() + +// Override this for the specific thing to do. +/obj/effect/map_effect/interval/proc/trigger() + return + +// Handles the delay and making sure it doesn't run when it would be bad. +/obj/effect/map_effect/interval/process() + //Not yet! + if(world.time < next_attempt) + return + + // Check to see if we're useful first. + if(!always_run && !check_for_player_proximity(src, proximity_needed, ignore_ghosts, ignore_afk)) + next_attempt = world.time + retry_delay + // Hey there's someone nearby. + else + next_attempt = world.time + rand(interval_lower_bound, interval_upper_bound) + trigger() + +// Helper proc to optimize the use of effects by making sure they do not run if nobody is around to perceive it. +/proc/check_for_player_proximity(var/atom/proximity_to, var/radius = 12, var/ignore_ghosts = FALSE, var/ignore_afk = TRUE) + if(!proximity_to) + return FALSE + + for(var/thing in player_list) + var/mob/M = thing // Avoiding typechecks for more speed, player_list will only contain mobs anyways. + if(ignore_ghosts && isobserver(M)) + continue + if(ignore_afk && M.client && M.client.is_afk(5 MINUTES)) + continue + if(M.z == proximity_to.z && get_dist(M, proximity_to) <= radius) + return TRUE + return FALSE \ No newline at end of file diff --git a/code/modules/effects/map_effects/perma_light.dm b/code/modules/effects/map_effects/perma_light.dm new file mode 100644 index 00000000000..7c9344c090c --- /dev/null +++ b/code/modules/effects/map_effects/perma_light.dm @@ -0,0 +1,28 @@ +// Emits light forever with magic. Useful for mood lighting in Points of Interest. +// Be sure to check how it looks ingame, and fiddle with the settings until it looks right. +/obj/effect/map_effect/perma_light + name = "permanent light" + icon_state = "permalight" + + light_range = 3 + light_power = 1 + light_color = "#FFFFFF" + +/obj/effect/map_effect/perma_light/brighter + name = "permanent light (bright)" + icon_state = "permalight" + + light_range = 5 + light_power = 3 + light_color = "#FFFFFF" + +/obj/effect/map_effect/perma_light/concentrated + name = "permanent light (concentrated)" + + light_range = 2 + light_power = 5 + +/obj/effect/map_effect/perma_light/concentrated/halogen + name = "permanent light (concentrated halogen)" + + light_color = LIGHT_COLOR_HALOGEN \ No newline at end of file diff --git a/code/modules/effects/map_effects/portal.dm b/code/modules/effects/map_effects/portal.dm new file mode 100644 index 00000000000..c4764fb8edb --- /dev/null +++ b/code/modules/effects/map_effects/portal.dm @@ -0,0 +1,345 @@ +var/global/list/all_portal_masters + +/* +Portal map effects allow a mapper to join two distant places together, while looking somewhat seamlessly connected. +This can allow for very strange PoIs that twist and turn in what appear to be physically impossible ways. +Portals do have some specific requirements when mapping them in; + - There must by one, and only one `/obj/effect/map_effect/portal/master` for each side of a portal. + - Both sides need to have matching `portal_id`s in order to link to each other. + - Each side must face opposite directions, e.g. if side A faces SOUTH, side B must face NORTH. + - Each side must have the same orientation, e.g. horizontal on both sides, or vertical on both sides. + - Portals can be made to be longer than 1x1 with `/obj/effect/map_effect/portal/line`s, + but both sides must have the same length. + - If portal lines are added, they must form a straight line and be next to a portal master or another portal line. + - If portal lines are used, both portal masters should be in the same relative position among the lines. + E.g. both being on the left most side on a horizontal row. +Portals also have some limitations to be aware of when mapping. Some of these are not an issue if you're trying to make an 'obvious' portal; + - The objects seen through portals are purely visual, which has many implications, + such as simple_mob AIs being blind to mobs on the other side of portals. + - Objects on the other side of a portal can be interacted with if the interaction has no range limitation, + or the distance between the two portal sides happens to be less than the interaction max range. Examine will probably work, + while picking up an item that appears to be next to you will fail. + - Sounds currently are not carried across portals. + - Mismatched lighting between each portal end can make the portal look obvious. + - Portals look weird when observing as a ghost, or otherwise when able to see through walls. Meson vision will also spoil the illusion. + - Walls that change icons based on neightboring walls can give away that a portal is nearby if both sides don't have a similar transition. + - Projectiles that pass through portals will generally work as intended, however aiming and firing upon someone on the other side of a portal + will likely be weird due to the click targeting the real position of the thing clicked instead of the apparent position. + Thrown objects suffer a similar fate. + - The tiles that are visually shown across a portal are determined based on visibility at the time of portal initialization, + and currently don't update, meaning that opacity changes are not reflected, e.g. a wall is deconstructed, or an airlock is opened. + - There is currently a small but somewhat noticable pause in mob movement when moving across a portal, + as a result of the mob's glide animation being inturrupted by a teleport. + - Gas is not transferred through portals, and ZAS is oblivious to them. +A lot of those limitations can potentially be solved with some more work. Otherwise, portals work best in static environments like Points of Interest, +when portals are shortly lived, or when portals are made to be obvious with special effects. +*/ + +/obj/effect/map_effect/portal + name = "portal subtype" + invisibility = 0 + opacity = TRUE + layer = ON_TURF_LAYER + appearance_flags = PIXEL_SCALE|KEEP_TOGETHER // Removed TILE_BOUND so things not visible on the other side stay hidden from the viewer. + + var/obj/effect/map_effect/portal/counterpart = null // The portal line or master that this is connected to, on the 'other side'. + + // Information used to apply `pixel_[x|y]` offsets so that the visuals line up. + // Set automatically by `calculate_dimensions()`. + var/total_height = 0 // Measured in tiles. + var/total_width = 0 + + var/portal_distance_x = 0 // How far the portal is from the left edge, in tiles. + var/portal_distance_y = 0 // How far the portal is from the top edge. + +/obj/effect/map_effect/portal/Destroy() + vis_contents = null + if(counterpart) + counterpart.counterpart = null // Disconnect our counterpart from us + counterpart = null // Now disconnect us from them. + return ..() + +// Called when something touches the portal, and usually teleports them to the other side. +/obj/effect/map_effect/portal/Crossed(atom/movable/AM) + ..() + if(!AM) + return + if(!counterpart) + return + + go_through_portal(AM) + + +/obj/effect/map_effect/portal/proc/go_through_portal(atom/movable/AM) + // TODO: Find a way to fake the glide or something. + if(isliving(AM)) + var/mob/living/L = AM + if(L.pulling) + var/atom/movable/pulled = L.pulling + L.stop_pulling() + // For some reason, trying to put the pulled object behind the person makes the drag stop and it doesn't even move to the other side. + // pulled.forceMove(get_turf(counterpart)) + pulled.forceMove(counterpart.get_focused_turf()) + L.forceMove(counterpart.get_focused_turf()) + L.start_pulling(pulled) + else + L.forceMove(counterpart.get_focused_turf()) + else + AM.forceMove(counterpart.get_focused_turf()) + +// 'Focused turf' is the turf directly in front of a portal, +// and it is used both as the destination when crossing, as well as the PoV for visuals. +/obj/effect/map_effect/portal/proc/get_focused_turf() + return get_step(get_turf(src), dir) + +// Determines the size of the block of turfs inside `vis_contents`, and where the portal is in relation to that. +/obj/effect/map_effect/portal/proc/calculate_dimensions() + var/highest_x = 0 + var/lowest_x = 0 + + var/highest_y = 0 + var/lowest_y = 0 + + // First pass is for finding the top right corner. + for(var/thing in vis_contents) + var/turf/T = thing + if(T.x > highest_x) + highest_x = T.x + if(T.y > highest_y) + highest_y = T.y + + lowest_x = highest_x + lowest_y = highest_y + + // Second one is for the bottom left corner. + for(var/thing in vis_contents) + var/turf/T = thing + if(T.x < lowest_x) + lowest_x = T.x + if(T.y < lowest_y) + lowest_y = T.y + + // Now calculate the dimensions. + total_width = (highest_x - lowest_x) + 1 + total_height = (highest_y - lowest_y) + 1 + + // Find how far the portal is from the edges. + var/turf/focused_T = counterpart.get_focused_turf() + portal_distance_x = lowest_x - focused_T.x + portal_distance_y = lowest_y - focused_T.y + + +// Portal masters manage everything else involving portals. +// This is the base type. Use `/side_a` or `/side_b` with matching IDs for actual portals. +/obj/effect/map_effect/portal/master + name = "portal master" + var/portal_id = "test" // For a portal to be made, both the A and B sides need to share the same ID value. + var/list/portal_lines = list() + +/obj/effect/map_effect/portal/master/Initialize() + LAZYADD(all_portal_masters, src) + LAZYADD(listening_objects, src) + find_lines() + ..() + return INITIALIZE_HINT_LATELOAD + +/obj/effect/map_effect/portal/master/LateInitialize() + find_counterparts() + make_visuals() + apply_offset() + +/obj/effect/map_effect/portal/master/Destroy() + LAZYREMOVE(all_portal_masters, src) + LAZYREMOVE(listening_objects, src) + for(var/thing in portal_lines) + qdel(thing) + return ..() + +/obj/effect/map_effect/portal/master/proc/find_lines() + var/list/dirs_to_search = list( turn(dir, 90), turn(dir, -90) ) + + for(var/dir_to_search in dirs_to_search) + var/turf/current_T = get_turf(src) + while(current_T) + current_T = get_step(current_T, dir_to_search) + var/obj/effect/map_effect/portal/line/line = locate() in current_T + if(line) + portal_lines += line + line.my_master = src + else + break + +// Connects both sides of a portal together. +/obj/effect/map_effect/portal/master/proc/find_counterparts() + for(var/thing in all_portal_masters) + var/obj/effect/map_effect/portal/master/M = thing + if(M == src) + continue + if(M.counterpart) + continue + + if(M.portal_id == src.portal_id) + counterpart = M + M.counterpart = src + if(portal_lines.len) + for(var/i = 1 to portal_lines.len) + var/obj/effect/map_effect/portal/line/our_line = portal_lines[i] + var/obj/effect/map_effect/portal/line/their_line = M.portal_lines[i] + our_line.counterpart = their_line + their_line.counterpart = our_line + break + + if(!counterpart) + crash_with("Portal master [type] ([x],[y],[z]) could not find another portal master with a matching portal_id ([portal_id]).") + +/obj/effect/map_effect/portal/master/proc/make_visuals() + var/list/observed_turfs = list() + for(var/thing in portal_lines + src) + var/obj/effect/map_effect/portal/P = thing + P.name = null + P.icon_state = null + + if(!P.counterpart) + return + + var/turf/T = P.counterpart.get_focused_turf() + P.vis_contents += T + + var/list/things = list() + DVIEW(things, world.view, T, INVISIBILITY_LIGHTING) + for(var/turf/turf in things) + if(get_dir(turf, T) & P.dir) + if(turf in observed_turfs) // Avoid showing the same turf twice or more for improved performance. + continue + + P.vis_contents += turf + observed_turfs += turf + + P.calculate_dimensions() + +// Shifts the portal's pixels in order to line up properly, as BYOND offsets the sprite when it holds multiple turfs inside `vis_contents`. +// This undos the shift that BYOND did. +/obj/effect/map_effect/portal/master/proc/apply_offset() + for(var/thing in portal_lines + src) + var/obj/effect/map_effect/portal/P = thing + + P.pixel_x = WORLD_ICON_SIZE * P.portal_distance_x + P.pixel_y = WORLD_ICON_SIZE * P.portal_distance_y + +// Allows portals to transfer emotes. +// Only portal masters do this to avoid flooding the other side with duplicate messages. +/obj/effect/map_effect/portal/master/see_emote(mob/M, text) + if(!counterpart) + return + var/turf/T = counterpart.get_focused_turf() + var/list/mobs_to_relay = list() + var/list/objs = list() + get_mobs_and_objs_in_view_fast(T, world.view, mobs_to_relay, objs) + + for(var/thing in mobs_to_relay) + var/mob/mob = thing + var/rendered = "[text]" + mob.show_message(rendered) + + ..() + +// Allows portals to transfer visible messages. +/obj/effect/map_effect/portal/master/show_message(msg, type, alt, alt_type) + if(!counterpart) + return + var/rendered = "[msg]" + var/turf/T = counterpart.get_focused_turf() + var/list/mobs_to_relay = list() + var/list/objs = list() + get_mobs_and_objs_in_view_fast(T, world.view, mobs_to_relay, objs) + + for(var/thing in mobs_to_relay) + var/mob/mob = thing + mob.show_message(rendered) + + ..() + +// Allows portals to transfer speech. +/obj/effect/map_effect/portal/master/hear_talk(mob/M, text, verb, datum/language/speaking) + if(!counterpart) + return + var/turf/T = counterpart.get_focused_turf() + var/list/mobs_to_relay = list() + var/list/objs = list() + get_mobs_and_objs_in_view_fast(T, world.view, mobs_to_relay, objs) + + for(var/thing in mobs_to_relay) + var/mob/mob = thing + var/accent_icon = M.get_accent_icon(speaking, src) + var/name_used = M.GetVoice() + var/rendered = null + if(speaking) + rendered = "[accent_icon ? accent_icon + " " : ""][name_used] [speaking.format_message(text, verb)]" + else + rendered = "[name_used] [verb], \"[text]\"" + mob.show_message(rendered, 2) + + ..() + +// Returns the position that an atom that's hopefully on the other side of the portal would be if it were really there. +// Z levels not taken into account. +/obj/effect/map_effect/portal/master/proc/get_apparent_position(atom/A) + if(!counterpart) + return null + + var/turf/true_turf = get_turf(A) + var/obj/effect/map_effect/portal/master/other_master = counterpart + + var/in_vis_contents = FALSE + for(var/thing in other_master.portal_lines + other_master) + var/obj/effect/map_effect/portal/P = thing + if(P in true_turf.vis_locs) + in_vis_contents = TRUE + break + + if(!in_vis_contents) + return null // Not in vision of the other portal. + + var/turf/their_focus = counterpart.get_focused_turf() + var/turf/our_focus = get_focused_turf() + + var/relative_x = (true_turf.x - our_focus.x) + relative_x += SIGN(relative_x) + var/relative_y = (true_turf.y - our_focus.y) + relative_y += SIGN(relative_y) + + return new /datum/position(their_focus.x + relative_x, their_focus.y + relative_y, our_focus.z) + + +/obj/effect/map_effect/portal/master/side_a + name = "portal master A" + icon_state = "portal_side_a" +// color = "#00FF00" + +/obj/effect/map_effect/portal/master/side_b + name = "portal master B" + icon_state = "portal_side_b" +// color = "#FF0000" + + + +// Portal lines extend out from the sides of portal masters, +// They let portals be longer than 1x1. +// Both sides MUST be the same length, meaning if side A is 1x3, side B must also be 1x3. +/obj/effect/map_effect/portal/line + name = "portal line" + var/obj/effect/map_effect/portal/master/my_master = null + +/obj/effect/map_effect/portal/line/Destroy() + if(my_master) + my_master.portal_lines -= src + my_master = null + return ..() + +/obj/effect/map_effect/portal/line/side_a + name = "portal line A" + icon_state = "portal_line_side_a" + +/obj/effect/map_effect/portal/line/side_b + name = "portal line B" + icon_state = "portal_line_side_b" \ No newline at end of file diff --git a/code/modules/effects/map_effects/screen_shaker.dm b/code/modules/effects/map_effects/screen_shaker.dm new file mode 100644 index 00000000000..960546c374a --- /dev/null +++ b/code/modules/effects/map_effects/screen_shaker.dm @@ -0,0 +1,18 @@ +// Makes the screen shake for nearby players every so often. +/obj/effect/map_effect/interval/screen_shaker + name = "screen shaker" + icon_state = "screen_shaker" + + interval_lower_bound = 1 SECOND + interval_upper_bound = 2 SECONDS + + var/shake_radius = 7 // How far the shaking effect extends to. By default it is one screen length. + var/shake_duration = 2 // How long the shaking lasts. + var/shake_strength = 1 // How much it shakes. + +/obj/effect/map_effect/interval/screen_shaker/trigger() + for(var/A in player_list) + var/mob/M = A + if(M.z == src.z && get_dist(src, M) <= shake_radius) + shake_camera(M, shake_duration, shake_strength) + ..() \ No newline at end of file diff --git a/code/modules/effects/map_effects/sound_emitter.dm b/code/modules/effects/map_effects/sound_emitter.dm new file mode 100644 index 00000000000..3b70e1d2abd --- /dev/null +++ b/code/modules/effects/map_effects/sound_emitter.dm @@ -0,0 +1,85 @@ +// Plays a sound at its location every so often. +/obj/effect/map_effect/interval/sound_emitter + name = "sound emitter" + icon_state = "sound_emitter" + var/list/sounds_to_play = list(null) // List containing sound files or strings of sound groups. + // A sound or string is picked randomly each run. + + var/sound_volume = 50 // How loud the sound is. 0 is silent, and 100 is loudest. Please be reasonable with the volume. + // Note that things like vacuum may affect the volume heard by other mobs. + + var/sound_frequency_variance = TRUE // If the sound will sound somewhat different each time. + // If a specific frequency is desired, sound_frequency must also be set. + + var/sound_extra_range = 0 // Set to make sounds heard from farther away than normal. + + var/sound_fallout = 0 // Within the 'fallout distance', the sound stays at the same volume, otherwise it attenuates. + // Higher numbers make the sound fade out more slowly with distance. + + var/sound_global = FALSE // If true, sounds will not be distorted due to the current area's 'sound environment'. + // It DOES NOT make the sound have a constant volume or z-level wide range, despite the misleading name. + + var/sound_frequency = null // Sets a specific custom frequency. sound_frequency_variance must be true as well. + // If sound_frequency is null, but sound_frequency_variance is true, a semi-random frequency will be chosen to the sound each time. + + var/sound_channel = 0 // BYOND allows a sound to play in 1 through 1024 sound channels. + // 0 will have BYOND give it the lowest available channel, it is not recommended to change this without a good reason. + + var/sound_pressure_affected = TRUE // If false, people in low pressure or vacuum will hear the sound. + + var/sound_ignore_walls = TRUE // If false, walls will completely muffle the sound. + + var/sound_preference = null // Player preference to check before playing this sound to them, if any. + +/obj/effect/map_effect/interval/sound_emitter/trigger() + playsound( + src, + pick(sounds_to_play), + sound_volume, + sound_frequency_variance, + sound_extra_range, + sound_fallout, + sound_global, + sound_frequency, + sound_channel, + sound_pressure_affected, + sound_ignore_walls, + sound_preference + ) + ..() + +/obj/effect/map_effect/interval/sound_emitter/punching + sounds_to_play = list(/decl/sound_category/punch_sound) + interval_lower_bound = 5 + interval_upper_bound = 1 SECOND + +/obj/effect/map_effect/interval/sound_emitter/explosions + sounds_to_play = list(/decl/sound_category/explosion_sound) + interval_lower_bound = 5 SECONDS + interval_upper_bound = 10 SECONDS + +/obj/effect/map_effect/interval/sound_emitter/explosions/distant + sounds_to_play = list('sound/effects/explosionfar.ogg') + +/obj/effect/map_effect/interval/sound_emitter/gunfight + sounds_to_play = list(/decl/sound_category/gunshots) + interval_lower_bound = 5 + interval_upper_bound = 2 SECONDS + +/obj/effect/map_effect/interval/sound_emitter/gunfight/ballistic + sounds_to_play = list(/decl/sound_category/gunshots/ballistic) + +/obj/effect/map_effect/interval/sound_emitter/gunfight/energy + sounds_to_play = list(/decl/sound_category/gunshots/energy) + + +// I'm not sorry. +/obj/effect/map_effect/interval/sound_emitter/clownsteps + sounds_to_play = list(/decl/sound_category/clown_sound) + interval_lower_bound = 5 + interval_upper_bound = 1 SECOND + +/obj/effect/map_effect/interval/sound_emitter/bikehorns + sounds_to_play = list('sound/items/bikehorn.ogg') + interval_lower_bound = 5 + interval_upper_bound = 1 SECOND \ No newline at end of file diff --git a/code/modules/mob/living/say.dm b/code/modules/mob/living/say.dm index ac9f4d2a5bb..3625c71cbb5 100644 --- a/code/modules/mob/living/say.dm +++ b/code/modules/mob/living/say.dm @@ -322,5 +322,5 @@ proc/get_radio_key_from_channel(var/channel) /obj/effect/speech_bubble var/mob/parent -/mob/living/proc/GetVoice() - return name +/mob/proc/GetVoice() + return name \ No newline at end of file diff --git a/html/changelogs/geeves-thinking_with_portals.yml b/html/changelogs/geeves-thinking_with_portals.yml new file mode 100644 index 00000000000..185aaba9dde --- /dev/null +++ b/html/changelogs/geeves-thinking_with_portals.yml @@ -0,0 +1,6 @@ +author: Geeves + +delete-after: True + +changes: + - backend: "Ported various map effects from Virgo and Polaris, which can be used by mappers in the future to do cool things, such as portals, permanent lights, smoke emitters, screen shakers, and sound emitters." \ No newline at end of file diff --git a/icons/effects/map_effects.dmi b/icons/effects/map_effects.dmi new file mode 100644 index 0000000000000000000000000000000000000000..0f28f9d2da8e936f2749a39bd4edfe70c083bf2f GIT binary patch literal 9181 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 v$XGvz2{|X