mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2026-01-07 23:42:44 +00:00
## 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. /🆑
407 lines
13 KiB
Plaintext
407 lines
13 KiB
Plaintext
/// Checks if the mob has jukebox muted in their preferences
|
|
#define IS_PREF_MUTED(mob) (!isnull(mob.client) && !mob.client.prefs.read_preference(/datum/preference/toggle/sound_jukebox))
|
|
|
|
// Reasons for appling STATUS_MUTE to a mob's sound status
|
|
/// The mob is deaf
|
|
#define MUTE_DEAF (1<<0)
|
|
/// The mob has disabled jukeboxes in their preferences
|
|
#define MUTE_PREF (1<<1)
|
|
/// The mob is out of range of the jukebox
|
|
#define MUTE_RANGE (1<<2)
|
|
|
|
/**
|
|
* ## Jukebox datum
|
|
*
|
|
* Plays music to nearby mobs when hosted in a movable or a turf.
|
|
*/
|
|
/datum/jukebox
|
|
/// Atom that hosts the jukebox. Can be a turf or a movable.
|
|
VAR_FINAL/atom/parent
|
|
/// List of /datum/tracks we can play. Set via get_songs().
|
|
VAR_FINAL/list/songs = list()
|
|
/// Current song track selected
|
|
VAR_FINAL/datum/track/selection
|
|
/// Current song datum playing
|
|
VAR_FINAL/sound/active_song_sound
|
|
/// Whether the jukebox requires a connect_range component to check for new listeners
|
|
VAR_PROTECTED/requires_range_check = TRUE
|
|
|
|
/// Assoc list of all mobs listening to the jukebox to their sound status.
|
|
VAR_PRIVATE/list/mob/listeners = list()
|
|
|
|
/// Volume of the songs played. Also serves as the max volume.
|
|
/// Do not set directly, use set_new_volume() instead.
|
|
VAR_PROTECTED/volume = 50
|
|
|
|
/// Range at which the sound plays to players, can also be a view "XxY" string
|
|
VAR_PROTECTED/sound_range
|
|
/// How far away horizontally from the jukebox can you be before you stop hearing it
|
|
VAR_PRIVATE/x_cutoff
|
|
/// How far away vertically from the jukebox can you be before you stop hearing it
|
|
VAR_PRIVATE/z_cutoff
|
|
/// Whether the music loops when done.
|
|
/// If FALSE, you must handle ending music yourself.
|
|
var/sound_loops = FALSE
|
|
|
|
/datum/jukebox/New(atom/new_parent)
|
|
if(!ismovable(new_parent) && !isturf(new_parent))
|
|
stack_trace("[type] created on non-turf or non-movable: [new_parent ? "[new_parent] ([new_parent.type])" : "null"])")
|
|
qdel(src)
|
|
return
|
|
|
|
parent = new_parent
|
|
|
|
if(isnull(sound_range))
|
|
sound_range = world.view
|
|
var/list/worldviewsize = getviewsize(sound_range)
|
|
x_cutoff = ceil(worldviewsize[1] * 1.25 / 2) // * 1.25 gives us some extra range to fade out with
|
|
z_cutoff = ceil(worldviewsize[2] * 1.25 / 2) // and / 2 is because world view is the whole screen, and we want the centre
|
|
|
|
if(requires_range_check)
|
|
var/static/list/connections = list(COMSIG_ATOM_ENTERED = PROC_REF(check_new_listener))
|
|
AddComponent(/datum/component/connect_range, parent, connections, max(x_cutoff, z_cutoff))
|
|
|
|
songs = init_songs()
|
|
if(length(songs))
|
|
selection = songs[pick(songs)]
|
|
|
|
RegisterSignal(parent, COMSIG_ENTER_AREA, PROC_REF(on_enter_area))
|
|
RegisterSignal(parent, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved))
|
|
RegisterSignal(parent, COMSIG_QDELETING, PROC_REF(parent_delete))
|
|
|
|
/datum/jukebox/Destroy()
|
|
unlisten_all()
|
|
parent = null
|
|
selection = null
|
|
songs.Cut()
|
|
active_song_sound = null
|
|
return ..()
|
|
|
|
/// When our parent is deleted, we should go too.
|
|
/datum/jukebox/proc/parent_delete(datum/source)
|
|
SIGNAL_HANDLER
|
|
qdel(src)
|
|
|
|
/**
|
|
* Initializes the track list.
|
|
*
|
|
* By default, this loads all tracks from the config datum.
|
|
*
|
|
* Returns
|
|
* * An assoc list of track names to /datum/track. Track names must be unique.
|
|
*/
|
|
/datum/jukebox/proc/init_songs()
|
|
return load_songs_from_config()
|
|
|
|
/// Loads the config sounds once, and returns a copy of them.
|
|
/datum/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.song_name] = new_track
|
|
|
|
if(!length(config_songs))
|
|
var/datum/track/default/default_track = new()
|
|
config_songs[default_track.song_name] = default_track
|
|
|
|
// returns a copy so it can mutate if desired.
|
|
return config_songs.Copy()
|
|
|
|
/**
|
|
* Returns a set of general data relating to the jukebox for use in TGUI.
|
|
*
|
|
* Returns
|
|
* * A list of UI data
|
|
*/
|
|
/datum/jukebox/proc/get_ui_data()
|
|
var/list/data = list()
|
|
var/list/songs_data = list()
|
|
for(var/song_name in songs)
|
|
var/datum/track/one_song = songs[song_name]
|
|
UNTYPED_LIST_ADD(songs_data, list( \
|
|
"name" = song_name, \
|
|
"length" = DisplayTimeText(one_song.song_length), \
|
|
"beat" = one_song.song_beat, \
|
|
))
|
|
|
|
data["active"] = !!active_song_sound
|
|
data["songs"] = songs_data
|
|
data["track_selected"] = selection?.song_name
|
|
data["looping"] = sound_loops
|
|
data["volume"] = volume
|
|
return data
|
|
|
|
/**
|
|
* Sets the sound's range to a new value. This can be a number or a view size string "XxY".
|
|
* Then updates any mobs listening to it.
|
|
*/
|
|
/datum/jukebox/proc/set_sound_range(new_range)
|
|
if(sound_range == new_range)
|
|
return
|
|
sound_range = new_range
|
|
var/list/worldviewsize = getviewsize(sound_range)
|
|
x_cutoff = ceil(worldviewsize[1] / 2)
|
|
z_cutoff = ceil(worldviewsize[2] / 2)
|
|
update_all()
|
|
|
|
/**
|
|
* Sets the sound's volume to a new value.
|
|
* Then updates any mobs listening to it.
|
|
*/
|
|
/datum/jukebox/proc/set_new_volume(new_vol)
|
|
new_vol = clamp(new_vol, 0, initial(volume))
|
|
if(volume == new_vol)
|
|
return
|
|
volume = new_vol
|
|
if(!active_song_sound)
|
|
return
|
|
active_song_sound.volume = volume
|
|
update_all()
|
|
|
|
/// Sets volume to the maximum possible value, the initial volume value.
|
|
/datum/jukebox/proc/set_volume_to_max()
|
|
set_new_volume(initial(volume))
|
|
|
|
/**
|
|
* Sets the sound's environment to a new value.
|
|
* Then updates any mobs listening to it.
|
|
*/
|
|
/datum/jukebox/proc/set_new_environment(new_env)
|
|
if(!active_song_sound || active_song_sound.environment == new_env)
|
|
return
|
|
active_song_sound.environment = new_env
|
|
update_all()
|
|
|
|
/// Helper to stop the music for all mobs listening to the music.
|
|
/datum/jukebox/proc/unlisten_all()
|
|
for(var/mob/listening as anything in listeners)
|
|
deregister_listener(listening)
|
|
active_song_sound = null
|
|
|
|
/// Helper to update all mobs currently listening to the music.
|
|
/datum/jukebox/proc/update_all()
|
|
for(var/mob/listening as anything in listeners)
|
|
update_listener(listening)
|
|
|
|
/// Helper to kickstart the music for all mobs in hearing range of the jukebox.
|
|
/datum/jukebox/proc/start_music()
|
|
for(var/mob/nearby in hearers(sound_range, parent))
|
|
register_listener(nearby)
|
|
|
|
/// Helper to get all mobs currently, ACTIVELY listening to the jukebox.
|
|
/datum/jukebox/proc/get_active_listeners()
|
|
var/list/all_listeners = list()
|
|
for(var/mob/listener as anything in listeners)
|
|
if(listeners[listener] & SOUND_MUTE)
|
|
continue
|
|
all_listeners += listener
|
|
return all_listeners
|
|
|
|
/// Registers the passed mob as a new listener to the jukebox.
|
|
/datum/jukebox/proc/register_listener(mob/new_listener)
|
|
PROTECTED_PROC(TRUE)
|
|
|
|
listeners[new_listener] = NONE
|
|
RegisterSignal(new_listener, COMSIG_QDELETING, PROC_REF(listener_deleted))
|
|
|
|
if(isnull(new_listener.client))
|
|
RegisterSignal(new_listener, COMSIG_MOB_LOGIN, PROC_REF(listener_login))
|
|
return
|
|
|
|
RegisterSignal(new_listener, COMSIG_MOVABLE_MOVED, PROC_REF(listener_moved))
|
|
RegisterSignals(new_listener, list(SIGNAL_ADDTRAIT(TRAIT_DEAF), SIGNAL_REMOVETRAIT(TRAIT_DEAF)), PROC_REF(listener_deaf))
|
|
|
|
if(HAS_TRAIT(new_listener, TRAIT_DEAF) || IS_PREF_MUTED(new_listener))
|
|
listeners[new_listener] |= SOUND_MUTE
|
|
|
|
if(isnull(active_song_sound))
|
|
var/area/juke_area = get_area(parent)
|
|
active_song_sound = sound(selection.song_path)
|
|
active_song_sound.channel = CHANNEL_JUKEBOX
|
|
active_song_sound.priority = 255
|
|
active_song_sound.falloff = 2
|
|
active_song_sound.volume = volume
|
|
active_song_sound.y = 1
|
|
active_song_sound.environment = juke_area.sound_environment || SOUND_ENVIRONMENT_NONE
|
|
active_song_sound.repeat = sound_loops
|
|
|
|
update_listener(new_listener)
|
|
// if you have a sound with status SOUND_UPDATE,
|
|
// and try to play it to a client who is not listening to the sound already,
|
|
// it will not work.
|
|
// so we only add this status AFTER the first update, which plays the first sound.
|
|
// and after that it's fine to keep it on the sound so it updates as the x/z does.
|
|
listeners[new_listener] |= SOUND_UPDATE
|
|
|
|
/// Deregisters mobs on deletion.
|
|
/datum/jukebox/proc/listener_deleted(mob/source)
|
|
SIGNAL_HANDLER
|
|
deregister_listener(source)
|
|
|
|
/// Updates the sound's position on mob movement.
|
|
/datum/jukebox/proc/listener_moved(mob/source)
|
|
SIGNAL_HANDLER
|
|
update_listener(source)
|
|
|
|
/// Allows mobs who are clientless when the music starts to hear it when they log in.
|
|
/datum/jukebox/proc/listener_login(mob/source)
|
|
SIGNAL_HANDLER
|
|
deregister_listener(source)
|
|
register_listener(source)
|
|
|
|
/// Updates the sound's mute status when the mob's deafness updates.
|
|
/datum/jukebox/proc/listener_deaf(mob/source)
|
|
SIGNAL_HANDLER
|
|
|
|
if(HAS_TRAIT(source, TRAIT_DEAF))
|
|
listeners[source] |= SOUND_MUTE
|
|
else if(!unmute_listener(source, MUTE_DEAF))
|
|
return
|
|
update_listener(source)
|
|
|
|
/**
|
|
* Unmutes the passed mob's sound from the passed reason.
|
|
*
|
|
* Arguments
|
|
* * mob/listener - The mob to unmute.
|
|
* * reason - The reason to unmute them for. Can be a combination of MUTE_DEAF, MUTE_PREF, MUTE_RANGE.
|
|
*/
|
|
/datum/jukebox/proc/unmute_listener(mob/listener, reason)
|
|
// We need to check everything BUT the reason we're unmuting for
|
|
// Because if we're muted for a different reason we don't wanna touch it
|
|
reason = ~reason
|
|
|
|
if((reason & MUTE_DEAF) && HAS_TRAIT(listener, TRAIT_DEAF))
|
|
return FALSE
|
|
|
|
if((reason & MUTE_PREF) && IS_PREF_MUTED(listener))
|
|
return FALSE
|
|
|
|
if(reason & MUTE_RANGE)
|
|
var/turf/sound_turf = get_turf(parent)
|
|
var/turf/listener_turf = get_turf(listener)
|
|
if(isnull(sound_turf) || isnull(listener_turf))
|
|
return FALSE
|
|
if(sound_turf.z != listener_turf.z)
|
|
return FALSE
|
|
if(abs(sound_turf.x - listener_turf.x) > x_cutoff)
|
|
return FALSE
|
|
if(abs(sound_turf.y - listener_turf.y) > z_cutoff)
|
|
return FALSE
|
|
|
|
listeners[listener] &= ~SOUND_MUTE
|
|
return TRUE
|
|
|
|
/// Deregisters the passed mob as a listener to the jukebox, stopping the music.
|
|
/datum/jukebox/proc/deregister_listener(mob/no_longer_listening)
|
|
PROTECTED_PROC(TRUE)
|
|
|
|
listeners -= no_longer_listening
|
|
no_longer_listening.stop_sound_channel(CHANNEL_JUKEBOX)
|
|
UnregisterSignal(no_longer_listening, list(
|
|
COMSIG_MOB_LOGIN,
|
|
COMSIG_QDELETING,
|
|
COMSIG_MOVABLE_MOVED,
|
|
SIGNAL_ADDTRAIT(TRAIT_DEAF),
|
|
SIGNAL_REMOVETRAIT(TRAIT_DEAF),
|
|
))
|
|
|
|
/// Updates the passed mob's sound in according to their position and status.
|
|
/datum/jukebox/proc/update_listener(mob/listener)
|
|
PROTECTED_PROC(TRUE)
|
|
|
|
active_song_sound.status = listeners[listener] || NONE
|
|
|
|
var/turf/sound_turf = get_turf(parent)
|
|
var/turf/listener_turf = get_turf(listener)
|
|
if(isnull(sound_turf) || isnull(listener_turf)) // ??
|
|
active_song_sound.x = 0
|
|
active_song_sound.z = 0
|
|
|
|
else if(sound_turf.z != listener_turf.z) // Could MAYBE model multi-z jukeboxes but that's too complex for now
|
|
listeners[listener] |= SOUND_MUTE
|
|
|
|
else
|
|
// keep in mind sound XYZ is different to world XYZ. sound +-z = world +-y
|
|
var/new_x = sound_turf.x - listener_turf.x
|
|
var/new_z = sound_turf.y - listener_turf.y
|
|
|
|
if((abs(new_x) > x_cutoff || abs(new_z) > z_cutoff))
|
|
listeners[listener] |= SOUND_MUTE
|
|
|
|
else if(listeners[listener] & SOUND_MUTE)
|
|
unmute_listener(listener, MUTE_RANGE)
|
|
|
|
active_song_sound.x = new_x
|
|
active_song_sound.z = new_z
|
|
|
|
SEND_SOUND(listener, active_song_sound)
|
|
|
|
/// When the jukebox moves, we need to update all listeners.
|
|
/datum/jukebox/proc/on_moved(datum/source, ...)
|
|
SIGNAL_HANDLER
|
|
update_all()
|
|
|
|
/// When the jukebox enters a new area entirely, we need to update the environment to the new area's.
|
|
/datum/jukebox/proc/on_enter_area(datum/source, area/area_to_register)
|
|
SIGNAL_HANDLER
|
|
set_new_environment(area_to_register.sound_environment || SOUND_ENVIRONMENT_NONE)
|
|
|
|
/// Check for new mobs entering the jukebox's range.
|
|
/datum/jukebox/proc/check_new_listener(datum/source, atom/movable/entered)
|
|
SIGNAL_HANDLER
|
|
|
|
if(isnull(active_song_sound))
|
|
return
|
|
if(!ismob(entered))
|
|
return
|
|
if(entered in listeners)
|
|
return
|
|
register_listener(entered)
|
|
|
|
/**
|
|
* Subtype which only plays the music to the mob you pass in via start_music().
|
|
*
|
|
* Multiple mobs can still listen at once, but you must register them all manually via start_music().
|
|
*/
|
|
/datum/jukebox/single_mob
|
|
requires_range_check = FALSE
|
|
|
|
/datum/jukebox/single_mob/start_music(mob/solo_listener)
|
|
register_listener(solo_listener)
|
|
|
|
#undef IS_PREF_MUTED
|
|
|
|
#undef MUTE_DEAF
|
|
#undef MUTE_PREF
|
|
#undef MUTE_RANGE
|
|
|
|
/// Track datums, used in jukeboxes
|
|
/datum/track
|
|
/// Readable name, used in the jukebox menu
|
|
var/song_name = "generic"
|
|
/// Filepath of the song
|
|
var/song_path = null
|
|
/// How long is the song in deciseconds
|
|
var/song_length = 0
|
|
/// How long is a beat of the song in decisconds
|
|
/// Used to determine time between effects when played
|
|
var/song_beat = 0
|
|
|
|
// Default track supplied for testing and also because it's a banger
|
|
/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
|