Refactor jukebox, jukebox datum, now jukebox audio is positional. And it fully respects deaf people. (#81135)

## About The Pull Request

- Jukebox is refactored into a datum that the rave visor and the jukebox
uses.

- Jukebox UI is now typescript. 

- How the Jukebox delivers sound to players has been rewritten. 
- Now it adjusts the sound's position in accordance to where the
listener is.
- This implementation was loosely inspired by that done by Baystation
half a decade ago, so kudos to them.
   - Additionally, being deafened will temporarily mute the jukebox. 

- And sorry, in refactoring this I snuck in one tiny feature. 
- You can now toggle looping on Jukeboxes to play the song
foreeeverrrrr.

## Why It's Good For The Game

It sounds wayyyyyy better. Overhead isn't even that bad, though it could
be tested on a live server to make sure.


https://github.com/tgstation/tgstation/assets/51863163/ec1321b6-bf1c-4c33-9663-83f2c23a4277

## Changelog

🆑 Melbert
refactor: Jukebox has been refactored. Jukebox music now updates as the
player moves, mutes when the player is deafened, and overall sounds
wayyy better. You can also now toggle song repeat on jukeboxes.
/🆑
This commit is contained in:
MrMelbert
2024-01-30 17:54:56 -07:00
committed by GitHub
parent b2efe359cf
commit 7ffc073b33
6 changed files with 612 additions and 260 deletions
+133 -209
View File
@@ -1,121 +1,60 @@
/// Helper macro to check if the passed mob has jukebox sound preference enabled
#define HAS_JUKEBOX_PREF(mob) (!QDELETED(mob) && !isnull(mob.client) && mob.client.prefs.read_preference(/datum/preference/toggle/sound_jukebox))
/obj/machinery/jukebox
name = "jukebox"
desc = "A classic music player."
icon = 'icons/obj/machines/music.dmi'
icon_state = "jukebox"
base_icon_state = "jukebox"
verb_say = "states"
density = TRUE
req_access = list(ACCESS_BAR)
/// Whether we're actively playing music
var/active = FALSE
/// List of weakrefs to mobs listening to the current song
var/list/datum/weakref/rangers = list()
/// World.time when the current song will stop playing, but also a cooldown between activations
var/stop = 0
/// List of /datum/tracks we can play
/// Inited from config every time a jukebox is instantiated
var/list/songs = list()
/// Current song selected
var/datum/track/selection = null
/// Volume of the songs played
var/volume = 50
processing_flags = START_PROCESSING_MANUALLY
/// Cooldown between "Error" sound effects being played
COOLDOWN_DECLARE(jukebox_error_cd)
/obj/machinery/jukebox/disco
name = "radiant dance machine mark IV"
desc = "The first three prototypes were discontinued after mass casualty incidents."
icon_state = "disco"
req_access = list(ACCESS_ENGINEERING)
anchored = FALSE
var/list/spotlights = list()
var/list/sparkles = list()
/obj/machinery/jukebox/disco/indestructible
name = "radiant dance machine mark V"
desc = "Now redesigned with data gathered from the extensive disco and plasma research."
req_access = null
anchored = TRUE
resistance_flags = INDESTRUCTIBLE | LAVA_PROOF | FIRE_PROOF | UNACIDABLE | ACID_PROOF
obj_flags = parent_type::obj_flags | NO_DECONSTRUCTION
/datum/track
var/song_name = "generic"
var/song_path = null
var/song_length = 0
var/song_beat = 0
/datum/track/default
song_path = 'sound/ambience/title3.ogg'
song_name = "Tintin on the Moon"
song_length = 3 MINUTES + 52 SECONDS
song_beat = 1 SECONDS
/// Cooldown between being allowed to play another song
COOLDOWN_DECLARE(jukebox_song_cd)
/// TimerID to when the current song ends
var/song_timerid
/// The actual music player datum that handles the music
var/datum/jukebox/music_player
/obj/machinery/jukebox/Initialize(mapload)
. = ..()
songs = load_songs_from_config()
if(length(songs))
selection = pick(songs)
/// Loads the config sounds once, and returns a copy of them.
/obj/machinery/jukebox/proc/load_songs_from_config()
var/static/list/config_songs
if(isnull(config_songs))
config_songs = list()
var/list/tracks = flist("[global.config.directory]/jukebox_music/sounds/")
for(var/track_file in tracks)
var/datum/track/new_track = new()
new_track.song_path = file("[global.config.directory]/jukebox_music/sounds/[track_file]")
var/list/track_data = splittext(track_file, "+")
if(length(track_data) != 3)
continue
new_track.song_name = track_data[1]
new_track.song_length = text2num(track_data[2])
new_track.song_beat = text2num(track_data[3])
config_songs += new_track
if(!length(config_songs))
// Includes title3 as a default for testing / "no config" support, also because it's a banger
config_songs += new /datum/track/default()
// returns a copy so it can mutate if desired.
return config_songs.Copy()
music_player = new(src)
/obj/machinery/jukebox/Destroy()
dance_over()
selection = null
songs.Cut()
stop_music()
QDEL_NULL(music_player)
return ..()
/obj/machinery/jukebox/attackby(obj/item/O, mob/user, params)
if(!active && !(obj_flags & NO_DECONSTRUCTION))
if(O.tool_behaviour == TOOL_WRENCH)
if(!anchored && !isinspace())
to_chat(user,span_notice("You secure [src] to the floor."))
set_anchored(TRUE)
else if(anchored)
to_chat(user,span_notice("You unsecure and disconnect [src]."))
set_anchored(FALSE)
playsound(src, 'sound/items/deconstruct.ogg', 50, TRUE)
return
return ..()
/obj/machinery/jukebox/no_access
req_access = null
/obj/machinery/jukebox/wrench_act(mob/living/user, obj/item/tool)
if(!isnull(music_player.active_song_sound))
return NONE
if(obj_flags & NO_DECONSTRUCTION)
return NONE
if(default_unfasten_wrench(user, tool) == SUCCESSFUL_UNFASTEN)
return ITEM_INTERACT_SUCCESS
return ITEM_INTERACT_BLOCKING
/obj/machinery/jukebox/update_icon_state()
icon_state = "[initial(icon_state)][active ? "-active" : null]"
icon_state = "[base_icon_state][music_player.active_song_sound ? "-active" : null]"
return ..()
/obj/machinery/jukebox/ui_status(mob/user)
if(isobserver(user))
return ..()
if(!anchored)
to_chat(user,span_warning("This device must be anchored by a wrench!"))
return UI_CLOSE
if(!allowed(user) && !isobserver(user))
if(!allowed(user))
to_chat(user,span_warning("Error: Access Denied."))
user.playsound_local(src, 'sound/misc/compiler-failure.ogg', 25, TRUE)
return UI_CLOSE
if(!songs.len && !isobserver(user))
if(!length(music_player.songs))
to_chat(user,span_warning("Error: No music tracks have been authorized for your station. Petition Central Command to resolve this issue."))
user.playsound_local(src, 'sound/misc/compiler-failure.ogg', 25, TRUE)
return UI_CLOSE
@@ -128,23 +67,7 @@
ui.open()
/obj/machinery/jukebox/ui_data(mob/user)
var/list/data = list()
data["active"] = active
data["songs"] = list()
for(var/datum/track/S in songs)
var/list/track_data = list(
name = S.song_name
)
data["songs"] += list(track_data)
data["track_selected"] = null
data["track_length"] = null
data["track_beat"] = null
if(selection)
data["track_selected"] = selection.song_name
data["track_length"] = DisplayTimeText(selection.song_length)
data["track_beat"] = selection.song_beat
data["volume"] = volume
return data
return music_player.get_ui_data()
/obj/machinery/jukebox/ui_act(action, list/params)
. = ..()
@@ -153,60 +76,120 @@
switch(action)
if("toggle")
if(QDELETED(src))
return
if(!active)
if(stop > world.time)
to_chat(usr, span_warning("Error: The device is still resetting from the last activation, it will be ready again in [DisplayTimeText(stop-world.time)]."))
if(!COOLDOWN_FINISHED(src, jukebox_error_cd))
return
playsound(src, 'sound/misc/compiler-failure.ogg', 50, TRUE)
COOLDOWN_START(src, jukebox_error_cd, 15 SECONDS)
return
if(isnull(music_player.active_song_sound))
if(!COOLDOWN_FINISHED(src, jukebox_song_cd))
to_chat(usr, span_warning("Error: The device is still resetting from the last activation, \
it will be ready again in [DisplayTimeText(COOLDOWN_TIMELEFT(src, jukebox_song_cd))]."))
if(COOLDOWN_FINISHED(src, jukebox_error_cd))
playsound(src, 'sound/misc/compiler-failure.ogg', 33, TRUE)
COOLDOWN_START(src, jukebox_error_cd, 15 SECONDS)
return TRUE
activate_music()
START_PROCESSING(SSobj, src)
return TRUE
else
stop = 0
return TRUE
if("select_track")
if(active)
to_chat(usr, span_warning("Error: You cannot change the song until the current one is over."))
return
var/list/available = list()
for(var/datum/track/S in songs)
available[S.song_name] = S
var/selected = params["track"]
if(QDELETED(src) || !selected || !istype(available[selected], /datum/track))
return
selection = available[selected]
stop_music()
return TRUE
if("select_track")
if(!isnull(music_player.active_song_sound))
to_chat(usr, span_warning("Error: You cannot change the song until the current one is over."))
return TRUE
var/datum/track/new_song = music_player.songs[params["track"]]
if(QDELETED(src) || !istype(new_song, /datum/track))
return TRUE
music_player.selection = new_song
return TRUE
if("set_volume")
var/new_volume = params["volume"]
if(new_volume == "reset")
volume = initial(volume)
return TRUE
if(new_volume == "reset" || new_volume == "max")
music_player.set_volume_to_max()
else if(new_volume == "min")
volume = 0
return TRUE
else if(new_volume == "max")
volume = initial(volume)
return TRUE
else if(text2num(new_volume) != null)
volume = text2num(new_volume)
return TRUE
music_player.set_new_volume(0)
else if(isnum(text2num(new_volume)))
music_player.set_new_volume(text2num(new_volume))
return TRUE
if("loop")
music_player.sound_loops = !!params["looping"]
return TRUE
/obj/machinery/jukebox/proc/activate_music()
active = TRUE
if(!isnull(music_player.active_song_sound))
return FALSE
music_player.start_music()
update_use_power(ACTIVE_POWER_USE)
update_appearance(UPDATE_ICON_STATE)
START_PROCESSING(SSobj, src)
stop = world.time + selection.song_length
if(!music_player.sound_loops)
song_timerid = addtimer(CALLBACK(src, PROC_REF(stop_music)), music_player.selection.song_length, TIMER_UNIQUE|TIMER_STOPPABLE|TIMER_DELETE_ME)
return TRUE
/obj/machinery/jukebox/proc/stop_music()
if(!isnull(song_timerid))
deltimer(song_timerid)
music_player.unlisten_all()
if(!QDELING(src))
COOLDOWN_START(src, jukebox_song_cd, 10 SECONDS)
playsound(src,'sound/machines/terminal_off.ogg',50,TRUE)
update_use_power(IDLE_POWER_USE)
update_appearance(UPDATE_ICON_STATE)
return TRUE
/obj/machinery/jukebox/on_set_is_operational(old_value)
if(!is_operational)
stop_music()
/obj/machinery/jukebox/disco
name = "radiant dance machine mark IV"
desc = "The first three prototypes were discontinued after mass casualty incidents."
icon_state = "disco"
base_icon_state = "disco"
req_access = list(ACCESS_ENGINEERING)
anchored = FALSE
/// Spotlight effects being played
VAR_PRIVATE/list/obj/item/flashlight/spotlight/spotlights = list()
/// Sparkle effects being played
VAR_PRIVATE/list/obj/effect/overlay/sparkles/sparkles = list()
/obj/machinery/jukebox/disco/indestructible
name = "radiant dance machine mark V"
desc = "Now redesigned with data gathered from the extensive disco and plasma research."
req_access = null
anchored = TRUE
resistance_flags = INDESTRUCTIBLE | LAVA_PROOF | FIRE_PROOF | UNACIDABLE | ACID_PROOF
obj_flags = parent_type::obj_flags | NO_DECONSTRUCTION
/obj/machinery/jukebox/disco/activate_music()
..()
. = ..()
if(!.)
return
dance_setup()
lights_spin()
begin_processing()
/obj/machinery/jukebox/disco/stop_music()
. = ..()
if(!.)
return
QDEL_LIST(spotlights)
QDEL_LIST(sparkles)
end_processing()
/obj/machinery/jukebox/disco/process()
var/dance_num = rand(1, 4) //all will do the same dance
for(var/mob/living/dancer in music_player.get_active_listeners())
if(!(dancer.mobility_flags & MOBILITY_MOVE))
continue
if(HAS_TRAIT(dancer, TRAIT_DISCO_DANCER))
continue
dance(dancer, dance_num)
/obj/machinery/jukebox/disco/proc/dance_setup()
var/turf/cen = get_turf(src)
@@ -247,7 +230,7 @@
/obj/machinery/jukebox/disco/proc/lights_spin()
for(var/i in 1 to 25)
if(QDELETED(src) || !active)
if(QDELETED(src) || isnull(music_player.active_song_sound))
return
var/obj/effect/overlay/sparkles/S = new /obj/effect/overlay/sparkles(src)
S.alpha = 0
@@ -266,7 +249,7 @@
for(var/s in sparkles)
var/obj/effect/overlay/sparkles/reveal = s
reveal.alpha = 255
while(active)
while(!isnull(music_player.active_song_sound))
for(var/g in spotlights) // The multiples reflects custom adjustments to each colors after dozens of tests
var/obj/item/flashlight/spotlight/glow = g
if(QDELETED(glow))
@@ -334,7 +317,7 @@
glow.even_cycle = !glow.even_cycle
if(prob(2)) // Unique effects for the dance floor that show up randomly to mix things up
INVOKE_ASYNC(src, PROC_REF(hierofunk))
sleep(selection.song_beat)
sleep(music_player.selection.song_beat)
if(QDELETED(src))
return
@@ -403,62 +386,3 @@
/obj/machinery/jukebox/disco/proc/dance4_revert(mob/living/dancer, matrix/starting_matrix)
animate(dancer, transform = starting_matrix, time = 5, loop = 0)
REMOVE_TRAIT(dancer, TRAIT_DISCO_DANCER, REF(src))
/obj/machinery/jukebox/proc/dance_over()
for(var/datum/weakref/weak_to_hide_from as anything in rangers)
var/mob/to_hide_from = weak_to_hide_from?.resolve()
to_hide_from?.stop_sound_channel(CHANNEL_JUKEBOX)
rangers.Cut()
/obj/machinery/jukebox/disco/dance_over()
..()
QDEL_LIST(spotlights)
QDEL_LIST(sparkles)
/obj/machinery/jukebox/process()
if(world.time < stop && active)
var/sound/song_played = sound(selection.song_path)
// Goes through existing mobs in rangers to determine if they should not be played to
for(var/datum/weakref/weak_to_hide_from as anything in rangers)
var/mob/to_hide_from = weak_to_hide_from?.resolve()
if(!HAS_JUKEBOX_PREF(to_hide_from) || get_dist(src, get_turf(to_hide_from)) > 10)
rangers -= weak_to_hide_from
to_hide_from?.stop_sound_channel(CHANNEL_JUKEBOX)
// Collect mobs to play the song to, stores weakrefs of them in rangers
for(var/mob/to_play_to in range(world.view, src))
if(!HAS_JUKEBOX_PREF(to_play_to))
continue
var/datum/weakref/weak_playing_to = WEAKREF(to_play_to)
if(rangers[weak_playing_to])
continue
rangers[weak_playing_to] = TRUE
// This plays the sound directly underneath the mob because otherwise it'd get stuck in their left ear or whatever
// Would be neat if it sourced from the box itself though
to_play_to.playsound_local(get_turf(to_play_to), null, volume, channel = CHANNEL_JUKEBOX, sound_to_use = song_played, use_reverb = FALSE)
else if(active)
active = FALSE
update_use_power(IDLE_POWER_USE)
STOP_PROCESSING(SSobj, src)
dance_over()
playsound(src,'sound/machines/terminal_off.ogg',50,TRUE)
update_appearance(UPDATE_ICON_STATE)
stop = world.time + 100
/obj/machinery/jukebox/disco/process()
. = ..()
if(!active)
return
var/dance_num = rand(1,4) //all will do the same dance
for(var/datum/weakref/weak_dancer as anything in rangers)
var/mob/living/to_dance = weak_dancer.resolve()
if(!istype(to_dance) || !(to_dance.mobility_flags & MOBILITY_MOVE))
continue
if(!HAS_TRAIT(to_dance, TRAIT_DISCO_DANCER))
dance(to_dance, dance_num)
#undef HAS_JUKEBOX_PREF