From cecf6766840dc3b9704ebd7934ac182b1de2863d Mon Sep 17 00:00:00 2001 From: Militaires Date: Sun, 3 Feb 2019 23:25:48 +0200 Subject: [PATCH] [Ready] Outputs datum (#41535) Idea and instructions by @Razharas, many thanks. This PR only implements the framework required to catalog as well as play the sounds. Their to_chat text, and icons (if any) are not included, are to be pushed in a separate PR. This PR does not remove the old playsound_local system, it is kept for the sake of not necessitating a direct changeover of every single playsound in the code, which will surely cause a lot of merge conflicts. It does however, replace bike horns' and toy nukes' means of playback to this datum, purely as proof of concept. Playsound_local may remain in the code to support playback of admin-uploaded sounds that do not have an inherent datum. Playsound will likely be renamed to something else in the next PR to reflect its new, more universal function. We will see. New process for adding sounds: Create a new datum/outputs subtype. If you wish, write down some supporting text; this gives further meaning to the sound. Add multiple sounds to the sounds list, and weight them as you wish. (New) Add a sound icon if you wish, it defaults to a generic sound circle anyways though. playsound(/datum/outputs/new_subtype, receiver), and it will deliver, icon, sound, and text to the receiver. Maintaining implication is that from now on playsound should only be processing datum/outputs This pr intends to gut to_chats that are added alongside playsounds in the code. This pr eliminates the need to initialize sounds in a list so that you can weight them or have the game play them randomly from the list. Sound Rings Currently, only mobs with the audiolocation var may view them. Sound icons have an alpha that depends on the volume of the sound, louder sounds create a more opaque image Sound rings are completely modular and may be changed to any image. In addition to renaming playsound to reflect its new function. I intend to give blind people sound icons at the cost of their small view, but that is for another PR. cl Basilman refactor: refactored how sounds are stored and played add: Added sound rings and supporting text /cl --- code/__DEFINES/subsystems.dm | 1 + code/controllers/subsystem/outputs.dm | 10 ++ code/datums/components/squeak.dm | 13 +-- code/datums/outputs.dm | 109 ++++++++++++++++++ .../temporary_visuals/miscellaneous.dm | 2 +- code/game/objects/items/clown_items.dm | 4 +- code/game/objects/items/plushes.dm | 14 +-- code/game/objects/items/toys.dm | 7 +- code/game/sound.dm | 18 ++- code/modules/clothing/shoes/bananashoes.dm | 2 +- code/modules/clothing/shoes/miscellaneous.dm | 2 +- code/modules/clothing/under/jobs/civilian.dm | 2 +- code/modules/mob/living/living.dm | 16 +++ code/modules/mob/living/living_defines.dm | 2 +- code/modules/mob/living/say.dm | 2 +- .../living/simple_animal/friendly/mouse.dm | 2 +- code/modules/mob/mob.dm | 12 ++ icons/sound_icon.dmi | Bin 0 -> 3473 bytes tgstation.dme | 2 + 19 files changed, 191 insertions(+), 29 deletions(-) create mode 100644 code/controllers/subsystem/outputs.dm create mode 100644 code/datums/outputs.dm create mode 100644 icons/sound_icon.dmi diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm index 0fddd81833f..13adfc87416 100644 --- a/code/__DEFINES/subsystems.dm +++ b/code/__DEFINES/subsystems.dm @@ -78,6 +78,7 @@ #define INIT_ORDER_STICKY_BAN -10 #define INIT_ORDER_LIGHTING -20 #define INIT_ORDER_SHUTTLE -21 +#define INIT_ORDER_OUTPUTS -22 #define INIT_ORDER_MINOR_MAPPING -40 #define INIT_ORDER_PATH -50 #define INIT_ORDER_PERSISTENCE -100 diff --git a/code/controllers/subsystem/outputs.dm b/code/controllers/subsystem/outputs.dm new file mode 100644 index 00000000000..3c8eca78504 --- /dev/null +++ b/code/controllers/subsystem/outputs.dm @@ -0,0 +1,10 @@ +SUBSYSTEM_DEF(outputs) + name = "Outputs" + init_order = INIT_ORDER_OUTPUTS + flags = SS_NO_FIRE + var/list/outputs = list() + +/datum/controller/subsystem/outputs/Initialize(timeofday) + for(var/A in subtypesof(/datum/outputs)) + outputs[A] = new A + return ..() diff --git a/code/datums/components/squeak.dm b/code/datums/components/squeak.dm index 29b074c3a7d..af7cff6a0fb 100644 --- a/code/datums/components/squeak.dm +++ b/code/datums/components/squeak.dm @@ -1,6 +1,5 @@ /datum/component/squeak - var/static/list/default_squeak_sounds = list('sound/items/toysqueak1.ogg'=1, 'sound/items/toysqueak2.ogg'=1, 'sound/items/toysqueak3.ogg'=1) - var/list/override_squeak_sounds + var/datum/outputs/squeak_datum var/squeak_chance = 100 var/volume = 30 @@ -12,7 +11,7 @@ var/last_use = 0 var/use_delay = 20 -/datum/component/squeak/Initialize(custom_sounds, volume_override, chance_override, step_delay_override, use_delay_override) +/datum/component/squeak/Initialize(custom_datum, volume_override, chance_override, step_delay_override, use_delay_override) if(!isatom(parent)) return COMPONENT_INCOMPATIBLE RegisterSignal(parent, list(COMSIG_ATOM_ENTERED, COMSIG_ATOM_BLOB_ACT, COMSIG_ATOM_HULK_ATTACK, COMSIG_PARENT_ATTACKBY), .proc/play_squeak) @@ -28,7 +27,7 @@ if(istype(parent, /obj/item/clothing/shoes)) RegisterSignal(parent, COMSIG_SHOES_STEP_ACTION, .proc/step_squeak) - override_squeak_sounds = custom_sounds + squeak_datum = SSoutputs.outputs[custom_datum] if(chance_override) squeak_chance = chance_override if(volume_override) @@ -40,10 +39,10 @@ /datum/component/squeak/proc/play_squeak() if(prob(squeak_chance)) - if(!override_squeak_sounds) - playsound(parent, pickweight(default_squeak_sounds), volume, 1, -1) + if(!squeak_datum) + CRASH("Squeak datum attempted to play missing datum") else - playsound(parent, pickweight(override_squeak_sounds), volume, 1, -1) + playsound(parent, squeak_datum, volume, 1, -1) /datum/component/squeak/proc/step_squeak() if(steps > step_delay) diff --git a/code/datums/outputs.dm b/code/datums/outputs.dm new file mode 100644 index 00000000000..d1bee4d5b58 --- /dev/null +++ b/code/datums/outputs.dm @@ -0,0 +1,109 @@ +//NOTE: When adding new sounds here, check to make sure they haven't been added already via CTRL + F. + +/datum/outputs + var/text = "" + var/list/sounds = 'sound/items/airhorn.ogg' //can be either a sound path or a WEIGHTED list, put multiple for random selection between sounds + var/mutable_appearance/vfx = list('icons/sound_icon.dmi',"circle", HUD_LAYER) //syntax: icon, icon_state, layer + +/datum/outputs/New() + vfx = mutable_appearance(vfx[1],vfx[2],vfx[3]) + +/datum/outputs/proc/send_info(mob/receiver, turf/turf_source, vol as num, vary, frequency, falloff, channel = 0, pressure_affected = TRUE) + var/sound/sound_output + //Pick sound + if(islist(sounds)) + if(sounds.len) + var/soundin = pickweight(sounds) + sound_output = sound(get_sfx(soundin)) + else + sound_output = sound(get_sfx(sounds)) + //Process sound + if(sound_output) + sound_output.wait = 0 //No queue + sound_output.channel = channel || open_sound_channel() + sound_output.volume = vol + + if(vary) + if(frequency) + sound_output.frequency = frequency + else + sound_output.frequency = get_rand_frequency() + + if(isturf(turf_source)) + var/turf/T = get_turf(receiver) + + //sound volume falloff with distance + var/distance = get_dist(T, turf_source) + + sound_output.volume -= max(distance - world.view, 0) * 2 //multiplicative falloff to add on top of natural audio falloff. + + if(pressure_affected) + //Atmosphere affects sound + var/pressure_factor = 1 + var/datum/gas_mixture/hearer_env = T.return_air() + var/datum/gas_mixture/source_env = turf_source.return_air() + + if(hearer_env && source_env) + var/pressure = min(hearer_env.return_pressure(), source_env.return_pressure()) + if(pressure < ONE_ATMOSPHERE) + pressure_factor = max((pressure - SOUND_MINIMUM_PRESSURE)/(ONE_ATMOSPHERE - SOUND_MINIMUM_PRESSURE), 0) + else //space + pressure_factor = 0 + + if(distance <= 1) + pressure_factor = max(pressure_factor, 0.15) //touching the source of the sound + + sound_output.volume *= pressure_factor + //End Atmosphere affecting sound + + if(sound_output.volume <= 0) + return //No sound + + var/dx = turf_source.x - T.x // Hearing from the right/left + sound_output.x = dx + var/dz = turf_source.y - T.y // Hearing from infront/behind + sound_output.z = dz + // The y value is for above your head, but there is no ceiling in 2d spessmens. + sound_output.y = 1 + sound_output.falloff = (falloff ? falloff : FALLOFF_SOUNDS) + + receiver.display_output(sound_output, vfx, text, turf_source, vol, vary, frequency, falloff, channel, pressure_affected) + +/datum/outputs/bikehorn + text = "You hear a HONK." + sounds = 'sound/items/bikehorn.ogg' + +/datum/outputs/airhorn + text = "You hear the violent blaring of an airhorn." + sounds = 'sound/items/airhorn2.ogg' + +/datum/outputs/alarm + text = "You hear a blaring alarm." + sounds = 'sound/machines/alarm.ogg' + +/datum/outputs/squeak + text = "You hear a squeak." + sounds = 'sound/effects/mousesqueek.ogg' + +/datum/outputs/clownstep + sounds = list('sound/effects/clownstep1.ogg' = 1,'sound/effects/clownstep2.ogg' = 1) + +/datum/outputs/bite + text = "You hear ravenous biting." + sounds = 'sound/weapons/bite.ogg' + +/datum/outputs/demonattack + text = "You hear a terrifying, unholy noise." + sounds = 'sound/magic/demon_attack1.ogg' + +/datum/outputs/slash + text = "You hear a slashing noise." + sounds = 'sound/weapons/slash.ogg' + +/datum/outputs/punch + text = "You hear a punch." + sounds = 'sound/effects/hit_punch.ogg' + +/datum/outputs/squelch + text = "You hear a horrendous squelching sound." + sounds = 'sound/effects/blobattack.ogg' diff --git a/code/game/objects/effects/temporary_visuals/miscellaneous.dm b/code/game/objects/effects/temporary_visuals/miscellaneous.dm index fb044b1ce79..36328ebaa9e 100644 --- a/code/game/objects/effects/temporary_visuals/miscellaneous.dm +++ b/code/game/objects/effects/temporary_visuals/miscellaneous.dm @@ -439,4 +439,4 @@ skew *= 2 animate(src, alpha = 0, transform = skew, time = duration) else - return INITIALIZE_HINT_QDEL + return INITIALIZE_HINT_QDEL \ No newline at end of file diff --git a/code/game/objects/items/clown_items.dm b/code/game/objects/items/clown_items.dm index 01dbffb44b0..a4a429fc96e 100644 --- a/code/game/objects/items/clown_items.dm +++ b/code/game/objects/items/clown_items.dm @@ -153,7 +153,7 @@ /obj/item/bikehorn/Initialize() . = ..() - AddComponent(/datum/component/squeak, list('sound/items/bikehorn.ogg'=1), 50) + AddComponent(/datum/component/squeak, /datum/outputs/bikehorn, 50) /obj/item/bikehorn/attack(mob/living/carbon/M, mob/living/carbon/user) SEND_SIGNAL(M, COMSIG_ADD_MOOD_EVENT, "honk", /datum/mood_event/honk) @@ -172,7 +172,7 @@ /obj/item/bikehorn/airhorn/Initialize() . = ..() - AddComponent(/datum/component/squeak, list('sound/items/airhorn2.ogg'=1), 50) + AddComponent(/datum/component/squeak, /datum/outputs/airhorn, 50) //golden bikehorn /obj/item/bikehorn/golden diff --git a/code/game/objects/items/plushes.dm b/code/game/objects/items/plushes.dm index b5d86402c21..e2063edac8d 100644 --- a/code/game/objects/items/plushes.dm +++ b/code/game/objects/items/plushes.dm @@ -369,14 +369,14 @@ icon_state = "carpplush" item_state = "carp_plushie" attack_verb = list("bitten", "eaten", "fin slapped") - squeak_override = list('sound/weapons/bite.ogg'=1) + squeak_override = /datum/outputs/bite /obj/item/toy/plush/bubbleplush name = "\improper Bubblegum plushie" desc = "The friendly red demon that gives good miners gifts." icon_state = "bubbleplush" attack_verb = list("rent") - squeak_override = list('sound/magic/demon_attack1.ogg'=1) + squeak_override = /datum/outputs/demonattack /obj/item/toy/plush/plushvar name = "\improper Ratvar plushie" @@ -459,7 +459,7 @@ say("NO! I will not be banished again...") P.say(pick("Ha.", "Ra'sha fonn dest.", "You fool. To come here.")) playsound(src, 'sound/magic/clockwork/anima_fragment_death.ogg', 62, TRUE, frequency = 2) - playsound(P, 'sound/magic/demon_attack1.ogg', 50, TRUE, frequency = 2) + playsound(P, /datum/outputs/demonattack, 50, TRUE, frequency = 2) explosion(src, 0, 0, 1) qdel(src) P.clashing = FALSE @@ -488,7 +488,7 @@ icon_state = "plushie_lizard" item_state = "plushie_lizard" attack_verb = list("clawed", "hissed", "tail slapped") - squeak_override = list('sound/weapons/slash.ogg' = 1) + squeak_override = /datum/outputs/slash /obj/item/toy/plush/snakeplushie name = "snake plushie" @@ -496,7 +496,7 @@ icon_state = "plushie_snake" item_state = "plushie_snake" attack_verb = list("bitten", "hissed", "tail slapped") - squeak_override = list('sound/weapons/bite.ogg' = 1) + squeak_override = /datum/outputs/bite /obj/item/toy/plush/nukeplushie name = "operative plushie" @@ -504,7 +504,7 @@ icon_state = "plushie_nuke" item_state = "plushie_nuke" attack_verb = list("shot", "nuked", "detonated") - squeak_override = list('sound/effects/hit_punch.ogg' = 1) + squeak_override = /datum/outputs/punch /obj/item/toy/plush/slimeplushie name = "slime plushie" @@ -512,7 +512,7 @@ icon_state = "plushie_slime" item_state = "plushie_slime" attack_verb = list("blorbled", "slimed", "absorbed") - squeak_override = list('sound/effects/blobattack.ogg' = 1) + squeak_override = /datum/outputs/squelch gender = FEMALE //given all the jokes and drawings, I'm not sure the xenobiologists would make a slimeboy /obj/item/toy/plush/awakenedplushie diff --git a/code/game/objects/items/toys.dm b/code/game/objects/items/toys.dm index c4302568cc1..00afae70367 100644 --- a/code/game/objects/items/toys.dm +++ b/code/game/objects/items/toys.dm @@ -986,15 +986,20 @@ icon = 'icons/obj/toy.dmi' icon_state = "nuketoyidle" w_class = WEIGHT_CLASS_SMALL + var/datum/outputs/output var/cooldown = 0 +/obj/item/toy/nuke/Initialize() + . = ..() + output = SSoutputs.outputs[/datum/outputs/alarm] + /obj/item/toy/nuke/attack_self(mob/user) if (cooldown < world.time) cooldown = world.time + 1800 //3 minutes user.visible_message("[user] presses a button on [src].", "You activate [src], it plays a loud noise!", "You hear the click of a button.") sleep(5) icon_state = "nuketoy" - playsound(src, 'sound/machines/alarm.ogg', 100, 0) + playsound(src, output, 100, 0) sleep(135) icon_state = "nuketoycool" sleep(cooldown - world.time) diff --git a/code/game/sound.dm b/code/game/sound.dm index 9a471f70c1b..1a09cea0c15 100644 --- a/code/game/sound.dm +++ b/code/game/sound.dm @@ -1,4 +1,4 @@ -/proc/playsound(atom/source, soundin, vol as num, vary, extrarange as num, falloff, frequency = null, channel = 0, pressure_affected = TRUE, ignore_walls = TRUE) +/proc/playsound(atom/source, input, vol as num, vary, extrarange as num, falloff, frequency = null, channel = 0, pressure_affected = TRUE, ignore_walls = TRUE) if(isarea(source)) throw EXCEPTION("playsound(): source is an area") return @@ -7,12 +7,11 @@ if (!turf_source) return - + //allocate a channel if necessary now so its the same for everyone channel = channel || open_sound_channel() // Looping through the player list has the added bonus of working for mobs inside containers - var/sound/S = sound(get_sfx(soundin)) var/maxdistance = (world.view + extrarange) var/z = turf_source.z var/list/listeners = SSmobs.clients_by_zlevel[z] @@ -21,12 +20,21 @@ for(var/P in listeners) var/mob/M = P if(get_dist(M, turf_source) <= maxdistance) - M.playsound_local(turf_source, soundin, vol, vary, frequency, falloff, channel, pressure_affected, S) + sound_or_datum(M, turf_source, input, vol, vary, frequency, falloff, channel, pressure_affected) for(var/P in SSmobs.dead_players_by_zlevel[z]) var/mob/M = P if(get_dist(M, turf_source) <= maxdistance) - M.playsound_local(turf_source, soundin, vol, vary, frequency, falloff, channel, pressure_affected, S) + sound_or_datum(M, turf_source, input, vol, vary, frequency, falloff, channel, pressure_affected) +/proc/sound_or_datum(mob/receiver, turf/turf_source, input, vol as num, vary, frequency, falloff, channel = 0, pressure_affected = TRUE) + if(istype(input,/datum)) + var/datum/outputs/O = input + O.send_info(receiver, turf_source, vol, vary, frequency, falloff, channel, pressure_affected) + else + var/sound/S = sound(get_sfx(input)) + receiver.playsound_local(turf_source, input, vol, vary, frequency, falloff, channel, pressure_affected, S) + +//kept for legacy support and uploaded admin sounds /mob/proc/playsound_local(turf/turf_source, soundin, vol as num, vary, frequency, falloff, channel = 0, pressure_affected = TRUE, sound/S) if(!client || !can_hear()) return diff --git a/code/modules/clothing/shoes/bananashoes.dm b/code/modules/clothing/shoes/bananashoes.dm index b6348948050..18b203a5e93 100644 --- a/code/modules/clothing/shoes/bananashoes.dm +++ b/code/modules/clothing/shoes/bananashoes.dm @@ -11,7 +11,7 @@ /obj/item/clothing/shoes/clown_shoes/banana_shoes/Initialize() . = ..() AddComponent(/datum/component/material_container, list(MAT_BANANIUM), 200000, TRUE, /obj/item/stack) - AddComponent(/datum/component/squeak, list('sound/items/bikehorn.ogg'=1), 75) + AddComponent(/datum/component/squeak, /datum/outputs/bikehorn, 75) if(always_noslip) clothing_flags |= NOSLIP diff --git a/code/modules/clothing/shoes/miscellaneous.dm b/code/modules/clothing/shoes/miscellaneous.dm index 7dc7a8d11c0..3eb5aefc48f 100644 --- a/code/modules/clothing/shoes/miscellaneous.dm +++ b/code/modules/clothing/shoes/miscellaneous.dm @@ -81,7 +81,7 @@ /obj/item/clothing/shoes/clown_shoes/Initialize() . = ..() - AddComponent(/datum/component/squeak, list('sound/effects/clownstep1.ogg'=1,'sound/effects/clownstep2.ogg'=1), 50) + AddComponent(/datum/component/squeak, /datum/outputs/clownstep, 50) /obj/item/clothing/shoes/clown_shoes/equipped(mob/user, slot) . = ..() diff --git a/code/modules/clothing/under/jobs/civilian.dm b/code/modules/clothing/under/jobs/civilian.dm index fe368376c5f..59e991d19ac 100644 --- a/code/modules/clothing/under/jobs/civilian.dm +++ b/code/modules/clothing/under/jobs/civilian.dm @@ -123,7 +123,7 @@ /obj/item/clothing/under/rank/clown/Initialize() . = ..() - AddComponent(/datum/component/squeak, list('sound/items/bikehorn.ogg'=1), 50) + AddComponent(/datum/component/squeak, /datum/outputs/bikehorn, 50) /obj/item/clothing/under/rank/head_of_personnel desc = "It's a jumpsuit worn by someone who works in the position of \"Head of Personnel\"." diff --git a/code/modules/mob/living/living.dm b/code/modules/mob/living/living.dm index c56ea07ca31..b6f369cb3b8 100644 --- a/code/modules/mob/living/living.dm +++ b/code/modules/mob/living/living.dm @@ -1210,6 +1210,22 @@ mob_pickup(user) return TRUE +/mob/living/display_output(sound/S, mutable_appearance/vfx, text, turf/turf_source, vol as num) + . = ..() + //Process icon + if(vfx && audiolocation) + var/image/sound_icon = image(vfx) + sound_icon.loc = turf_source + if(vol && S) + sound_icon.alpha = sound_icon.alpha * (vol / 100) + client.images += sound_icon + addtimer(CALLBACK(src, .proc/remove_image, sound_icon), 7) + +/mob/living/proc/remove_image(sound_image) + if(sound_image && client) + client.images -= sound_image + qdel(sound_image) + /mob/living/proc/get_static_viruses() //used when creating blood and other infective objects if(!LAZYLEN(diseases)) return diff --git a/code/modules/mob/living/living_defines.dm b/code/modules/mob/living/living_defines.dm index 02a55dbfcc9..7f5ab66f4c7 100644 --- a/code/modules/mob/living/living_defines.dm +++ b/code/modules/mob/living/living_defines.dm @@ -29,8 +29,8 @@ var/lying = 0 //number of degrees. DO NOT USE THIS IN CHECKS. CHECK FOR MOBILITY FLAGS INSTEAD!! var/lying_prev = 0 //last value of lying on update_mobility + var/audiolocation = FALSE var/confused = 0 //Makes the mob move in random directions. - var/hallucination = 0 //Directly affects how long a mob will hallucinate for var/last_special = 0 //Used by the resist verb, likely used to prevent players from bypassing next_move by logging in/out. diff --git a/code/modules/mob/living/say.dm b/code/modules/mob/living/say.dm index 90c5526132d..4544b52941b 100644 --- a/code/modules/mob/living/say.dm +++ b/code/modules/mob/living/say.dm @@ -398,4 +398,4 @@ GLOBAL_LIST_INIT(department_radio_keys, list( if(.) return . - . = ..() + . = ..() \ No newline at end of file diff --git a/code/modules/mob/living/simple_animal/friendly/mouse.dm b/code/modules/mob/living/simple_animal/friendly/mouse.dm index 2ccaa9fc641..8638472fb4b 100644 --- a/code/modules/mob/living/simple_animal/friendly/mouse.dm +++ b/code/modules/mob/living/simple_animal/friendly/mouse.dm @@ -28,7 +28,7 @@ /mob/living/simple_animal/mouse/Initialize() . = ..() - AddComponent(/datum/component/squeak, list('sound/effects/mousesqueek.ogg'=1), 100) + AddComponent(/datum/component/squeak, /datum/outputs/squeak, 100) if(!body_color) body_color = pick( list("brown","gray","white") ) icon_state = "mouse_[body_color]" diff --git a/code/modules/mob/mob.dm b/code/modules/mob/mob.dm index 2d7f99d6418..6a9fada2979 100644 --- a/code/modules/mob/mob.dm +++ b/code/modules/mob/mob.dm @@ -105,6 +105,18 @@ else to_chat(src, msg) +/mob/proc/display_output(sound/S, mutable_appearance/vfx, text, turf/turf_source, vol as num, vary, frequency, falloff, channel = 0, pressure_affected = TRUE) + if(!can_hear()) + return + //Process sound + if(S) + SEND_SOUND(src, S) + //Process text + if(text) + to_chat(src, "[text]") + //to whoever sees this: icons are handled in living.dm + + // Show a message to all player mobs who sees this atom // Show a message to the src mob (if the src is a mob) // Use for atoms performing visible actions diff --git a/icons/sound_icon.dmi b/icons/sound_icon.dmi new file mode 100644 index 0000000000000000000000000000000000000000..d614932dc31f358e21db4a46d83ec91aaadd48b7 GIT binary patch literal 3473 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