mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2026-07-19 03:55:11 +01:00
cd8deef686
## About The Pull Request The primary feature of this pr is two admin verbs: Both locked behind `R_SERVER` at present. Also both can be removed from this pr if we arent interested in giving admins easy control over this. Changes will not take effect untill the next round unless someone has yet to touch/spawn a jukebox. please ignore the "#. " in the titles, thats just how my spotify scraper formats the titles and I am lazy. ### Jukebox Upload Music Allows for the upload of any music to the jukebox (currently restricted to `.ogg` file types for being the better format but I can release that restriction). Adding Beats is optional but not required as it does not do a ton tbh. https://github.com/user-attachments/assets/859cdba8-c1dd-4ce4-bfce-40727a0a30b8 ### Jukebox Browse Music Allows you to delete any of them, this noticeably catches ANY sound format stored within the list. Also play and download https://github.com/user-attachments/assets/cffca3dc-d7f2-4926-bda9-2bf3fef80adb ### Refactors In order to add both of these verbs seemisly i made a bunch of improvments, Sound length for music is now gotten via the rust_g call instead of being baked into the title, this means you only need two args for jukebox titles. (This will mess up the bpm of old music tracks unfortunately.) Standerized the behavoir for validating the file type of a file, this is not full proof from what im aware and there is no checks for if the file ACCTALLY exists. ## Why It's Good For The Game Atleast on a downstream, R_SERVER is alot more commonly handed out then direct access to the tgs configs. And TGS kinda blows bubbles for doing large scale file managment. ## Changelog 🆑 code: generic helpers and defines for validating file extenstion refactor: the jukebox has had some improvements in how it validates the sound file. hopefully none of your funny songs disappear. admin: command_report_menu will let you set ANY sound file type for the sound it plays. admin: Admins can now manage jukebox songs in game server: Jukebox songs length are now set automatically, update your jukebox configs! /🆑 --------- Co-authored-by: san7890 <the@san7890.com>
409 lines
15 KiB
Plaintext
409 lines
15 KiB
Plaintext
/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)
|
|
processing_flags = START_PROCESSING_MANUALLY
|
|
/// Cooldown between "Error" sound effects being played
|
|
COOLDOWN_DECLARE(jukebox_error_cd)
|
|
/// 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)
|
|
. = ..()
|
|
music_player = new(src)
|
|
register_context()
|
|
|
|
/obj/machinery/jukebox/add_context(atom/source, list/context, obj/item/held_item, mob/user)
|
|
. = ..()
|
|
if(held_item?.tool_behaviour == TOOL_WRENCH)
|
|
context[SCREENTIP_CONTEXT_LMB] = anchored ? "Unsecure" : "Secure"
|
|
return CONTEXTUAL_SCREENTIP_SET
|
|
context[SCREENTIP_CONTEXT_RMB] = "Toggle Playing"
|
|
return CONTEXTUAL_SCREENTIP_SET
|
|
|
|
/obj/machinery/jukebox/Destroy()
|
|
stop_music()
|
|
QDEL_NULL(music_player)
|
|
return ..()
|
|
|
|
/obj/machinery/jukebox/examine(mob/user)
|
|
. = ..()
|
|
if(music_player.active_song_sound)
|
|
. += "Now playing: [music_player.selection.song_name]"
|
|
|
|
/obj/machinery/jukebox/wrench_act(mob/living/user, obj/item/tool)
|
|
if(!isnull(music_player.active_song_sound))
|
|
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 = "[base_icon_state][music_player.active_song_sound ? "-active" : null]"
|
|
return ..()
|
|
|
|
/obj/machinery/jukebox/attack_hand_secondary(mob/user, list/modifiers)
|
|
. = ..()
|
|
if(. == SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN || !allowed(user))
|
|
return .
|
|
toggle_playing(user)
|
|
return SECONDARY_ATTACK_CANCEL_ATTACK_CHAIN
|
|
|
|
/obj/machinery/jukebox/ui_status(mob/user, datum/ui_state/state)
|
|
if(isobserver(user))
|
|
return ..()
|
|
if(!anchored)
|
|
balloon_alert(user, "must be anchored!")
|
|
return UI_CLOSE
|
|
if(!allowed(user))
|
|
balloon_alert(user, "access denied!")
|
|
user.playsound_local(src, 'sound/machines/compiler/compiler-failure.ogg', 20, TRUE)
|
|
return UI_CLOSE
|
|
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/machines/compiler/compiler-failure.ogg', 25, TRUE)
|
|
return UI_CLOSE
|
|
return ..()
|
|
|
|
/obj/machinery/jukebox/ui_interact(mob/user, datum/tgui/ui)
|
|
ui = SStgui.try_update_ui(user, src, ui)
|
|
if(!ui)
|
|
ui = new(user, src, "Jukebox", name)
|
|
ui.open()
|
|
|
|
/obj/machinery/jukebox/ui_data(mob/user)
|
|
return music_player.get_ui_data()
|
|
|
|
/obj/machinery/jukebox/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state)
|
|
. = ..()
|
|
if(.)
|
|
return
|
|
|
|
var/mob/user = ui.user
|
|
switch(action)
|
|
if("toggle")
|
|
toggle_playing(user)
|
|
return TRUE
|
|
|
|
if("select_track")
|
|
if(!isnull(music_player.active_song_sound))
|
|
to_chat(user, 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" || new_volume == "max")
|
|
music_player.set_volume_to_max()
|
|
else if(new_volume == "min")
|
|
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
|
|
|
|
///If a song is playing, cut it. If none is playing, and the cooldown is up, start the queued track.
|
|
/obj/machinery/jukebox/proc/toggle_playing(mob/user)
|
|
if(!isnull(music_player.active_song_sound))
|
|
stop_music()
|
|
return
|
|
if(COOLDOWN_FINISHED(src, jukebox_song_cd))
|
|
activate_music()
|
|
return
|
|
balloon_alert(user, "on cooldown for [DisplayTimeText(COOLDOWN_TIMELEFT(src, jukebox_song_cd))]!")
|
|
if(COOLDOWN_FINISHED(src, jukebox_error_cd))
|
|
playsound(src, 'sound/machines/compiler/compiler-failure.ogg', 25, TRUE)
|
|
COOLDOWN_START(src, jukebox_error_cd, 15 SECONDS)
|
|
|
|
/obj/machinery/jukebox/proc/activate_music()
|
|
if(!isnull(music_player.active_song_sound))
|
|
return FALSE
|
|
|
|
music_player.start_music()
|
|
update_use_power(ACTIVE_POWER_USE)
|
|
update_appearance(UPDATE_ICON_STATE)
|
|
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/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/no_access
|
|
req_access = null
|
|
|
|
/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/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)
|
|
FOR_DVIEW(var/turf/t, 3, get_turf(src),INVISIBILITY_LIGHTING)
|
|
if(t.x == cen.x && t.y > cen.y)
|
|
spotlights += new /obj/item/flashlight/spotlight(t, 1 + get_dist(src, t), 30 - (get_dist(src, t) * 8), COLOR_SOFT_RED)
|
|
continue
|
|
if(t.x == cen.x && t.y < cen.y)
|
|
spotlights += new /obj/item/flashlight/spotlight(t, 1 + get_dist(src, t), 30 - (get_dist(src, t) * 8), LIGHT_COLOR_PURPLE)
|
|
continue
|
|
if(t.x > cen.x && t.y == cen.y)
|
|
spotlights += new /obj/item/flashlight/spotlight(t, 1 + get_dist(src, t), 30 - (get_dist(src, t) * 8), LIGHT_COLOR_DIM_YELLOW)
|
|
continue
|
|
if(t.x < cen.x && t.y == cen.y)
|
|
spotlights += new /obj/item/flashlight/spotlight(t, 1 + get_dist(src, t), 30 - (get_dist(src, t) * 8), LIGHT_COLOR_GREEN)
|
|
continue
|
|
if((t.x+1 == cen.x && t.y+1 == cen.y) || (t.x+2 == cen.x && t.y+2 == cen.y))
|
|
spotlights += new /obj/item/flashlight/spotlight(t, 1.4 + get_dist(src, t), 30 - (get_dist(src, t) * 8), LIGHT_COLOR_ORANGE)
|
|
continue
|
|
if((t.x-1 == cen.x && t.y-1 == cen.y) || (t.x-2 == cen.x && t.y-2 == cen.y))
|
|
spotlights += new /obj/item/flashlight/spotlight(t, 1.4 + get_dist(src, t), 30 - (get_dist(src, t) * 8), LIGHT_COLOR_CYAN)
|
|
continue
|
|
if((t.x-1 == cen.x && t.y+1 == cen.y) || (t.x-2 == cen.x && t.y+2 == cen.y))
|
|
spotlights += new /obj/item/flashlight/spotlight(t, 1.4 + get_dist(src, t), 30 - (get_dist(src, t) * 8), LIGHT_COLOR_BLUEGREEN)
|
|
continue
|
|
if((t.x+1 == cen.x && t.y-1 == cen.y) || (t.x+2 == cen.x && t.y-2 == cen.y))
|
|
spotlights += new /obj/item/flashlight/spotlight(t, 1.4 + get_dist(src, t), 30 - (get_dist(src, t) * 8), LIGHT_COLOR_BLUE)
|
|
continue
|
|
continue
|
|
FOR_DVIEW_END
|
|
|
|
/obj/machinery/jukebox/disco/proc/hierofunk()
|
|
for(var/i in 1 to 10)
|
|
spawn_atom_to_turf(/obj/effect/temp_visual/hierophant/telegraph/edge, src, 1, FALSE)
|
|
sleep(0.5 SECONDS)
|
|
|
|
#define DISCO_INFENO_RANGE (rand(85, 115)*0.01)
|
|
|
|
/obj/machinery/jukebox/disco/proc/lights_spin()
|
|
for(var/i in 1 to 25)
|
|
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
|
|
sparkles += S
|
|
switch(i)
|
|
if(1 to 8)
|
|
S.orbit(src, 30, TRUE, 60, 36, TRUE)
|
|
if(9 to 16)
|
|
S.orbit(src, 62, TRUE, 60, 36, TRUE)
|
|
if(17 to 24)
|
|
S.orbit(src, 95, TRUE, 60, 36, TRUE)
|
|
if(25)
|
|
S.pixel_y = 7
|
|
S.forceMove(get_turf(src))
|
|
sleep(0.7 SECONDS)
|
|
for(var/s in sparkles)
|
|
var/obj/effect/overlay/sparkles/reveal = s
|
|
reveal.alpha = 255
|
|
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))
|
|
stack_trace("[glow?.gc_destroyed ? "Qdeleting glow" : "null entry"] found in [src].[gc_destroyed ? " Source qdeleting at the time." : ""]")
|
|
return
|
|
switch(glow.light_color)
|
|
if(COLOR_SOFT_RED)
|
|
if(glow.even_cycle)
|
|
glow.set_light_on(FALSE)
|
|
glow.set_light_color(LIGHT_COLOR_BLUE)
|
|
else
|
|
glow.set_light_range_power_color(glow.base_light_range * DISCO_INFENO_RANGE, glow.light_power * 1.48, LIGHT_COLOR_BLUE)
|
|
glow.set_light_on(TRUE)
|
|
if(LIGHT_COLOR_BLUE)
|
|
if(glow.even_cycle)
|
|
glow.set_light_range_power_color(glow.base_light_range * DISCO_INFENO_RANGE, glow.light_power * 2, LIGHT_COLOR_GREEN)
|
|
glow.set_light_on(TRUE)
|
|
else
|
|
glow.set_light_on(FALSE)
|
|
glow.set_light_color(LIGHT_COLOR_GREEN)
|
|
if(LIGHT_COLOR_GREEN)
|
|
if(glow.even_cycle)
|
|
glow.set_light_on(FALSE)
|
|
glow.set_light_color(LIGHT_COLOR_ORANGE)
|
|
else
|
|
glow.set_light_range_power_color(glow.base_light_range * DISCO_INFENO_RANGE, glow.light_power * 0.5, LIGHT_COLOR_ORANGE)
|
|
glow.set_light_on(TRUE)
|
|
if(LIGHT_COLOR_ORANGE)
|
|
if(glow.even_cycle)
|
|
glow.set_light_range_power_color(glow.base_light_range * DISCO_INFENO_RANGE, glow.light_power * 2.27, LIGHT_COLOR_PURPLE)
|
|
glow.set_light_on(TRUE)
|
|
else
|
|
glow.set_light_on(FALSE)
|
|
glow.set_light_color(LIGHT_COLOR_PURPLE)
|
|
if(LIGHT_COLOR_PURPLE)
|
|
if(glow.even_cycle)
|
|
glow.set_light_on(FALSE)
|
|
glow.set_light_color(LIGHT_COLOR_BLUEGREEN)
|
|
else
|
|
glow.set_light_range_power_color(glow.base_light_range * DISCO_INFENO_RANGE, glow.light_power * 0.44, LIGHT_COLOR_BLUEGREEN)
|
|
glow.set_light_on(TRUE)
|
|
if(LIGHT_COLOR_BLUEGREEN)
|
|
if(glow.even_cycle)
|
|
glow.set_light_range(glow.base_light_range * DISCO_INFENO_RANGE)
|
|
glow.set_light_color(LIGHT_COLOR_DIM_YELLOW)
|
|
glow.set_light_on(TRUE)
|
|
else
|
|
glow.set_light_on(FALSE)
|
|
glow.set_light_color(LIGHT_COLOR_DIM_YELLOW)
|
|
if(LIGHT_COLOR_DIM_YELLOW)
|
|
if(glow.even_cycle)
|
|
glow.set_light_on(FALSE)
|
|
glow.set_light_color(LIGHT_COLOR_CYAN)
|
|
else
|
|
glow.set_light_range(glow.base_light_range * DISCO_INFENO_RANGE)
|
|
glow.set_light_color(LIGHT_COLOR_CYAN)
|
|
glow.set_light_on(TRUE)
|
|
if(LIGHT_COLOR_CYAN)
|
|
if(glow.even_cycle)
|
|
glow.set_light_range_power_color(glow.base_light_range * DISCO_INFENO_RANGE, glow.light_power * 0.68, COLOR_SOFT_RED)
|
|
glow.set_light_on(TRUE)
|
|
else
|
|
glow.set_light_on(FALSE)
|
|
glow.set_light_color(COLOR_SOFT_RED)
|
|
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(music_player.selection.song_beat_deciseconds || 1 SECONDS)
|
|
if(QDELETED(src))
|
|
return
|
|
|
|
#undef DISCO_INFENO_RANGE
|
|
|
|
/obj/machinery/jukebox/disco/proc/dance(mob/living/dancer, dance_num) //Show your moves
|
|
ADD_TRAIT(dancer, TRAIT_DISCO_DANCER, REF(src))
|
|
switch(dance_num)
|
|
if(1)
|
|
dance1(dancer)
|
|
if(2)
|
|
dance2(dancer)
|
|
if(3)
|
|
start_dance3(dancer)
|
|
if(4)
|
|
dance4(dancer)
|
|
|
|
/mob/proc/dance_flip()
|
|
if(dir == WEST)
|
|
emote("flip")
|
|
|
|
/obj/machinery/jukebox/disco/proc/dance1(mob/living/dancer)
|
|
addtimer(TRAIT_CALLBACK_REMOVE(dancer, TRAIT_DISCO_DANCER, REF(src)), 6.5 SECONDS, TIMER_CLIENT_TIME)
|
|
for(var/i in 0 to (6 SECONDS) step (1.5 SECONDS))
|
|
addtimer(CALLBACK(GLOBAL_PROC, GLOBAL_PROC_REF(dance_rotate), dancer, CALLBACK(dancer, TYPE_PROC_REF(/mob, dance_flip))), i, TIMER_CLIENT_TIME)
|
|
|
|
/obj/machinery/jukebox/disco/proc/dance2(mob/living/dancer, dance_length = 2.5 SECONDS)
|
|
var/matrix/initial_matrix = matrix(dancer.transform)
|
|
var/list/transforms = list(
|
|
"[NORTH]" = matrix(dancer.transform).Translate(0, 3),
|
|
"[EAST]" = matrix(dancer.transform).Translate(3, 0),
|
|
"[SOUTH]" = matrix(dancer.transform).Translate(0, -3),
|
|
"[WEST]" = matrix(dancer.transform).Translate(-1, -1),
|
|
)
|
|
addtimer(VARSET_CALLBACK(dancer, transform, initial_matrix), dance_length + 0.5 SECONDS, TIMER_CLIENT_TIME)
|
|
addtimer(TRAIT_CALLBACK_REMOVE(dancer, TRAIT_DISCO_DANCER, REF(src)), dance_length + 0.5 SECONDS)
|
|
for (var/i in 1 to dance_length)
|
|
addtimer(CALLBACK(src, PROC_REF(animate_dance2), dancer, transforms, initial_matrix), i, TIMER_CLIENT_TIME)
|
|
|
|
/obj/machinery/jukebox/disco/proc/animate_dance2(mob/living/dancer, list/transforms, matrix/initial_matrix)
|
|
dancer.setDir(turn(dancer.dir, 90))
|
|
animate(dancer, transform = transforms[num2text(dancer.dir)], time = 1, loop = 0)
|
|
animate(transform = initial_matrix, time = 2, loop = 0)
|
|
|
|
/obj/machinery/jukebox/disco/proc/start_dance3(mob/living/dancer, dance_length = 3 SECONDS)
|
|
var/initially_resting = dancer.resting
|
|
var/direction_index = 1 //this should allow everyone to dance in the same direction
|
|
addtimer(TRAIT_CALLBACK_REMOVE(dancer, TRAIT_DISCO_DANCER, REF(src)), dance_length + 0.2 SECONDS)
|
|
addtimer(CALLBACK(dancer, TYPE_PROC_REF(/mob/living, set_resting), initially_resting, TRUE, TRUE), dance_length + 0.2 SECONDS, TIMER_CLIENT_TIME)
|
|
for (var/i in 1 to dance_length step 2) // 1 = 0.1 seconds
|
|
addtimer(CALLBACK(src, PROC_REF(dance3), dancer, GLOB.cardinals[direction_index]), i, TIMER_CLIENT_TIME)
|
|
direction_index++
|
|
if(direction_index > GLOB.cardinals.len)
|
|
direction_index = 1
|
|
|
|
/obj/machinery/jukebox/disco/proc/dance3(mob/living/dancer, dir)
|
|
dancer.setDir(dir)
|
|
dancer.set_resting(!dancer.resting, silent = TRUE, instant = TRUE)
|
|
|
|
/obj/machinery/jukebox/disco/proc/dance4(mob/living/dancer, dance_length = 1.5 SECONDS)
|
|
var/matrix/initial_matrix = matrix(dancer.transform)
|
|
animate(dancer, transform = matrix(dancer.transform).Turn(180), time = 2, loop = 0)
|
|
dancer.emote("spin")
|
|
addtimer(CALLBACK(src, PROC_REF(dance4_revert), dancer, initial_matrix), dance_length, TIMER_CLIENT_TIME)
|
|
|
|
/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))
|