diff --git a/_maps/voidcrew/templates/gags/stairs.dmm b/_maps/voidcrew/templates/gags/stairs.dmm new file mode 100644 index 00000000000..c7b4501cbd8 --- /dev/null +++ b/_maps/voidcrew/templates/gags/stairs.dmm @@ -0,0 +1,639 @@ +//MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE +"a" = ( +/turf/closed/indestructible/wood, +/area/gag_room) +"b" = ( +/turf/open/floor/carpet/red, +/area/gag_room) +"c" = ( +/obj/structure/railing/wooden_fence{ + dir = 2 + }, +/turf/open/floor/carpet/red, +/area/gag_room) +"d" = ( +/obj/effect/gag_scenery{ + icon = 'icons/obj/stairs.dmi'; + icon_state = "stairs_wood"; + dir = 1 + }, +/turf/open/floor/wood, +/area/gag_room) +"e" = ( +/turf/open/floor/carpet/cyan, +/area/gag_room) +"f" = ( +/obj/item/flashlight/lamp, +/turf/open/floor/carpet/cyan, +/area/gag_room) +"g" = ( +/obj/item/kirbyplants/random, +/turf/open/floor/carpet/cyan, +/area/gag_room) +"h" = ( +/obj/structure/chair/sofa/left{ + dir = 1 + }, +/turf/open/floor/carpet/cyan, +/area/gag_room) +"i" = ( +/obj/structure/chair/sofa/middle{ + dir = 1 + }, +/turf/open/floor/carpet/cyan, +/area/gag_room) +"j" = ( +/obj/structure/chair/sofa/right{ + dir = 1 + }, +/turf/open/floor/carpet/cyan, +/area/gag_room) + +(1,1,1) = {" +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +"} + +(2,1,1) = {" +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +"} + +(3,1,1) = {" +a +b +b +c +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +e +f +e +e +e +a +"} + +(4,1,1) = {" +a +b +b +c +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +e +e +e +e +e +a +"} + +(5,1,1) = {" +a +b +b +c +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +e +e +e +e +e +a +"} + +(6,1,1) = {" +a +b +b +c +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +e +e +e +e +e +a +"} + +(7,1,1) = {" +a +b +b +c +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +e +e +e +e +e +a +"} + +(8,1,1) = {" +a +b +b +c +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +e +e +e +e +e +a +"} + +(9,1,1) = {" +a +b +b +b +d +d +d +d +d +d +d +d +d +d +d +d +d +d +d +d +d +d +e +e +e +e +e +a +"} + +(10,1,1) = {" +a +b +b +b +d +d +d +d +d +d +d +d +d +d +d +d +d +d +d +d +d +d +e +e +e +e +e +a +"} + +(11,1,1) = {" +a +b +b +b +d +d +d +d +d +d +d +d +d +d +d +d +d +d +d +d +d +d +e +e +e +e +e +a +"} + +(12,1,1) = {" +a +b +b +c +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +e +e +e +e +e +a +"} + +(13,1,1) = {" +a +b +b +c +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +e +e +e +e +e +a +"} + +(14,1,1) = {" +a +b +b +c +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +e +e +e +h +e +a +"} + +(15,1,1) = {" +a +b +b +c +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +e +e +e +i +e +a +"} + +(16,1,1) = {" +a +b +b +c +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +e +e +e +j +e +a +"} + +(17,1,1) = {" +a +b +b +c +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +e +g +e +e +e +a +"} + +(18,1,1) = {" +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +"} + +(19,1,1) = {" +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +a +"} diff --git a/_maps/voidcrew/templates/gags/trains.dmm b/_maps/voidcrew/templates/gags/trains.dmm new file mode 100644 index 00000000000..6f6f839da03 --- /dev/null +++ b/_maps/voidcrew/templates/gags/trains.dmm @@ -0,0 +1,514 @@ +//MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE +"a" = ( +/turf/open/indestructible/weird_route/white, +/area/gag_room) +"b" = ( +/obj/structure/fluff/tram_rail, +/turf/open/indestructible/weird_route/white, +/area/gag_room) +"c" = ( +/obj/structure/fluff/tram_rail{ + dir = 1 + }, +/turf/open/indestructible/weird_route/white, +/area/gag_room) + +(1,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(2,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(3,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(4,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(5,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(6,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(7,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(8,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(9,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(10,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(11,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(12,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(13,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(14,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(15,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(16,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(17,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(18,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(19,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(20,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(21,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(22,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(23,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(24,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} + +(25,1,1) = {" +a +a +a +a +a +a +a +b +a +c +a +a +a +a +a +a +a +"} diff --git a/_maps/voidcrew/templates/gags/tram.dmm b/_maps/voidcrew/templates/gags/tram.dmm new file mode 100644 index 00000000000..84a6ae892d1 --- /dev/null +++ b/_maps/voidcrew/templates/gags/tram.dmm @@ -0,0 +1,272 @@ +//MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE +"a" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/tram/spoiler{ + dir = 8 + }, +/turf/open/floor/plating, +/area/gag_room) +"b" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/tram, +/turf/open/floor/plating, +/area/gag_room) +"c" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/thermoplastic/light, +/obj/effect/gag_scenery/standing{ + icon = 'icons/obj/doors/airlocks/tram/tram.dmi'; + icon_state = "closed" + }, +/turf/open/floor/plating, +/area/gag_room) +"d" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/thermoplastic/light, +/turf/open/floor/plating, +/area/gag_room) +"e" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/tram/split, +/turf/open/floor/plating, +/area/gag_room) +"f" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/tram/split, +/obj/effect/gag_scenery/standing{ + icon = 'icons/obj/tram/tram_display.dmi'; + icon_state = "desto_blank"; + pixel_x = -8 + }, +/turf/open/floor/plating, +/area/gag_room) +"g" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/tram/spoiler{ + dir = 1 + }, +/turf/open/floor/plating, +/area/gag_room) +"h" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/thermoplastic, +/turf/open/floor/plating, +/area/gag_room) +"i" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/thermoplastic, +/obj/structure/chair/sofa/bench/tram/right, +/turf/open/floor/plating, +/area/gag_room) +"j" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/thermoplastic, +/obj/structure/chair/sofa/bench/tram, +/turf/open/floor/plating, +/area/gag_room) +"k" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/thermoplastic, +/obj/structure/chair/sofa/bench/tram/left, +/obj/effect/gag_scenery/standing{ + icon = 'icons/obj/machines/computer.dmi'; + icon_state = "tram_controls"; + dir = 2; + pixel_x = -8; + pixel_y = 32 + }, +/turf/open/floor/plating, +/area/gag_room) +"l" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/thermoplastic, +/obj/structure/chair/sofa/bench/tram/right{ + dir = 8 + }, +/turf/open/floor/plating, +/area/gag_room) +"m" = ( +/obj/effect/turf_decal/tile/neutral/tram, +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/tram, +/turf/open/indestructible/tram/plate, +/area/gag_room) +"n" = ( +/obj/effect/turf_decal/tile/neutral/tram, +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/thermoplastic, +/obj/structure/chair/sofa/bench/tram/left{ + dir = 4 + }, +/turf/open/indestructible/tram/plate, +/area/gag_room) +"o" = ( +/obj/effect/turf_decal/tile/neutral/tram, +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/thermoplastic/light, +/turf/open/indestructible/tram/plate, +/area/gag_room) +"p" = ( +/obj/effect/turf_decal/tile/neutral/tram, +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/thermoplastic, +/obj/structure/chair/sofa/bench/tram/left{ + dir = 8 + }, +/turf/open/indestructible/tram/plate, +/area/gag_room) +"q" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/thermoplastic, +/obj/structure/chair/sofa/bench/tram/right{ + dir = 4 + }, +/turf/open/floor/plating, +/area/gag_room) +"r" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/thermoplastic, +/obj/structure/chair/sofa/bench/tram/left{ + dir = 1 + }, +/obj/effect/gag_scenery/standing{ + icon = 'icons/obj/machines/computer.dmi'; + icon_state = "tram_controls"; + dir = 1; + pixel_x = 8; + pixel_y = -32 + }, +/turf/open/floor/plating, +/area/gag_room) +"s" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/thermoplastic, +/obj/structure/chair/sofa/bench/tram{ + dir = 1 + }, +/turf/open/floor/plating, +/area/gag_room) +"t" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/thermoplastic, +/obj/structure/chair/sofa/bench/tram/right{ + dir = 1 + }, +/turf/open/floor/plating, +/area/gag_room) +"u" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/thermoplastic, +/obj/structure/sign/tram_plate/directional/south, +/turf/open/floor/plating, +/area/gag_room) +"v" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/tram/spoiler, +/turf/open/floor/plating, +/area/gag_room) +"w" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/tram/split, +/obj/effect/gag_scenery/standing{ + icon = 'icons/obj/tram/tram_display.dmi'; + icon_state = "desto_blank"; + pixel_x = 8 + }, +/turf/open/floor/plating, +/area/gag_room) +"x" = ( +/obj/effect/gag_scenery/tram_subfloor, +/obj/structure/tram/spoiler{ + dir = 4 + }, +/turf/open/floor/plating, +/area/gag_room) + +(1,1,1) = {" +a +b +m +b +v +"} + +(2,1,1) = {" +b +h +n +q +b +"} + +(3,1,1) = {" +c +d +o +d +c +"} + +(4,1,1) = {" +d +d +o +d +d +"} + +(5,1,1) = {" +e +i +o +r +w +"} + +(6,1,1) = {" +e +j +o +s +e +"} + +(7,1,1) = {" +f +k +o +t +e +"} + +(8,1,1) = {" +c +d +o +d +c +"} + +(9,1,1) = {" +d +d +o +d +d +"} + +(10,1,1) = {" +b +l +p +u +b +"} + +(11,1,1) = {" +g +b +m +b +x +"} diff --git a/code/__DEFINES/sound.dm b/code/__DEFINES/sound.dm index 98aa66e5114..45a8c0ad0e5 100644 --- a/code/__DEFINES/sound.dm +++ b/code/__DEFINES/sound.dm @@ -14,7 +14,11 @@ #define CHANNEL_WEATHER 1012 //THIS SHOULD ALWAYS BE THE LOWEST ONE! //KEEP IT UPDATED -#define CHANNEL_HIGHEST_AVAILABLE 1011 +// VOIDCREW EDIT CHANGE START - Voidcrew plays voice barks (and econ scans) on fixed channel 1010, +// which sat inside the reservable pool: whatever reserved 1010 (a rhythm battle's music, a +// jukebox) got cut off by the next person to speak. +#define CHANNEL_HIGHEST_AVAILABLE 1009 // ORIGINAL: 1011 +// VOIDCREW EDIT CHANGE END #define MAX_INSTRUMENT_CHANNELS (128 * 6) diff --git a/code/game/objects/effects/spawners/random/mod.dm b/code/game/objects/effects/spawners/random/mod.dm index 50d583633d7..3ca823d6a9e 100644 --- a/code/game/objects/effects/spawners/random/mod.dm +++ b/code/game/objects/effects/spawners/random/mod.dm @@ -7,6 +7,7 @@ name = "maint MOD module spawner" loot = list( /obj/item/mod/module/springlock = 2, + /obj/item/mod/module/magboot/corpse_exoskeleton = 2, // VOIDCREW EDIT ADDITION - Monkestation PR #9889 /obj/item/mod/module/visor/rave = 2, /obj/item/mod/module/tanner = 1, /obj/item/mod/module/balloon = 1, diff --git a/config/game_options.txt b/config/game_options.txt index 54701121fc7..2b9b40b387f 100644 --- a/config/game_options.txt +++ b/config/game_options.txt @@ -371,6 +371,8 @@ ROUNDSTART_RACES plasmaman ## Races that are better than humans in some ways, but worse in others ROUNDSTART_RACES ethereal +ROUNDSTART_RACES experiment +ROUNDSTART_RACES milkie #ROUNDSTART_RACES jelly #ROUNDSTART_RACES abductor #ROUNDSTART_RACES snail diff --git a/tgstation.dme b/tgstation.dme index fdfeed6725e..204fc244b3d 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -6696,11 +6696,14 @@ #include "voidcrew\_DEFINES\drug_smuggling.dm" #include "voidcrew\_DEFINES\dynamic_events.dm" #include "voidcrew\_DEFINES\flags.dm" +#include "voidcrew\_DEFINES\fnf.dm" #include "voidcrew\_DEFINES\hud.dm" #include "voidcrew\_DEFINES\job_categories.dm" #include "voidcrew\_DEFINES\keybindings.dm" #include "voidcrew\_DEFINES\lich.dm" #include "voidcrew\_DEFINES\lighting.dm" +#include "voidcrew\_DEFINES\limb_physics.dm" +#include "voidcrew\_DEFINES\limb_rig.dm" #include "voidcrew\_DEFINES\loot.dm" #include "voidcrew\_DEFINES\maps.dm" #include "voidcrew\_DEFINES\maths.dm" @@ -6742,6 +6745,7 @@ #include "voidcrew\_DEFINES\ship_signals.dm" #include "voidcrew\_DEFINES\shuttles.dm" #include "voidcrew\_DEFINES\sound.dm" +#include "voidcrew\_DEFINES\species.dm" #include "voidcrew\_DEFINES\subsystems.dm" #include "voidcrew\_DEFINES\techweb_nodes.dm" #include "voidcrew\_DEFINES\trade.dm" @@ -7001,6 +7005,11 @@ #include "voidcrew\mapping\shuttles\syndicate\geneva.dm" #include "voidcrew\mapping\shuttles\syndicate\hyena.dm" #include "voidcrew\mapping\spawners\nanites.dm" +#include "voidcrew\modules\expie\animations.dm" +#include "voidcrew\modules\expie\bodyparts.dm" +#include "voidcrew\modules\expie\experiment.dm" +#include "voidcrew\modules\expie\milkie.dm" +#include "voidcrew\modules\expie\traits.dm" #include "voidcrew\modules\player_outposts\outpost_cargo_dock.dm" #include "voidcrew\modules\player_outposts\outpost_upgrades.dm" #include "voidcrew\modules\metrics\combat_metrics.dm" @@ -7207,7 +7216,26 @@ #include "voidcrew\modules\dynamic_events\events\stray_cargo_market.dm" #include "voidcrew\modules\dynamic_events\events\vent_clog.dm" #include "voidcrew\modules\dynamic_events\events\wormholes.dm" +#include "voidcrew\modules\fnf\battle.dm" +#include "voidcrew\modules\fnf\corruption.dm" +#include "voidcrew\modules\fnf\faces.dm" +#include "voidcrew\modules\fnf\game_over.dm" +#include "voidcrew\modules\fnf\hud.dm" +#include "voidcrew\modules\fnf\microphone.dm" +#include "voidcrew\modules\fnf\opponents.dm" +#include "voidcrew\modules\fnf\poses.dm" +#include "voidcrew\modules\fnf\props.dm" +#include "voidcrew\modules\fnf\side.dm" +#include "voidcrew\modules\fnf\songs.dm" +#include "voidcrew\modules\fnf\traced_anims.dm" +#include "voidcrew\modules\fnf\stage.dm" #include "voidcrew\modules\food_and_drinks\drinks\bottles.dm" +#include "voidcrew\modules\gags\gags.dm" +#include "voidcrew\modules\gags\invincible.dm" +#include "voidcrew\modules\gags\skibidi.dm" +#include "voidcrew\modules\gags\springlock.dm" +#include "voidcrew\modules\gags\stairs.dm" +#include "voidcrew\modules\gags\trains.dm" #include "voidcrew\modules\holopads\ship_holocall.dm" #include "voidcrew\modules\intents\_defines.dm" #include "voidcrew\modules\intents\hud_integration.dm" @@ -7223,6 +7251,18 @@ #include "voidcrew\modules\lich\lich_spells.dm" #include "voidcrew\modules\lich\lich_thrall.dm" #include "voidcrew\modules\lich\lich_wards.dm" +#include "voidcrew\modules\limb_rig\animations.dm" +#include "voidcrew\modules\limb_rig\humanoid_rig.dm" +#include "voidcrew\modules\limb_rig\limb_rig.dm" +#include "voidcrew\modules\limb_rig\physics\admin.dm" +#include "voidcrew\modules\limb_rig\physics\limb_physics.dm" +#include "voidcrew\modules\limb_rig\physics\serverblight.dm" +#include "voidcrew\modules\limb_rig\physics\serverblight_chase.dm" +#include "voidcrew\modules\limb_rig\physics\vcphys.dm" +#include "voidcrew\modules\limb_rig\preference.dm" +#include "voidcrew\modules\limb_rig\quirk.dm" +#include "voidcrew\modules\limb_rig\species_shapes.dm" +#include "voidcrew\modules\limb_rig\sprite_rig.dm" #include "voidcrew\modules\loot\abandoned_crate.dm" #include "voidcrew\modules\loot\loot_guns.dm" #include "voidcrew\modules\loot\zone_loot.dm" @@ -7317,6 +7357,8 @@ #include "voidcrew\modules\mob\living\simple_animal\slime\slime.dm" #include "voidcrew\modules\mob_spawn\corpses\wasteland_corpses.dm" #include "voidcrew\modules\mob_spawn\ghost_roles\skeleton_roles.dm" +#include "voidcrew\modules\mod\ai_hands.dm" +#include "voidcrew\modules\mod\corpse_exoskeleton.dm" #include "voidcrew\modules\nanites\code\circuits.dm" #include "voidcrew\modules\nanites\code\designs.dm" #include "voidcrew\modules\nanites\code\machines.dm" @@ -7687,6 +7729,10 @@ #include "voidcrew\modules\weapons_bench\guns\laser_carbine.dm" #include "voidcrew\modules\weapons_bench\guns\sniper.dm" #include "voidcrew\modules\weapons_bench\guns\wt550.dm" +#include "voidcrew\modules\weird_route\places.dm" +#include "voidcrew\modules\weird_route\route.dm" +#include "voidcrew\modules\weird_route\scene.dm" +#include "voidcrew\modules\weird_route\screen.dm" #include "voidcrew\objects\items\blueprints.dm" #include "voidcrew\objects\items\cards_ids.dm" #include "voidcrew\objects\items\trash.dm" diff --git a/tgui/packages/voidcrew_tgui/preferences/features/index.ts b/tgui/packages/voidcrew_tgui/preferences/features/index.ts index eafe325f6e9..54c04755e8b 100644 --- a/tgui/packages/voidcrew_tgui/preferences/features/index.ts +++ b/tgui/packages/voidcrew_tgui/preferences/features/index.ts @@ -4,3 +4,4 @@ export * from './autotranslate'; export * from './bark_voice'; export * from './intents'; +export * from './limb_rig'; diff --git a/tgui/packages/voidcrew_tgui/preferences/features/limb_rig.tsx b/tgui/packages/voidcrew_tgui/preferences/features/limb_rig.tsx new file mode 100644 index 00000000000..ed13d74c10f --- /dev/null +++ b/tgui/packages/voidcrew_tgui/preferences/features/limb_rig.tsx @@ -0,0 +1,12 @@ +import { + CheckboxInput, + type FeatureToggle, +} from 'tgui/interfaces/PreferencesMenu/preferences/features/base'; + +export const limb_rig: FeatureToggle = { + name: 'Animated body', + category: 'GAMEPLAY', + description: + 'Draws your character on the bigger, animated body that acts out walking, working and emotes. Off, you keep the plain sprite. Experiments and Milkies, and anyone with the Overanimated quirk, are animated either way.', + component: CheckboxInput, +}; diff --git a/tools/fnf/fetch_corruption_plus.py b/tools/fnf/fetch_corruption_plus.py new file mode 100644 index 00000000000..cf181796d82 --- /dev/null +++ b/tools/fnf/fetch_corruption_plus.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +""" +Downloads Corruption+'s songs for the battle microphone into data/fnf/songs/. + +Friday Night Funkin': Corruption+ is Carlito049's fan extension of Phantom Fear and Pincer's +Corruption mod (gamejolt.com/games/fnfcorruptionplus/793089). Its Demo 1 has three weeks: Kapi's +arcade (Wild Eyed, Claws, Growl), Skarlet Bunny's hunt (Vile Vice, Feral, Broken Wires) and Carol's +church (Lullaby, Cursed, Purification). Carlito lets its music, charts and sprites be used with +credit, but asks that the mod itself only be downloaded from GameJolt, so this fetches it from there +for a local server rather than the songs being shipped. data/ is gitignored. + +Credits, as the mod gives them: Carlito049 (Corruption+), FellowDesert51 (Broken Wires), Phantom +Fear and Pincer (the original Corruption mod), PaperKitty (Kapi), Rechi (Skarlet Bunny), bb-panzu +(Carol). + +The download is about 430 MB (the whole mod), of which the songs are about 90 MB. + + python3 tools/fnf/fetch_corruption_plus.py + python3 tools/fnf/fetch_corruption_plus.py --from path/to/early-access-demo-plus.zip + python3 tools/fnf/fetch_corruption_plus.py --from path/to/unpacked/mod/folder + +An installed copy of the mod works as --from too (the game's folder, or its mods folder). + +Each song's own game over comes along: its death sound, the music that loops and the sting on +retrying, as its gameoverjuice.lua picks them, in the song's folder as gameover-*.ogg with a +gameover.json saying which to play from when. And how long each runs (info.json), since some play +on past their last note. +""" + +import json +import pathlib +import re +import shutil +import sys +import tempfile +import time +import urllib.request +import zipfile + +GAME = 793089 +ROOT = pathlib.Path(__file__).resolve().parents[2] +SONGS = ROOT / "data" / "fnf" / "songs" +WEEKS = ROOT / "data" / "fnf" / "weeks" +# GameJolt turns away Python's own user agent (403), so ask as a browser would. +USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36" + + +def api(path, data=None): + request = urllib.request.Request( + f"https://gamejolt.com/site-api/web/discover/games/{path}", + data=json.dumps(data).encode() if data is not None else None, + headers={"Content-Type": "application/json", "User-Agent": USER_AGENT}, + ) + # GameJolt can be slow to answer: give it a few goes. + for attempt in range(1, 5): + try: + with urllib.request.urlopen(request, timeout=120) as reply: + return json.load(reply)["payload"] + except (TimeoutError, OSError) as error: + if attempt == 4: + sys.exit(f"GameJolt didn't answer ({error}). Download the mod from its page and use --from.") + print(f"GameJolt didn't answer ({error}), trying again...") + time.sleep(attempt * 5) + + +def download(to): + """The mod's zip, from GameJolt.""" + builds = api(f"overview/{GAME}").get("builds") or [] + if not builds: + sys.exit("GameJolt lists no downloads for Corruption+.") + url = api(f"builds/get-download-url/{builds[0]['id']}", {"forceDownload": True})["url"] + print(f"Downloading {builds[0]['primary_file']['filename']} ({builds[0]['primary_file']['filesize'] // 1_000_000} MB)...") + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(request, timeout=600) as reply, open(to, "wb") as out: + shutil.copyfileobj(reply, out) + + +def find_mod(folder): + """The mod's own folder: the one whose week lists name songs it has (the game's own assets + folder is laid out the same way, so there can be more than one).""" + best, most = None, 0 + for weeks in folder.rglob("weeks"): + mod = weeks.parent + if not (mod / "data").is_dir() or not (mod / "songs").is_dir(): + continue + songs = 0 + for week_file in weeks.glob("week*.json"): + try: + week = json.loads(week_file.read_text(encoding="utf-8-sig")) + except ValueError: + continue + songs += sum(1 for entry in week.get("songs", []) if (mod / "songs" / entry[0] / "Inst.ogg").exists()) + if songs > most: + best, most = mod, songs + if not best: + sys.exit(f"No Psych Engine mod with songs found in {folder}.") + return best + + +def ogg_length_ms(path): + """How long an Ogg Vorbis file runs: its last page's sample position over its sample rate.""" + data = path.read_bytes() + marker = data.find(b"\x01vorbis") + last = data.rfind(b"OggS") + if marker < 0 or last < 0: + return 0 + rate = int.from_bytes(data[marker + 12:marker + 16], "little") + samples = int.from_bytes(data[last + 6:last + 14], "little") + return round(samples / rate * 1000) if rate else 0 + + +def game_overs(mod, song, bpm): + """The song's game over, as its gameoverjuice.lua sets it: list of (from ms, {"death", "loop", + "end": file}), latest last. A "curStep >= N" test switches sets partway through.""" + script = mod / "data" / song / "gameoverjuice.lua" + if not script.exists(): + return [] + text = script.read_text(encoding="utf-8", errors="replace") + def picks(chunk): + found = {} + for key, name in re.findall(r"'(deathSoundName|loopSoundName|endSoundName)'\s*,\s*'([^']+)'", chunk): + found[{"deathSoundName": "death", "loopSoundName": "loop", "endSoundName": "end"}[key]] = name + return found + switch = re.search(r"curStep\s*>=\s*(\d+)\s*then(.*?)^\s*else\s*$(.*?)^\s*end\s*$", text, re.S | re.M) + if switch: + step_ms = 60000 / bpm / 4 + return [(0, picks(switch.group(3))), (round(int(switch.group(1)) * step_ms), picks(switch.group(2)))] + return [(0, picks(text))] + + +def copy_game_over(mod, song, dest, bpm): + sets = [] + for start, names in game_overs(mod, song, bpm): + files = {} + for part, name in names.items(): + source = mod / ("sounds" if part == "death" else "music") / f"{name}.ogg" + if not source.exists(): + continue + file = "gameover-" + re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + ".ogg" + shutil.copy2(source, dest / file) + files[part] = file + if files: + sets.append({"from": start, **files}) + if sets: + (dest / "gameover.json").write_text(json.dumps(sets)) + + +def main(): + args = sys.argv[1:] + source = None + if "--from" in args: + source = pathlib.Path(args[args.index("--from") + 1]).expanduser().resolve() + with tempfile.TemporaryDirectory() as tmp: + tmp = pathlib.Path(tmp) + if source and source.is_dir(): + unpacked = source + else: + archive = source or tmp / "corruption_plus.zip" + if not source: + download(archive) + unpacked = tmp / "mod" + with zipfile.ZipFile(archive) as zipped: + zipped.extractall(unpacked) + mod = find_mod(unpacked) + SONGS.mkdir(parents=True, exist_ok=True) + WEEKS.mkdir(parents=True, exist_ok=True) + weeks = sorted(p for p in (mod / "weeks").glob("*.json") if p.stem.startswith("week")) + for number, week_file in enumerate(weeks, 1): + week = json.loads(week_file.read_text(encoding="utf-8-sig")) + ids = [entry[0] for entry in week.get("songs", [])] + for song in ids: + charts, audio = mod / "data" / song, mod / "songs" / song + if not charts.is_dir() or not (audio / "Inst.ogg").exists(): + print(f" {song}: missing its chart or its audio, skipped") + continue + dest = SONGS / song + dest.mkdir(exist_ok=True) + for chart in charts.glob("*.json"): + shutil.copy2(chart, dest / chart.name) + for sound in audio.glob("*.ogg"): + shutil.copy2(sound, dest / sound.name) + bpm = 100 + for chart in charts.glob("*.json"): + try: + bpm = json.loads(chart.read_text(encoding="utf-8-sig"))["song"]["bpm"] + break + except (ValueError, KeyError, TypeError): + continue + copy_game_over(mod, song, dest, bpm) + # How long it runs, so a song that plays on past its last note isn't cut short. + (dest / "info.json").write_text(json.dumps({"length_ms": ogg_length_ms(audio / "Inst.ogg")})) + print(f" {song}") + (WEEKS / f"corruption-{number}.json").write_text(json.dumps({"name": week.get("storyName") or week_file.stem, "songs": ids})) + print(f"Done: {SONGS}") + + +if __name__ == "__main__": + main() diff --git a/tools/fnf/fetch_funkin.py b/tools/fnf/fetch_funkin.py new file mode 100644 index 00000000000..7d49f5e5d11 --- /dev/null +++ b/tools/fnf/fetch_funkin.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +""" +Downloads Friday Night Funkin' songs for the battle microphone into data/fnf/songs/. + +The songs belong to The Funkin' Crew and can't be shipped with the game, so this pulls them for +a local server straight from their public assets repository (github.com/FunkinCrew/funkin.assets). +data/ is gitignored, so nothing downloaded here ends up in a commit. + +Each song comes with its mixes (Erect, Pico): charts, metadata, instrumentals, the singers' Voices +files, and lyrics where there are any. The week lists come too, so songs can be picked by week, and Week 6's dialogue. +That's a few hundred MB for the lot. + + python3 tools/fnf/fetch_funkin.py every song + python3 tools/fnf/fetch_funkin.py bopeebo fresh just these + +If GitHub won't load for you, get the repository some other way (a zip of it from anywhere, or a +clone made on another machine), unpack it, and point this at the folder instead of downloading: + + python3 tools/fnf/fetch_funkin.py --from path/to/funkin.assets + +Modded songs don't need this script. Put each one in its own folder under data/fnf/songs/, named +after the song, with its chart JSON files, Inst.ogg and its Voices files together. A Psych Engine +mod keeps charts in mods/data// and audio in mods/songs//: copy both into one folder. +""" + +import json +import pathlib +import shutil +import subprocess +import sys +import tempfile + +REPO = "https://github.com/FunkinCrew/funkin.assets" +ROOT = pathlib.Path(__file__).resolve().parents[2] +DEST = ROOT / "data" / "fnf" / "songs" + + +def git(*args, cwd): + subprocess.run(["git", *args], cwd=cwd, check=True) + + +def voice_names(metadata, variation=None): + """Every Voices- a song's singers might use. Metadata names variants ("bf-car", "mom-car") + while the files are just Voices-bf and Voices-mom, so the part before a hyphen counts too. A + mix's own recordings end in its name: Voices-pico-pico.""" + characters = metadata.get("playData", {}).get("characters", {}) + names = [] + for key, fallback in (("playerVocals", "player"), ("opponentVocals", "opponent")): + vocals = characters.get(key) or ([characters[fallback]] if characters.get(fallback) else []) + for vocal in vocals[:1]: + names.append(vocal) + if "-" in vocal: + names.append(vocal.split("-")[0]) + if variation: + names += [f"{name}-{variation}" for name in names] + return names + + +def audio_names(metadata, variation=None): + """The audio files one version of a song uses.""" + instrumental = metadata.get("playData", {}).get("characters", {}).get("instrumental") or variation + files = [f"Inst-{instrumental}.ogg" if instrumental else "Inst.ogg"] + if variation: + files.append("Inst.ogg") + return files + [f"Voices-{voice}.ogg" for voice in voice_names(metadata, variation)] + + +def main(): + args = sys.argv[1:] + local = None + if "--from" in args: + at = args.index("--from") + local = pathlib.Path(args[at + 1]).expanduser().resolve() + del args[at:at + 2] + wanted = set(args) + with tempfile.TemporaryDirectory() as tmp: + if local: + clone = local + if not (clone / "preload" / "data" / "songs").is_dir(): + sys.exit(f"{clone} doesn't look like funkin.assets: no preload/data/songs in it.") + else: + clone = pathlib.Path(tmp) / "assets" + print("Fetching the song list...") + git("clone", "--depth", "1", "--filter=blob:none", "--sparse", REPO, str(clone), cwd=tmp) + git("sparse-checkout", "set", "--no-cone", "/preload/data/songs/", "/preload/data/levels/", "/preload/data/dialogue/", cwd=clone) + + songs = {} + for song_dir in sorted((clone / "preload" / "data" / "songs").iterdir()): + song_id = song_dir.name + chart = song_dir / f"{song_id}-chart.json" + meta = song_dir / f"{song_id}-metadata.json" + if not chart.exists() or not meta.exists() or (wanted and song_id not in wanted): + continue + metadata = json.loads(meta.read_text(encoding="utf-8")) + audio = audio_names(metadata) + for variation in metadata.get("playData", {}).get("songVariations", []): + variation_meta = song_dir / f"{song_id}-metadata-{variation}.json" + if variation_meta.exists(): + audio += audio_names(json.loads(variation_meta.read_text(encoding="utf-8")), variation) + songs[song_id] = (song_dir, sorted(set(audio))) + if not songs: + print("No songs matched.") + return + + if not local: + patterns = ["/preload/data/songs/"] + patterns.append("/preload/data/levels/") + patterns.append("/preload/data/dialogue/") + for song_id, (_, audio) in songs.items(): + patterns.extend(f"/songs/{song_id}/{name}" for name in audio) + print(f"Fetching audio for {len(songs)} songs...") + git("sparse-checkout", "set", "--no-cone", *patterns, cwd=clone) + + for song_id, (song_dir, audio_files) in songs.items(): + audio = clone / "songs" / song_id + if not (audio / "Inst.ogg").exists(): + print(f" {song_id}: no Inst.ogg, skipped") + continue + out = DEST / song_id + out.mkdir(parents=True, exist_ok=True) + for data in song_dir.glob("*.json"): + shutil.copy2(data, out / data.name) + if (song_dir / "subtitles").is_dir(): + shutil.copytree(song_dir / "subtitles", out / "subtitles", dirs_exist_ok=True) + for name in audio_files: + if (audio / name).exists(): + shutil.copy2(audio / name, out / name) + print(f" {song_id}") + + levels = clone / "preload" / "data" / "levels" + if levels.is_dir(): + weeks = ROOT / "data" / "fnf" / "weeks" + weeks.mkdir(parents=True, exist_ok=True) + for week in levels.glob("*.json"): + shutil.copy2(week, weeks / week.name) + + conversations = clone / "preload" / "data" / "dialogue" / "conversations" + if conversations.is_dir(): + dialogue = ROOT / "data" / "fnf" / "dialogue" + dialogue.mkdir(parents=True, exist_ok=True) + for conversation in conversations.glob("*.json"): + shutil.copy2(conversation, dialogue / conversation.name) + print(f"Done. Songs are in {DEST}.") + + +if __name__ == "__main__": + main() diff --git a/tools/vcphys/.gitignore b/tools/vcphys/.gitignore new file mode 100644 index 00000000000..2f7896d1d13 --- /dev/null +++ b/tools/vcphys/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tools/vcphys/Cargo.lock b/tools/vcphys/Cargo.lock new file mode 100644 index 00000000000..9e50e8132f3 --- /dev/null +++ b/tools/vcphys/Cargo.lock @@ -0,0 +1,25 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "box2d-rs" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5150004def1b5eb8db8c80fac41437dabda563ccc49f8a71dac4ffd132615ba5" +dependencies = [ + "bitflags", +] + +[[package]] +name = "vcphys" +version = "0.1.0" +dependencies = [ + "box2d-rs", +] diff --git a/tools/vcphys/Cargo.toml b/tools/vcphys/Cargo.toml new file mode 100644 index 00000000000..f68cffc703a --- /dev/null +++ b/tools/vcphys/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "vcphys" +version = "0.1.0" +edition = "2021" +description = "Box2D physics for Voidcrew's limb rig, called from BYOND through call_ext." +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +box2d-rs = "=0.0.4" + +[profile.release] +opt-level = 3 +# Panics are caught at the boundary and turned into ERR replies; they must unwind to be caught. +panic = "unwind" diff --git a/tools/vcphys/src/lib.rs b/tools/vcphys/src/lib.rs new file mode 100644 index 00000000000..401dae65db2 --- /dev/null +++ b/tools/vcphys/src/lib.rs @@ -0,0 +1,523 @@ +//! vcphys: Box2D (through the pure Rust port box2d-rs) for Voidcrew's limb rig. +//! +//! Every Box2D object lives here, behind small integer handles. BYOND never sees the object graph: +//! it makes a world, puts bodies, box fixtures and revolute joints in it, steps it, reads back where +//! the bodies are, and pushes them about. +//! +//! # The boundary +//! +//! Every export is a classic BYOND `call_ext` function: text arguments in, text out, +//! `extern "C" fn(argc, argv) -> *const c_char`. That keeps it trivially debuggable (every call and +//! reply can be logged and read), and needs nothing linked from BYOND. +//! +//! Replies are one of: +//! - a handle or other value, as text +//! - `OK` +//! - `ERR:` for bad arguments, unknown handles, or a caught panic +//! +//! Units are Box2D's own: metres, radians (counter-clockwise), kilograms, seconds. Converting to +//! pixels and BYOND's clockwise degrees is the caller's job. +//! +//! # Determinism +//! +//! Given the same sequence of calls, the results are the same: stepping only ever uses the time step +//! it's given, bodies are kept in handle order (`BTreeMap`), and nothing reads a clock or a random +//! source. (Floating point makes no promises across different CPUs or compilers.) +//! +//! # Threading +//! +//! BYOND calls `call_ext` functions from its one thread, so the state is thread-local. + +use box2d_rs::b2_body::{B2body, B2bodyDef, B2bodyType, BodyPtr}; +use box2d_rs::b2_fixture::B2fixtureDef; +use box2d_rs::b2_joint::{B2JointDefEnum, B2jointPtr, JointAsDerivedMut}; +use box2d_rs::b2_math::B2vec2; +use box2d_rs::b2_world::{B2world, B2worldPtr}; +use box2d_rs::b2rs_common::UserDataType; +use box2d_rs::joints::b2_revolute_joint::B2revoluteJointDef; +use box2d_rs::shapes::b2_circle_shape::B2circleShape; +use box2d_rs::shapes::b2_polygon_shape::B2polygonShape; +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_int}; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::rc::Rc; + +#[derive(Default, Copy, Clone, Debug, PartialEq)] +pub struct NoData; +impl UserDataType for NoData { + type Fixture = (); + type Body = (); + type Joint = (); +} + +struct World { + world: B2worldPtr, + bodies: BTreeMap>, + joints: BTreeMap>, + next_handle: u32, +} + +#[derive(Default)] +struct State { + worlds: BTreeMap, + next_world: u32, +} + +thread_local! { + static STATE: RefCell = RefCell::new(State::default()); + static REPLY: RefCell = RefCell::new(CString::default()); +} + +type Reply = Result; + +/// Parses the arguments BYOND passed, runs the command, and hands back its reply as text. Panics +/// are caught here: they must not unwind into BYOND. +fn run(argc: c_int, argv: *const *const c_char, command: fn(&[String]) -> Reply) -> *const c_char { + let args: Vec = (0..argc.max(0) as usize) + .map(|i| unsafe { + let arg = *argv.add(i); + if arg.is_null() { + String::new() + } else { + CStr::from_ptr(arg).to_string_lossy().into_owned() + } + }) + .collect(); + let text = match catch_unwind(AssertUnwindSafe(|| command(&args))) { + Ok(Ok(value)) => value, + Ok(Err(message)) => format!("ERR:{message}"), + Err(_) => "ERR:panic".to_string(), + }; + REPLY.with(|reply| { + *reply.borrow_mut() = CString::new(text.replace('\0', " ")).unwrap_or_default(); + reply.borrow().as_ptr() + }) +} + +fn num(args: &[String], index: usize, name: &str) -> Result { + let text = args.get(index).ok_or_else(|| format!("missing {name}"))?; + let value: f32 = text.trim().parse().map_err(|_| format!("{name} is not a number: {text}"))?; + if value.is_finite() { + Ok(value) + } else { + Err(format!("{name} is not finite")) + } +} + +fn handle(args: &[String], index: usize, name: &str) -> Result { + let text = args.get(index).ok_or_else(|| format!("missing {name}"))?; + text.trim().parse().map_err(|_| format!("{name} is not a handle: {text}")) +} + +fn with_world(id: u32, f: impl FnOnce(&mut World) -> Result) -> Result { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let world = state.worlds.get_mut(&id).ok_or_else(|| format!("no world {id}"))?; + f(world) + }) +} + +fn body_of(world: &World, id: u32) -> Result, String> { + world.bodies.get(&id).cloned().ok_or_else(|| format!("no body {id}")) +} + +fn fmt_body(id: u32, body: &BodyPtr) -> String { + let body = body.borrow(); + let position = body.get_position(); + let velocity = body.get_linear_velocity(); + format!( + "{id} {:.5} {:.5} {:.5} {:.5} {:.5} {:.5}", + position.x, + position.y, + body.get_angle(), + velocity.x, + velocity.y, + body.get_angular_velocity() + ) +} + +// The commands. Each takes the text arguments and returns a reply. + +pub fn version(_: &[String]) -> Reply { + Ok(format!("vcphys {} box2d-rs 0.0.4", env!("CARGO_PKG_VERSION"))) +} + +/// world_create(gravity_x, gravity_y) -> world +pub fn world_create(args: &[String]) -> Reply { + let gravity = B2vec2::new(num(args, 0, "gravity_x")?, num(args, 1, "gravity_y")?); + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.next_world += 1; + let id = state.next_world; + state.worlds.insert(id, World { world: B2world::new(gravity), bodies: BTreeMap::new(), joints: BTreeMap::new(), next_handle: 0 }); + Ok(id.to_string()) + }) +} + +/// world_destroy(world) -> OK. Everything in it goes with it. +pub fn world_destroy(args: &[String]) -> Reply { + let id = handle(args, 0, "world")?; + STATE.with(|state| { + let removed = state.borrow_mut().worlds.remove(&id); + let world = removed.ok_or_else(|| format!("no world {id}"))?; + // Joints first, then bodies, so nothing is left holding a reference to the world. + for (_, joint) in world.joints { + world.world.borrow_mut().destroy_joint(joint); + } + for (_, body) in world.bodies { + world.world.borrow_mut().destroy_body(body); + } + Ok("OK".to_string()) + }) +} + +/// world_step(world, dt, velocity_iterations, position_iterations, substeps) -> OK +/// Takes `substeps` steps of exactly `dt` seconds each. +pub fn world_step(args: &[String]) -> Reply { + let id = handle(args, 0, "world")?; + let dt = num(args, 1, "dt")?; + let velocity_iterations = num(args, 2, "velocity_iterations")? as i32; + let position_iterations = num(args, 3, "position_iterations")? as i32; + let substeps = num(args, 4, "substeps")? as i32; + if !(dt > 0.0 && dt <= 0.1) || !(1..=64).contains(&substeps) { + return Err("dt must be in (0, 0.1] and substeps in 1..=64".into()); + } + with_world(id, |world| { + for _ in 0..substeps { + world.world.borrow_mut().step(dt, velocity_iterations.clamp(1, 32), position_iterations.clamp(1, 32)); + } + Ok("OK".to_string()) + }) +} + +/// world_read(world) -> "id x y angle vx vy spin;..." for every body, in handle order. +pub fn world_read(args: &[String]) -> Reply { + with_world(handle(args, 0, "world")?, |world| { + Ok(world.bodies.iter().map(|(id, body)| fmt_body(*id, body)).collect::>().join(";")) + }) +} + +/// world_stats(world) -> "bodies=N joints=M" +pub fn world_stats(args: &[String]) -> Reply { + with_world(handle(args, 0, "world")?, |world| Ok(format!("bodies={} joints={}", world.bodies.len(), world.joints.len()))) +} + +/// body_create(world, type, x, y, angle, linear_damping, angular_damping[, can_sleep[, fixed_rotation]]) +/// -> body +/// type: 0 static, 1 kinematic, 2 dynamic. can_sleep 0 keeps a body simulated even when it's +/// barely moving (Box2D otherwise stops simulating resting bodies, and a stalled motor won't wake one). +/// fixed_rotation 1 makes a body that never turns (something that slides about, like a mob). +pub fn body_create(args: &[String]) -> Reply { + let id = handle(args, 0, "world")?; + let body_type = match num(args, 1, "type")? as i32 { + 0 => B2bodyType::B2StaticBody, + 1 => B2bodyType::B2KinematicBody, + 2 => B2bodyType::B2DynamicBody, + other => return Err(format!("unknown body type {other}")), + }; + let mut def = B2bodyDef::::default(); + def.body_type = body_type; + def.position = B2vec2::new(num(args, 2, "x")?, num(args, 3, "y")?); + def.angle = num(args, 4, "angle")?; + def.linear_damping = num(args, 5, "linear_damping")?.max(0.0); + def.angular_damping = num(args, 6, "angular_damping")?.max(0.0); + if args.len() > 7 { + def.allow_sleep = num(args, 7, "can_sleep")? != 0.0; + } + if args.len() > 8 { + def.fixed_rotation = num(args, 8, "fixed_rotation")? != 0.0; + } + with_world(id, |world| { + let body = B2world::create_body(world.world.clone(), &def); + world.next_handle += 1; + world.bodies.insert(world.next_handle, body); + Ok(world.next_handle.to_string()) + }) +} + +/// body_destroy(world, body) -> OK. Its fixtures and joints go with it. +pub fn body_destroy(args: &[String]) -> Reply { + let body_id = handle(args, 1, "body")?; + with_world(handle(args, 0, "world")?, |world| { + let body = world.bodies.remove(&body_id).ok_or_else(|| format!("no body {body_id}"))?; + // Box2D destroys attached joints itself; forget our handles to them. + let attached: Vec = world + .joints + .iter() + .filter(|(_, joint)| { + let joint = joint.borrow(); + Rc::ptr_eq(&joint.get_base().get_body_a(), &body) || Rc::ptr_eq(&joint.get_base().get_body_b(), &body) + }) + .map(|(id, _)| *id) + .collect(); + for joint_id in attached { + world.joints.remove(&joint_id); + } + world.world.borrow_mut().destroy_body(body); + Ok("OK".to_string()) + }) +} + +/// fixture_box(world, body, half_width, half_height, centre_x, centre_y, angle, density, friction, +/// restitution, group) -> OK +/// A box in the body's own frame. Fixtures sharing a negative group never collide with each other. +/// A body can have more than one: to make a body start colliding with something it was grouped +/// away from, add a second, weightless box in another group. (Changing a fixture's group in place +/// isn't offered: box2d-rs panics refiltering a fixture that's touching anything.) +pub fn fixture_box(args: &[String]) -> Reply { + let body_id = handle(args, 1, "body")?; + let (half_width, half_height) = (num(args, 2, "half_width")?, num(args, 3, "half_height")?); + if half_width <= 0.0 || half_height <= 0.0 { + return Err("box half sizes must be positive".into()); + } + let centre = B2vec2::new(num(args, 4, "centre_x")?, num(args, 5, "centre_y")?); + let angle = num(args, 6, "angle")?; + let mut shape = B2polygonShape::default(); + shape.set_as_box_angle(half_width, half_height, centre, angle); + let mut def = B2fixtureDef::::default(); + def.shape = Some(Rc::new(RefCell::new(shape))); + def.density = num(args, 7, "density")?.max(0.0); + def.friction = num(args, 8, "friction")?.max(0.0); + def.restitution = num(args, 9, "restitution")?.clamp(0.0, 1.0); + def.filter.group_index = num(args, 10, "group")? as i16; + with_world(handle(args, 0, "world")?, |world| { + let body = body_of(world, body_id)?; + B2body::create_fixture(body, &def); + Ok("OK".to_string()) + }) +} + +/// fixture_circle(world, body, radius, centre_x, centre_y, density, friction, restitution, group) -> OK +/// A circle in the body's own frame. Groups work as they do for fixture_box(). +pub fn fixture_circle(args: &[String]) -> Reply { + let body_id = handle(args, 1, "body")?; + let radius = num(args, 2, "radius")?; + if radius <= 0.0 { + return Err("circle radius must be positive".into()); + } + let mut shape = B2circleShape::default(); + shape.base.m_radius = radius; + shape.m_p = B2vec2::new(num(args, 3, "centre_x")?, num(args, 4, "centre_y")?); + let mut def = B2fixtureDef::::default(); + def.shape = Some(Rc::new(RefCell::new(shape))); + def.density = num(args, 5, "density")?.max(0.0); + def.friction = num(args, 6, "friction")?.max(0.0); + def.restitution = num(args, 7, "restitution")?.clamp(0.0, 1.0); + def.filter.group_index = num(args, 8, "group")? as i16; + with_world(handle(args, 0, "world")?, |world| { + let body = body_of(world, body_id)?; + B2body::create_fixture(body, &def); + Ok("OK".to_string()) + }) +} + +/// joint_revolute(world, body_a, body_b, anchor_x, anchor_y, lower, upper, motor_torque[, +/// collide_connected]) -> joint +/// Pins two bodies together at a point in the world. The angle limits, if lower < upper, are +/// relative to how the bodies sit now. A motor torque above 0 adds joint friction: a motor held +/// at zero speed that can only push that hard, which keeps a ragdoll from flopping like string. +/// collide_connected 1 lets the two bodies still collide with each other (Box2D otherwise stops +/// jointed bodies colliding): pinned together where they overlap, they never stop pushing apart. +pub fn joint_revolute(args: &[String]) -> Reply { + let (a_id, b_id) = (handle(args, 1, "body_a")?, handle(args, 2, "body_b")?); + let anchor = B2vec2::new(num(args, 3, "anchor_x")?, num(args, 4, "anchor_y")?); + let (lower, upper) = (num(args, 5, "lower")?, num(args, 6, "upper")?); + let motor_torque = num(args, 7, "motor_torque")?.max(0.0); + with_world(handle(args, 0, "world")?, |world| { + let (a, b) = (body_of(world, a_id)?, body_of(world, b_id)?); + if Rc::ptr_eq(&a, &b) { + return Err("a joint needs two different bodies".into()); + } + let mut def = B2revoluteJointDef::::default(); + def.initialize(a, b, anchor); + def.base.collide_connected = args.len() > 8 && num(args, 8, "collide_connected")? != 0.0; + def.enable_limit = lower < upper; + def.lower_angle = lower; + def.upper_angle = upper; + def.enable_motor = motor_torque > 0.0; + def.motor_speed = 0.0; + def.max_motor_torque = motor_torque; + let joint = world.world.borrow_mut().create_joint(&B2JointDefEnum::RevoluteJoint(def)); + world.next_handle += 1; + world.joints.insert(world.next_handle, joint); + Ok(world.next_handle.to_string()) + }) +} + +/// joint_destroy(world, joint) -> OK +pub fn joint_destroy(args: &[String]) -> Reply { + let joint_id = handle(args, 1, "joint")?; + with_world(handle(args, 0, "world")?, |world| { + let joint = world.joints.remove(&joint_id).ok_or_else(|| format!("no joint {joint_id}"))?; + world.world.borrow_mut().destroy_joint(joint); + Ok("OK".to_string()) + }) +} + +/// joint_set_limits(world, joint, lower, upper) -> OK +/// New angle limits for a revolute joint, relative to how it sat when made. lower >= upper frees it. +pub fn joint_set_limits(args: &[String]) -> Reply { + let joint_id = handle(args, 1, "joint")?; + let (lower, upper) = (num(args, 2, "lower")?, num(args, 3, "upper")?); + with_revolute(handle(args, 0, "world")?, joint_id, |revolute| { + revolute.enable_limit(lower < upper); + if lower < upper { + revolute.set_limits(lower, upper); + } + }) +} + +/// joint_set_motor(world, joint, speed, max_torque) -> OK +/// Drives a revolute joint at a speed (radians a second, counter-clockwise), pushing no harder than +/// max_torque. A max_torque of 0 turns the motor off. +pub fn joint_set_motor(args: &[String]) -> Reply { + let joint_id = handle(args, 1, "joint")?; + let (speed, max_torque) = (num(args, 2, "speed")?, num(args, 3, "max_torque")?.max(0.0)); + with_revolute(handle(args, 0, "world")?, joint_id, |revolute| { + revolute.enable_motor(max_torque > 0.0); + revolute.set_motor_speed(speed); + revolute.set_max_motor_torque(max_torque); + }) +} + +fn with_revolute( + world_id: u32, + joint_id: u32, + f: impl FnOnce(&mut box2d_rs::joints::b2_revolute_joint::B2revoluteJoint), +) -> Reply { + with_world(world_id, |world| { + let joint = world.joints.get(&joint_id).cloned().ok_or_else(|| format!("no joint {joint_id}"))?; + let mut joint = joint.borrow_mut(); + match joint.as_derived_mut() { + JointAsDerivedMut::ERevoluteJoint(revolute) => { + f(revolute); + Ok("OK".to_string()) + } + _ => Err(format!("joint {joint_id} isn't revolute")), + } + }) +} + +/// body_set_transform(world, body, x, y, angle) -> OK +/// Puts a body somewhere else at once (a teleport, not a move: nothing in between is hit). +pub fn body_set_transform(args: &[String]) -> Reply { + let body_id = handle(args, 1, "body")?; + let position = B2vec2::new(num(args, 2, "x")?, num(args, 3, "y")?); + let angle = num(args, 4, "angle")?; + with_world(handle(args, 0, "world")?, |world| { + body_of(world, body_id)?.borrow_mut().set_transform(position, angle); + Ok("OK".to_string()) + }) +} + +/// body_set_velocity(world, body, vx, vy, spin) -> OK +pub fn body_set_velocity(args: &[String]) -> Reply { + let body_id = handle(args, 1, "body")?; + let velocity = B2vec2::new(num(args, 2, "vx")?, num(args, 3, "vy")?); + let spin = num(args, 4, "spin")?; + with_world(handle(args, 0, "world")?, |world| { + let body = body_of(world, body_id)?; + let mut body = body.borrow_mut(); + body.set_linear_velocity(velocity); + body.set_angular_velocity(spin); + Ok("OK".to_string()) + }) +} + +/// body_read(world, body) -> "id x y angle vx vy spin" +pub fn body_read(args: &[String]) -> Reply { + let body_id = handle(args, 1, "body")?; + with_world(handle(args, 0, "world")?, |world| Ok(fmt_body(body_id, &body_of(world, body_id)?))) +} + +/// Where on a body to push: the world point given, or its centre of mass if none is. +fn push_point(args: &[String], index: usize, body: &BodyPtr) -> Result { + if args.len() > index + 1 { + Ok(B2vec2::new(num(args, index, "point_x")?, num(args, index + 1, "point_y")?)) + } else { + Ok(body.borrow().get_world_center()) + } +} + +/// body_impulse(world, body, impulse_x, impulse_y[, point_x, point_y]) -> OK +pub fn body_impulse(args: &[String]) -> Reply { + let body_id = handle(args, 1, "body")?; + let impulse = B2vec2::new(num(args, 2, "impulse_x")?, num(args, 3, "impulse_y")?); + with_world(handle(args, 0, "world")?, |world| { + let body = body_of(world, body_id)?; + let point = push_point(args, 4, &body)?; + body.borrow_mut().apply_linear_impulse(impulse, point, true); + Ok("OK".to_string()) + }) +} + +/// body_force(world, body, force_x, force_y[, point_x, point_y]) -> OK. Lasts one step. +pub fn body_force(args: &[String]) -> Reply { + let body_id = handle(args, 1, "body")?; + let force = B2vec2::new(num(args, 2, "force_x")?, num(args, 3, "force_y")?); + with_world(handle(args, 0, "world")?, |world| { + let body = body_of(world, body_id)?; + let point = push_point(args, 4, &body)?; + body.borrow_mut().apply_force(force, point, true); + Ok("OK".to_string()) + }) +} + +/// body_torque(world, body, torque) -> OK. Lasts one step. +pub fn body_torque(args: &[String]) -> Reply { + let body_id = handle(args, 1, "body")?; + let torque = num(args, 2, "torque")?; + with_world(handle(args, 0, "world")?, |world| { + body_of(world, body_id)?.borrow_mut().apply_torque(torque, true); + Ok("OK".to_string()) + }) +} + +/// body_angular_impulse(world, body, impulse) -> OK +pub fn body_angular_impulse(args: &[String]) -> Reply { + let body_id = handle(args, 1, "body")?; + let impulse = num(args, 2, "impulse")?; + with_world(handle(args, 0, "world")?, |world| { + body_of(world, body_id)?.borrow_mut().apply_angular_impulse(impulse, true); + Ok("OK".to_string()) + }) +} + +/// Exports each command to BYOND under a `vcphys_` name, so nothing clashes with anything else +/// BYOND loads. +macro_rules! export { + ($($export:ident => $command:ident),* $(,)?) => { + $( + #[no_mangle] + pub extern "C" fn $export(argc: c_int, argv: *const *const c_char) -> *const c_char { + run(argc, argv, $command) + } + )* + }; +} + +export! { + vcphys_version => version, + vcphys_world_create => world_create, + vcphys_world_destroy => world_destroy, + vcphys_world_step => world_step, + vcphys_world_read => world_read, + vcphys_world_stats => world_stats, + vcphys_body_create => body_create, + vcphys_body_destroy => body_destroy, + vcphys_body_read => body_read, + vcphys_fixture_box => fixture_box, + vcphys_joint_revolute => joint_revolute, + vcphys_joint_destroy => joint_destroy, + vcphys_body_impulse => body_impulse, + vcphys_body_force => body_force, + vcphys_body_torque => body_torque, + vcphys_body_angular_impulse => body_angular_impulse, + vcphys_joint_set_limits => joint_set_limits, + vcphys_joint_set_motor => joint_set_motor, + vcphys_fixture_circle => fixture_circle, + vcphys_body_set_transform => body_set_transform, + vcphys_body_set_velocity => body_set_velocity, +} diff --git a/tools/vcphys/tests/api.rs b/tools/vcphys/tests/api.rs new file mode 100644 index 00000000000..408ec874c17 --- /dev/null +++ b/tools/vcphys/tests/api.rs @@ -0,0 +1,181 @@ +//! The API as BYOND sees it: text in, text out. +use vcphys::*; + +fn call(command: fn(&[String]) -> Result, args: &[&str]) -> Result { + command(&args.iter().map(|s| s.to_string()).collect::>()) +} + +fn ok(command: fn(&[String]) -> Result, args: &[&str]) -> String { + call(command, args).unwrap_or_else(|e| panic!("{e}")) +} + +/// A floor, and a two-link arm hanging off a box, knocked sideways. +fn scene() -> (String, String) { + let world = ok(world_create, &["0", "-9.8"]); + let ground = ok(body_create, &[&world, "0", "0", "-0.5", "0", "0", "0"]); + ok(fixture_box, &[&world, &ground, "5", "0.5", "0", "0", "0", "0", "0.6", "0", "0"]); + let upper = ok(body_create, &[&world, "2", "0", "1.5", "0", "0.05", "0.3"]); + ok(fixture_box, &[&world, &upper, "0.1", "0.25", "0", "-0.25", "0", "1", "0.6", "0.1", "-1"]); + let lower = ok(body_create, &[&world, "2", "0", "1.0", "0", "0.05", "0.3"]); + ok(fixture_box, &[&world, &lower, "0.1", "0.25", "0", "-0.25", "0", "1", "0.6", "0.1", "-1"]); + // An elbow that only bends one way, a quarter turn at most. + ok(joint_revolute, &[&world, &upper, &lower, "0", "1.0", "0", "1.5708", "0.2"]); + ok(body_impulse, &[&world, &lower, "0.8", "0"]); + for _ in 0..90 { + ok(world_step, &[&world, "0.016666667", "8", "3", "1"]); + } + let read = ok(world_read, &[&world]); + (world, read) +} + +#[test] +fn same_calls_same_result() { + let (first_world, first) = scene(); + let (second_world, second) = scene(); + assert_eq!(first, second, "two identical runs came out different"); + ok(world_destroy, &[&first_world]); + ok(world_destroy, &[&second_world]); +} + +#[test] +fn joint_limit_holds() { + let (world, read) = scene(); + // Bodies in handle order: ground (1), upper (2), lower (3). Field 3 is the angle. + let angles: Vec = read.split(';').map(|body| body.split(' ').nth(3).unwrap().parse().unwrap()).collect(); + let bend = angles[2] - angles[1]; + assert!(bend >= -0.05 && bend <= 1.6208, "the elbow bent {bend} rad, outside its limits"); + ok(world_destroy, &[&world]); +} + +#[test] +fn things_fall_and_land() { + let world = ok(world_create, &["0", "-9.8"]); + let ground = ok(body_create, &[&world, "0", "0", "-0.5", "0", "0", "0"]); + ok(fixture_box, &[&world, &ground, "5", "0.5", "0", "0", "0", "0", "0.6", "0", "0"]); + let crate_ = ok(body_create, &[&world, "2", "0", "3", "0", "0", "0"]); + ok(fixture_box, &[&world, &crate_, "0.25", "0.25", "0", "0", "0", "1", "0.6", "0", "0"]); + ok(world_step, &[&world, "0.016666667", "8", "3", "64"]); + ok(world_step, &[&world, "0.016666667", "8", "3", "64"]); + let y: f32 = ok(body_read, &[&world, &crate_]).split(' ').nth(2).unwrap().parse().unwrap(); + assert!((y - 0.25).abs() < 0.02, "the crate came to rest at {y}, not on the floor"); + ok(world_destroy, &[&world]); +} + +#[test] +fn bad_calls_say_why() { + assert!(call(world_step, &["999", "0.016", "8", "3", "1"]).unwrap_err().contains("no world")); + let world = ok(world_create, &["0", "-9.8"]); + assert!(call(body_read, &[&world, "42"]).unwrap_err().contains("no body")); + assert!(call(body_create, &[&world, "7", "0", "0", "0", "0", "0"]).unwrap_err().contains("body type")); + assert!(call(world_step, &[&world, "0.5", "8", "3", "1"]).is_err(), "a huge time step was allowed"); + assert!(call(world_create, &["NaN", "0"]).is_err(), "NaN gravity was allowed"); + assert!(call(fixture_box, &[&world]).unwrap_err().contains("missing")); + ok(world_destroy, &[&world]); + assert!(call(world_stats, &[&world]).unwrap_err().contains("no world"), "a destroyed world still answers"); +} + +#[test] +fn destroying_a_body_forgets_its_joints() { + let world = ok(world_create, &["0", "-9.8"]); + let a = ok(body_create, &[&world, "2", "0", "1", "0", "0", "0"]); + ok(fixture_box, &[&world, &a, "0.1", "0.1", "0", "0", "0", "1", "0.5", "0", "0"]); + let b = ok(body_create, &[&world, "2", "0", "0.8", "0", "0", "0"]); + ok(fixture_box, &[&world, &b, "0.1", "0.1", "0", "0", "0", "1", "0.5", "0", "0"]); + ok(joint_revolute, &[&world, &a, &b, "0", "0.9", "0", "0", "0"]); + assert_eq!(ok(world_stats, &[&world]), "bodies=2 joints=1"); + ok(body_destroy, &[&world, &a]); + assert_eq!(ok(world_stats, &[&world]), "bodies=1 joints=0"); + ok(world_destroy, &[&world]); +} + +#[test] +fn collisions_limits_and_motors_can_change() { + let world = ok(world_create, &["0", "0"]); + // Two boxes in the same negative group, one flung through the other. + let a = ok(body_create, &[&world, "2", "0", "0", "0", "0", "0"]); + ok(fixture_box, &[&world, &a, "0.2", "0.2", "0", "0", "0", "1", "0.5", "0", "-1"]); + let b = ok(body_create, &[&world, "2", "-1", "0", "0", "0", "0"]); + ok(fixture_box, &[&world, &b, "0.2", "0.2", "0", "0", "0", "1", "0.5", "0", "-1"]); + ok(body_impulse, &[&world, &b, "1", "0"]); + ok(world_step, &[&world, "0.016666667", "8", "3", "60"]); + let passed: f32 = ok(body_read, &[&world, &b]).split(' ').nth(1).unwrap().parse().unwrap(); + assert!(passed > 0.3, "the same group collided anyway (b is at {passed})"); + // Now they should: move b back and fling it again. + let world2 = ok(world_create, &["0", "0"]); + let a2 = ok(body_create, &[&world2, "2", "0", "0", "0", "0", "0"]); + ok(fixture_box, &[&world2, &a2, "0.2", "0.2", "0", "0", "0", "1", "0.5", "0", "-1"]); + let b2 = ok(body_create, &[&world2, "2", "-1", "0", "0", "0", "0"]); + ok(fixture_box, &[&world2, &b2, "0.2", "0.2", "0", "0", "0", "1", "0.5", "0", "-1"]); + // A second, weightless box in group 0 each, and they collide after all. + ok(fixture_box, &[&world2, &a2, "0.2", "0.2", "0", "0", "0", "0", "0.5", "0", "0"]); + ok(fixture_box, &[&world2, &b2, "0.2", "0.2", "0", "0", "0", "0", "0.5", "0", "0"]); + ok(body_impulse, &[&world2, &b2, "1", "0"]); + ok(world_step, &[&world2, "0.016666667", "8", "3", "60"]); + // With no bounce they carry on together, b still behind a. + let b_x: f32 = ok(body_read, &[&world2, &b2]).split(' ').nth(1).unwrap().parse().unwrap(); + let a_x: f32 = ok(body_read, &[&world2, &a2]).split(' ').nth(1).unwrap().parse().unwrap(); + assert!(b_x < a_x - 0.3, "ungrouped bodies passed through each other (b at {b_x}, a at {a_x})"); + // A motor spins a free joint; a limit then holds it. + let base = ok(body_create, &[&world2, "0", "5", "0", "0", "0", "0"]); + let arm = ok(body_create, &[&world2, "2", "5", "0", "0", "0", "0"]); + ok(fixture_box, &[&world2, &arm, "0.3", "0.05", "0.3", "0", "0", "1", "0.5", "0", "-2"]); + let joint = ok(joint_revolute, &[&world2, &base, &arm, "5", "0", "0", "0", "0"]); + ok(joint_set_motor, &[&world2, &joint, "3", "50"]); + ok(world_step, &[&world2, "0.016666667", "8", "3", "30"]); + let spun: f32 = ok(body_read, &[&world2, &arm]).split(' ').nth(3).unwrap().parse().unwrap(); + assert!(spun > 1.0, "the motor didn't turn the joint ({spun} rad)"); + ok(joint_set_limits, &[&world2, &joint, "-0.1", "0.1"]); + ok(world_step, &[&world2, "0.016666667", "8", "3", "60"]); + let held: f32 = ok(body_read, &[&world2, &arm]).split(' ').nth(3).unwrap().parse().unwrap(); + assert!(held.abs() < 0.3, "the new limit didn't hold ({held} rad)"); + assert!(call(joint_set_motor, &[&world2, "999", "1", "1"]).unwrap_err().contains("no joint")); + ok(world_destroy, &[&world]); + ok(world_destroy, &[&world2]); +} + +/// Top-down, as the Serverblight chase uses it: no gravity, a circle that never turns, sliding +/// along a wall it's pushed into, then teleported and stopped while touching it. +#[test] +fn a_slider_slides_along_walls_and_teleports() { + let world = ok(world_create, &["0", "0"]); + let wall = ok(body_create, &[&world, "0", "2", "0", "0", "0", "0"]); + ok(fixture_box, &[&world, &wall, "0.5", "5", "0", "0", "0", "0", "0", "0", "0"]); + let slider = ok(body_create, &[&world, "2", "1.2", "0", "0", "1", "0", "0", "1"]); + ok(fixture_circle, &[&world, &slider, "0.3", "0", "0", "1", "0", "0", "0"]); + for _ in 0..60 { + // Diagonally into the wall: the wall stops the x, the y carries on. + ok(body_force, &[&world, &slider, "3", "3"]); + ok(world_step, &[&world, "0.016666667", "8", "3", "1"]); + } + let state: Vec = ok(body_read, &[&world, &slider]).split(' ').skip(1).map(|v| v.parse().unwrap()).collect(); + assert!(state[0] < 1.21, "went into the wall: x {}", state[0]); + assert!(state[1] > 0.5, "didn't slide along it: y {}", state[1]); + assert_eq!(state[2], 0.0, "turned with fixed rotation"); + ok(body_set_transform, &[&world, &slider, "-3", "4", "0"]); + ok(body_set_velocity, &[&world, &slider, "0", "0", "0"]); + ok(world_step, &[&world, "0.016666667", "8", "3", "1"]); + let moved: Vec = ok(body_read, &[&world, &slider]).split(' ').skip(1).map(|v| v.parse().unwrap()).collect(); + assert!((moved[0] + 3.0).abs() < 0.01 && (moved[1] - 4.0).abs() < 0.01, "teleport didn't take: {moved:?}"); + assert!(call(fixture_circle, &[&world, &slider, "0", "0", "0", "1", "0", "0", "0"]).is_err()); +} + +/// Two boxes glued where they overlap, still colliding with each other: pushed apart, the glue +/// holds them anyway. +#[test] +fn glued_bodies_still_collide() { + let world = ok(world_create, &["0", "0"]); + let a = ok(body_create, &[&world, "2", "0", "0", "0", "0", "0"]); + ok(fixture_box, &[&world, &a, "0.5", "0.5", "0", "0", "0", "1", "0.7", "0", "0"]); + let b = ok(body_create, &[&world, "2", "0.4", "0", "0", "0", "0"]); + ok(fixture_box, &[&world, &b, "0.5", "0.5", "0", "0", "0", "1", "0.7", "0", "0"]); + ok(joint_revolute, &[&world, &a, &b, "0.2", "0", "0", "0", "0", "1"]); + for _ in 0..30 { + ok(world_step, &[&world, "0.016666667", "8", "3", "1"]); + } + let read = |body: &str| -> Vec { ok(body_read, &[&world, body]).split(' ').skip(1).map(|v| v.parse().unwrap()).collect() }; + let (first, second) = (read(&a), read(&b)); + let apart = ((second[0] - first[0]).powi(2) + (second[1] - first[1]).powi(2)).sqrt(); + // The overlap pushed them round the pin (they started 0.4 apart, overlapping by 0.6). + assert!(first[2].abs() + second[2].abs() > 0.01 || apart > 0.41, "they didn't collide: {first:?} {second:?}"); + assert!(apart < 1.0, "the glue let go: {apart}"); +} diff --git a/vcphys.dll b/vcphys.dll new file mode 100755 index 00000000000..2e832c4a400 Binary files /dev/null and b/vcphys.dll differ diff --git a/voidcrew/_DEFINES/fnf.dm b/voidcrew/_DEFINES/fnf.dm new file mode 100644 index 00000000000..4a6a6315304 --- /dev/null +++ b/voidcrew/_DEFINES/fnf.dm @@ -0,0 +1,69 @@ +// Rhythm battles (voidcrew/modules/fnf) + +/// Server time in deciseconds, including how far into the current tick we are. Key presses +/// arrive mid-tick, so world.time alone would round every press to the tick. +#define FNF_NOW (world.time + world.tick_lag * TICK_USAGE_REAL / 100) + +/// How far off a press can be from its note and still count, in milliseconds either side. +#define FNF_WINDOW_SICK 55 +#define FNF_WINDOW_GOOD 100 +#define FNF_WINDOW_BAD 140 +#define FNF_WINDOW_SHIT 175 + +/// A long note let go this close to its end counts as held to the end, in milliseconds. +#define FNF_HOLD_GRACE 90 + +/// Battle states. +#define FNF_STATE_READY 0 +#define FNF_STATE_COUNTDOWN 1 +#define FNF_STATE_PLAYING 2 +#define FNF_STATE_OVER 3 + +/// Health runs from 0 (the challenger on the right lost) to 100 (the opponent on the left lost). +#define FNF_HEALTH_MAX 100 + +/// Layout, in pixels: the strums' height above the singer's tile, how far below them notes +/// start, the gap between lanes, and the health bar's size. +#define FNF_STRUM_Y 60 +#define FNF_TRAVEL_PX 150 +#define FNF_LANE_GAP 22 +#define FNF_BAR_WIDTH 128 +#define FNF_BAR_HEIGHT 6 +/// How wide a health bar's own frame is (see /obj/effect/abstract/fnf_hud/healthbar/proc/set_frame()). +#define FNF_HUD_FRAME_WIDTH 144 + +/// Trait source for being kept on the floor through a game over. +#define FNF_GAME_OVER_TRAIT "fnf_game_over" +/// Trait source for being locked into singing. +#define FNF_BATTLE_TRAIT "fnf_battle" +/// How far a singer's screen zooms in on the stage. +#define FNF_ZOOM 2 +/// Priority of a summoned opponent's skin colour over their limbs: the lowest, so anything else +/// that recolours limbs (a hulk's green) still wins. +#define FNF_SKIN_PRIORITY 1 + +// Corruption+ songs (voidcrew/modules/fnf/corruption.dm). +/// The colour corruption coats things in. +#define FNF_CORRUPTION_COLOUR "#261636" +/// What corrupted hands turn. +#define FNF_CORRUPTION_HANDS "#a8102c" +/// How much of the corruption it takes to swallow one piece of the body, start to finish. +#define FNF_CORRUPTION_SPAN 0.35 +/// How far a head's taken over before its face is the corruption's: halfway, as dark as it gets +/// with its own face still to be seen on it. +#define FNF_CORRUPTION_FACE 0.5 +/// How far below its peak corruption has to fall for a piece to be fought wholly free. +#define FNF_CORRUPTION_FIGHT 0.4 +/// How much of a taken head has to be fought free for its near eye to come back, the grin staying. +#define FNF_CORRUPTION_FACE_HALF 0.45 +/// And for the whole face to. +#define FNF_CORRUPTION_FACE_FREED 0.9 +/// What the main colour of clothes corruption's taken turns: its violet, with next to no green, so +/// their shading sinks to indigo rather than teal. +#define FNF_CORRUPTION_CLOTHES_COLOUR "#2c0640" +/// What the bright trims of clothes corruption's taken glow. +#define FNF_CORRUPTION_TRIM "#ff289e" +/// The darkest clothes' main colour is taken to be, so dark clothes stay dark (brightness, 0 to 1). +#define FNF_CORRUPTION_DARKEST_MAIN 0.4 +/// How much brighter than the clothes' main colour a trim has to be to glow fully. +#define FNF_CORRUPTION_TRIM_RISE 0.3 diff --git a/voidcrew/_DEFINES/limb_physics.dm b/voidcrew/_DEFINES/limb_physics.dm new file mode 100644 index 00000000000..7498b587290 --- /dev/null +++ b/voidcrew/_DEFINES/limb_physics.dm @@ -0,0 +1,65 @@ +// Box2D physics for the limb rig (voidcrew/modules/limb_rig/physics/). + +/// Pixels to a metre. A human-sized body comes out about 1.8 metres tall. +#define LIMB_PHYSICS_PPM 20 +/// Every physics step is exactly this long, in seconds, whatever the server's doing: the +/// simulation only depends on how many steps it's taken, never on how long they took. +#define LIMB_PHYSICS_DT (1 / 60) +/// Steps taken each time the subsystem fires (every decisecond): a tenth of a second's worth. +#define LIMB_PHYSICS_STEPS_PER_FIRE 6 +/// How long a body's steps may take each fire, in milliseconds, on average. Past that it takes +/// fewer steps a fire (down to one), and runs slow rather than the server does. +#define LIMB_PHYSICS_STEP_BUDGET 5 +#define LIMB_PHYSICS_VELOCITY_ITERATIONS 8 +#define LIMB_PHYSICS_POSITION_ITERATIONS 3 +/// Earth's. +#define LIMB_PHYSICS_GRAVITY -9.8 +/// The collision group a ragdoll's pieces share. Negative, so they never collide with each other, +/// only with the floor and walls. +#define LIMB_PHYSICS_RAGDOLL_GROUP -1 +/// Box2D body types. +#define LIMB_PHYSICS_STATIC 0 +#define LIMB_PHYSICS_DYNAMIC 2 +/// How high a human's hand is, where held items are drawn from (as the sprite rig has it). +#define LIMB_PHYSICS_HUMAN_HAND_Y 12.5 +/// The trait source Serverblight holds its victim still with. +#define SERVERBLIGHT_TRAIT "serverblight" +/// How many metres of the ragdoll's frame a tile is. +#define SERVERBLIGHT_TILE_METRES (32 / LIMB_PHYSICS_PPM) +/// How fast Serverblight hunts, in tiles a second. +#define SERVERBLIGHT_SPEED 5.5 +/// How far it can see someone to hunt them, in tiles. +#define SERVERBLIGHT_SIGHT 8 +/// Someone in reach of Serverblight's hands, to them: a box this big, in metres, feet at the bottom. +#define SERVERBLIGHT_PREY_HALF_WIDTH 0.45 +#define SERVERBLIGHT_PREY_HALF_HEIGHT 0.9 +/// How much holding it takes to take someone: a point for every hand on them, every fire (with +/// five hands, six fires of all of them). Nothing holding them, it slips back two a fire. +#define SERVERBLIGHT_ASSIMILATION 30 +/// How many people taken in are grown onto the body. Past that they're only taken. +#define SERVERBLIGHT_MAX_DRAWN_MERGES 3 +/// How much slower someone moves for each of Serverblight's hands on them, in deciseconds a tile. +#define SERVERBLIGHT_GRIP_SLOWDOWN 0.8 +/// The most Serverblight's hands slow anyone down, however many there are: slow, never stuck. +#define SERVERBLIGHT_GRIP_MAX_SLOWDOWN 6 +/// How many of the pieces nearest it each piece of someone taken in is glued to. +#define SERVERBLIGHT_GLUE_PER_PIECE 3 +/// How fast glued pieces lying in each other are pushed apart, at most, in metres a second a fire... +#define SERVERBLIGHT_PUSHBACK 1 +/// ...and how close, in metres, they have to be for it. +#define SERVERBLIGHT_PUSHBACK_REACH 0.9 +/// The fastest any piece of a Serverblighted body is let go, in metres a second... +#define SERVERBLIGHT_TOP_SPEED 10 +/// ...and spun, in radians a second. Unchecked, its pieces spin at Box2D's own limit (thirty pi) +/// and the body comes apart. +#define SERVERBLIGHT_TOP_SPIN 15 +/// How far from its torso, in metres, any piece of a Serverblighted body is ever meant to get. Past +/// that it's coming apart, and it starts over (no more than every SERVERBLIGHT_REBUILD_COOLDOWN). +#define SERVERBLIGHT_TORN_REACH 5 +#define SERVERBLIGHT_REBUILD_COOLDOWN (3 SECONDS) +/// How long a killed Serverblight lies dead before it gets back up. +#define SERVERBLIGHT_DEATH_TIME (1 MINUTES) +/// With no gravity, how many fires between Serverblight throwing itself off things. +#define SERVERBLIGHT_PUSHOFF_FIRES 5 +/// How much health each body Serverblight takes in adds to it. +#define SERVERBLIGHT_HEALTH_PER_BODY 100 diff --git a/voidcrew/_DEFINES/limb_rig.dm b/voidcrew/_DEFINES/limb_rig.dm new file mode 100644 index 00000000000..ad9e7ae33df --- /dev/null +++ b/voidcrew/_DEFINES/limb_rig.dm @@ -0,0 +1,25 @@ +// Limb rig (voidcrew/modules/limb_rig): the pieces a human's sprite is cut into so each can move. + +#define RIG_HEAD "head" +#define RIG_CHEST "chest" +#define RIG_L_ARM "l_arm" +#define RIG_R_ARM "r_arm" +#define RIG_L_LEG "l_leg" +#define RIG_R_LEG "r_leg" +/// The tail, for species that give the rig one. +#define RIG_TAIL "tail" + +/// How much longer arms and legs are drawn than the sprite, along their length. +#define RIG_ARM_STRETCH 1.25 +#define RIG_LEG_STRETCH 1.3 + +/// What the rig is doing, which decides what it goes back to after a one-off animation. +#define RIG_ACTIVITY_IDLE "idle" +#define RIG_ACTIVITY_MOVING "moving" +#define RIG_ACTIVITY_ONESHOT "oneshot" +#define RIG_ACTIVITY_WORKING "working" + +/// How a rigged body walks. +#define RIG_WALK_NORMAL "normal" +/// Long, loping, high-kneed strides with a sway. For very long legs. +#define RIG_WALK_LOPE "lope" diff --git a/voidcrew/_DEFINES/species.dm b/voidcrew/_DEFINES/species.dm new file mode 100644 index 00000000000..83520b4cd4f --- /dev/null +++ b/voidcrew/_DEFINES/species.dm @@ -0,0 +1,7 @@ +/// The Experiment (voidcrew/modules/expie): dark dog-rat people with long legs and a big spiky tail. +#define SPECIES_EXPERIMENT "experiment" +/// Milkies: short, fluffy white Experiments (voidcrew/modules/expie/milkie.dm). +#define SPECIES_MILKIE "milkie" +/// An Experiment's blood type, and its colour: yellow-orange. +#define BLOOD_TYPE_EXPERIMENT "EXP" +#define BLOOD_COLOR_EXPERIMENT "#E8A020" diff --git a/voidcrew/_DEFINES/techweb_nodes.dm b/voidcrew/_DEFINES/techweb_nodes.dm index f9bcf266077..686ecd4e7c4 100644 --- a/voidcrew/_DEFINES/techweb_nodes.dm +++ b/voidcrew/_DEFINES/techweb_nodes.dm @@ -69,3 +69,6 @@ #define TECHWEB_NODE_SHIP_REPAIR_SWARM "ship_repair_swarm" #define TECHWEB_NODE_SHIP_FABRICATION_MK3 "ship_fabrication_mk3" #define TECHWEB_NODE_SHIP_FABRICATION_MK4 "ship_fabrication_mk4" + +// MOD modules +#define TECHWEB_NODE_MOD_MORTALITY "mod_mortality" diff --git a/voidcrew/modules/expie/animations.dm b/voidcrew/modules/expie/animations.dm new file mode 100644 index 00000000000..25af8ece4ee --- /dev/null +++ b/voidcrew/modules/expie/animations.dm @@ -0,0 +1,96 @@ +/** + * How an Experiment moves: its own animations rather than the human ones, recreated as key poses + * from how the creature moves in its home game. Each clip is a list of list(fraction of the loop, + * pose), with whole joint angles (see sprite_rig.dm). Legs listed as the right one are the near leg + * side-on. + */ + +/// Standing: weight rocking between the legs, arms swaying. +GLOBAL_LIST_INIT(expie_idle, list( + list(0.0, list(RIG_R_LEG = list("thigh" = -8, "shin" = -64, "foot" = 3), RIG_L_LEG = list("thigh" = 23, "shin" = -47, "foot" = 13), RIG_R_ARM = list("upper" = -8, "lower" = 9), RIG_L_ARM = list("upper" = 0, "lower" = 8))), + list(0.25, list(RIG_R_LEG = list("thigh" = -6, "shin" = -68, "foot" = 5), RIG_L_LEG = list("thigh" = 24, "shin" = -50, "foot" = 16), RIG_R_ARM = list("upper" = -10, "lower" = 11), RIG_L_ARM = list("upper" = 2, "lower" = 10))), + list(0.5, list(RIG_R_LEG = list("thigh" = -5, "shin" = -73, "foot" = 6), RIG_L_LEG = list("thigh" = 25, "shin" = -53, "foot" = 20), RIG_R_ARM = list("upper" = -13, "lower" = 12), RIG_L_ARM = list("upper" = 3, "lower" = 12))), + list(0.75, list(RIG_R_LEG = list("thigh" = -6, "shin" = -68, "foot" = 5), RIG_L_LEG = list("thigh" = 24, "shin" = -50, "foot" = 16), RIG_R_ARM = list("upper" = -10, "lower" = 11), RIG_L_ARM = list("upper" = 2, "lower" = 10))), +)) + +/// Running: leaning well forward, long bounding strides, arms pumping. One loop is two steps. +GLOBAL_LIST_INIT(expie_run, list( + list(0.0, list(RIG_R_LEG = list("thigh" = 45, "shin" = -58, "foot" = -21), RIG_L_LEG = list("thigh" = -18, "shin" = -53, "foot" = -27), RIG_R_ARM = list("upper" = -76, "lower" = 9), RIG_L_ARM = list("upper" = 1, "lower" = 94), RIG_CHEST = list("bend" = 35), RIG_HEAD = list("nod" = 0))), + list(0.125, list(RIG_R_LEG = list("thigh" = 69, "shin" = -26, "foot" = -1), RIG_L_LEG = list("thigh" = -44, "shin" = -88, "foot" = -63), RIG_R_ARM = list("upper" = -56, "lower" = 35), RIG_L_ARM = list("upper" = -10, "lower" = 83), RIG_CHEST = list("bend" = 29), RIG_HEAD = list("nod" = 1))), + list(0.25, list(RIG_R_LEG = list("thigh" = 56, "shin" = -3, "foot" = 24), RIG_L_LEG = list("thigh" = -41, "shin" = -87, "foot" = -81), RIG_R_ARM = list("upper" = -27, "lower" = 71), RIG_L_ARM = list("upper" = -29, "lower" = 61), RIG_CHEST = list("bend" = 24), RIG_HEAD = list("nod" = 1))), + list(0.375, list(RIG_R_LEG = list("thigh" = 17, "shin" = -25, "foot" = 1), RIG_L_LEG = list("thigh" = -3, "shin" = -76, "foot" = -69), RIG_R_ARM = list("upper" = -5, "lower" = 89), RIG_L_ARM = list("upper" = -56, "lower" = 30), RIG_CHEST = list("bend" = 30), RIG_HEAD = list("nod" = 1))), + list(0.5, list(RIG_R_LEG = list("thigh" = -20, "shin" = -53, "foot" = -29), RIG_L_LEG = list("thigh" = 49, "shin" = -60, "foot" = -34), RIG_R_ARM = list("upper" = 7, "lower" = 95), RIG_L_ARM = list("upper" = -73, "lower" = 10), RIG_CHEST = list("bend" = 35), RIG_HEAD = list("nod" = 0))), + list(0.625, list(RIG_R_LEG = list("thigh" = -46, "shin" = -88, "foot" = -69), RIG_L_LEG = list("thigh" = 71, "shin" = -25, "foot" = 2), RIG_R_ARM = list("upper" = -5, "lower" = 89), RIG_L_ARM = list("upper" = -56, "lower" = 30), RIG_CHEST = list("bend" = 30), RIG_HEAD = list("nod" = 0))), + list(0.75, list(RIG_R_LEG = list("thigh" = -39, "shin" = -87, "foot" = -81), RIG_L_LEG = list("thigh" = 55, "shin" = 6, "foot" = 18), RIG_R_ARM = list("upper" = -27, "lower" = 71), RIG_L_ARM = list("upper" = -29, "lower" = 61), RIG_CHEST = list("bend" = 24), RIG_HEAD = list("nod" = 1))), + list(0.875, list(RIG_R_LEG = list("thigh" = 3, "shin" = -70, "foot" = -58), RIG_L_LEG = list("thigh" = 13, "shin" = -23, "foot" = 2), RIG_R_ARM = list("upper" = -56, "lower" = 35), RIG_L_ARM = list("upper" = -10, "lower" = 83), RIG_CHEST = list("bend" = 29), RIG_HEAD = list("nod" = 1))), +)) + +/// Walking: hunched nearly flat, creeping on bent legs. One loop is two steps. +GLOBAL_LIST_INIT(expie_sneak, list( + list(0.0, list(RIG_R_LEG = list("thigh" = 71, "shin" = -73, "foot" = -40), RIG_L_LEG = list("thigh" = 37, "shin" = -95, "foot" = 7), RIG_CHEST = list("bend" = 73))), + list(0.125, list(RIG_R_LEG = list("thigh" = 79, "shin" = -52, "foot" = -7), RIG_L_LEG = list("thigh" = 2, "shin" = -113, "foot" = -8), RIG_CHEST = list("bend" = 73))), + list(0.25, list(RIG_R_LEG = list("thigh" = 81, "shin" = -42, "foot" = 18), RIG_L_LEG = list("thigh" = -15, "shin" = -120, "foot" = -25), RIG_CHEST = list("bend" = 73))), + list(0.375, list(RIG_R_LEG = list("thigh" = 63, "shin" = -65, "foot" = 16), RIG_L_LEG = list("thigh" = 26, "shin" = -95, "foot" = -37), RIG_CHEST = list("bend" = 73))), + list(0.5, list(RIG_R_LEG = list("thigh" = 38, "shin" = -96, "foot" = 7), RIG_L_LEG = list("thigh" = 75, "shin" = -64, "foot" = -38), RIG_CHEST = list("bend" = 73))), + list(0.625, list(RIG_R_LEG = list("thigh" = 5, "shin" = -116, "foot" = 6), RIG_L_LEG = list("thigh" = 88, "shin" = -44, "foot" = -12), RIG_CHEST = list("bend" = 73))), + list(0.75, list(RIG_R_LEG = list("thigh" = -13, "shin" = -123, "foot" = 1), RIG_L_LEG = list("thigh" = 85, "shin" = -39, "foot" = 16), RIG_CHEST = list("bend" = 73))), + list(0.875, list(RIG_R_LEG = list("thigh" = 21, "shin" = -103, "foot" = -17), RIG_L_LEG = list("thigh" = 65, "shin" = -62, "foot" = 16), RIG_CHEST = list("bend" = 73))), +)) + +/// Swinging at something with both arms, overhead and down. +GLOBAL_LIST_INIT(expie_swing, list( + list(0.0, list(RIG_R_ARM = list("upper" = 43, "lower" = 94), RIG_L_ARM = list("upper" = 28, "lower" = 104))), + list(0.25, list(RIG_R_ARM = list("upper" = 85, "lower" = 139), RIG_L_ARM = list("upper" = 54, "lower" = 122))), + list(0.5, list(RIG_R_ARM = list("upper" = 53, "lower" = 136), RIG_L_ARM = list("upper" = 47, "lower" = 121))), + list(0.75, list(RIG_R_ARM = list("upper" = -9, "lower" = 61), RIG_L_ARM = list("upper" = 13, "lower" = 103))), +)) + +/datum/limb_rig/sprites/experiment + +/datum/limb_rig/sprites/experiment/get_own_idle() + return rig_clip_keyframes(GLOB.expie_idle, 10, tail_wag = 7) + +/datum/limb_rig/sprites/experiment/get_own_step(step_time, running, lying) + if(lying) + return null // Crawling is crawling. + // A clip loop is two steps: this step plays whichever half its leg leads. + var/start = left_foot_forward ? 0 : 0.5 + return rig_clip_keyframes(running ? GLOB.expie_run : GLOB.expie_sneak, step_time, start, start + 0.5, running ? 10 : 5, running ? -12 : -5) + +/datum/limb_rig/sprites/experiment/play_attack() + if(prob(50)) + playsound(owner, get_expie_exert_sound(), 35, TRUE) + var/list/keyframes = rig_clip_keyframes(GLOB.expie_swing, 3.3) + keyframes += list(list(null, 2)) + play(keyframes) + +/** + * Keyframes playing part of a clip. + * + * * duration - how long the part takes, in deciseconds, shared out evenly between its frames + * * start, stop - which part of the loop, as fractions. Frames after start up to and including stop + * play, so one part picks up where the last left off. + * * tail_wag, tail_lift - a tail swinging from side to side with each frame + */ +/proc/rig_clip_keyframes(list/clip, duration, start = 0, stop = 1, tail_wag = 0, tail_lift = 0) + . = list() + var/list/poses = list() + var/list/wrapped + for(var/list/frame as anything in clip) + var/fraction = frame[1] + if(fraction > start && fraction <= stop) + poses += list(frame[2]) + else if(fraction == 0 && stop >= 1) + wrapped = frame[2] // The loop's first frame is also its last. + if(wrapped) + poses += list(wrapped) + if(!length(poses)) + return + var/each = duration / length(poses) + var/beat = 0 + for(var/list/pose as anything in poses) + beat++ + var/list/keyframe_pose = pose.Copy() + if(tail_wag || tail_lift) + keyframe_pose[RIG_TAIL] = list("wag" = (beat % 2) ? tail_wag : -tail_wag, "lift" = tail_lift) + . += list(list(keyframe_pose, each)) diff --git a/voidcrew/modules/expie/bodyparts.dm b/voidcrew/modules/expie/bodyparts.dm new file mode 100644 index 00000000000..768583382e4 --- /dev/null +++ b/voidcrew/modules/expie/bodyparts.dm @@ -0,0 +1,91 @@ +#define EXPIE_BODYPARTS 'voidcrew/modules/expie/icons/bodyparts.dmi' + +// An Experiment is always drawn through the limb rig (see experiment.dm), which builds it from its +// own piece sprites. These are what's drawn when it isn't rigged (a preview, a corpse): the body +// standing at rest, tail and all, 64x64 and drawn 16 pixels left like the rig's pieces. + +/obj/item/bodypart/head/experiment + icon = EXPIE_BODYPARTS + // Human-shaped wounds don't fit; the rig draws the Experiment's own (see experiment.dm). + dmg_overlay_type = null + icon_static = EXPIE_BODYPARTS + limb_id = SPECIES_EXPERIMENT + is_dimorphic = FALSE + should_draw_greyscale = FALSE + // No hair, and the eyes are part of the sprite. + head_flags = HEAD_DEBRAIN + +/obj/item/bodypart/head/experiment/get_limb_icon(dropped, mob/living/carbon/update_on) + . = ..() + for(var/image/sprite as anything in .) + sprite.pixel_w -= 16 + +/obj/item/bodypart/chest/experiment + icon = EXPIE_BODYPARTS + // Human-shaped wounds don't fit; the rig draws the Experiment's own (see experiment.dm). + dmg_overlay_type = null + icon_static = EXPIE_BODYPARTS + limb_id = SPECIES_EXPERIMENT + is_dimorphic = FALSE + should_draw_greyscale = FALSE + +/obj/item/bodypart/chest/experiment/get_limb_icon(dropped, mob/living/carbon/update_on) + . = ..() + for(var/image/sprite as anything in .) + sprite.pixel_w -= 16 + +/obj/item/bodypart/arm/left/experiment + icon = EXPIE_BODYPARTS + // Human-shaped wounds don't fit; the rig draws the Experiment's own (see experiment.dm). + dmg_overlay_type = null + icon_static = EXPIE_BODYPARTS + limb_id = SPECIES_EXPERIMENT + should_draw_greyscale = FALSE + +/obj/item/bodypart/arm/left/experiment/get_limb_icon(dropped, mob/living/carbon/update_on) + . = ..() + for(var/image/sprite as anything in .) + sprite.pixel_w -= 16 + +/obj/item/bodypart/arm/right/experiment + icon = EXPIE_BODYPARTS + // Human-shaped wounds don't fit; the rig draws the Experiment's own (see experiment.dm). + dmg_overlay_type = null + icon_static = EXPIE_BODYPARTS + limb_id = SPECIES_EXPERIMENT + should_draw_greyscale = FALSE + +/obj/item/bodypart/arm/right/experiment/get_limb_icon(dropped, mob/living/carbon/update_on) + . = ..() + for(var/image/sprite as anything in .) + sprite.pixel_w -= 16 + +/obj/item/bodypart/leg/left/experiment + icon = EXPIE_BODYPARTS + // Human-shaped wounds don't fit; the rig draws the Experiment's own (see experiment.dm). + dmg_overlay_type = null + icon_static = EXPIE_BODYPARTS + limb_id = SPECIES_EXPERIMENT + should_draw_greyscale = FALSE + footprint_sprite = FOOTPRINT_SPRITE_PAWS + +/obj/item/bodypart/leg/left/experiment/get_limb_icon(dropped, mob/living/carbon/update_on) + . = ..() + for(var/image/sprite as anything in .) + sprite.pixel_w -= 16 + +/obj/item/bodypart/leg/right/experiment + icon = EXPIE_BODYPARTS + // Human-shaped wounds don't fit; the rig draws the Experiment's own (see experiment.dm). + dmg_overlay_type = null + icon_static = EXPIE_BODYPARTS + limb_id = SPECIES_EXPERIMENT + should_draw_greyscale = FALSE + footprint_sprite = FOOTPRINT_SPRITE_PAWS + +/obj/item/bodypart/leg/right/experiment/get_limb_icon(dropped, mob/living/carbon/update_on) + . = ..() + for(var/image/sprite as anything in .) + sprite.pixel_w -= 16 + +#undef EXPIE_BODYPARTS diff --git a/voidcrew/modules/expie/experiment.dm b/voidcrew/modules/expie/experiment.dm new file mode 100644 index 00000000000..c9d54650a70 --- /dev/null +++ b/voidcrew/modules/expie/experiment.dm @@ -0,0 +1,168 @@ +/** + * The Experiment. + * + * Dark, furry dog-rat people with big orange eyes, long digitigrade legs, thick thighs and an + * enormous spiky tail. They're always drawn through the limb rig, built from their own piece + * sprites (voidcrew/modules/limb_rig/sprite_rig.dm), a head taller than a human and reaching + * past the tile. Worn clothes are stretched onto them piece by piece. + */ +/datum/species/experiment + name = "Experiment" + plural_form = "Experiments" + id = SPECIES_EXPERIMENT + sexes = FALSE + inherent_biotypes = MOB_ORGANIC|MOB_HUMANOID + changesource_flags = MIRROR_BADMIN | WABBAJACK | MIRROR_MAGIC | MIRROR_PRIDE | RACE_SWAP | ERT_SPAWN | SLIME_EXTRACT + payday_modifier = 1.0 + exotic_bloodtype = BLOOD_TYPE_EXPERIMENT + mutantbrain = /obj/item/organ/brain/experiment + bodypart_overrides = list( + BODY_ZONE_HEAD = /obj/item/bodypart/head/experiment, + BODY_ZONE_CHEST = /obj/item/bodypart/chest/experiment, + BODY_ZONE_L_ARM = /obj/item/bodypart/arm/left/experiment, + BODY_ZONE_R_ARM = /obj/item/bodypart/arm/right/experiment, + BODY_ZONE_L_LEG = /obj/item/bodypart/leg/left/experiment, + BODY_ZONE_R_LEG = /obj/item/bodypart/leg/right/experiment, + ) + limb_rig_shape = list( + "always" = TRUE, + "rig_type" = /datum/limb_rig/sprites/experiment, + "sprites" = 'voidcrew/modules/expie/icons/rig.dmi', + "cloth_masks" = 'voidcrew/modules/expie/icons/cloth_masks.dmi', + // Brute and burn marks for each piece, three levels each, brute tinted with the blood colour. + "damage" = 'voidcrew/modules/expie/icons/damage.dmi', + // Rest joints, in pixels from the bottom left of the tile, limbs hanging straight down: + // arms are shoulder, elbow, wrist; legs are hip, knee, hock, sole; crown is the top of the head. + "skeleton" = list( + "south" = list("l_arm" = list(list(19.96, 37.04), list(19.85, 31.27), list(19.96, 25.11)), "r_arm" = list(list(12.04, 37.04), list(12.15, 31.27), list(12.04, 25.11)), "l_leg" = list(list(18.56, 25.94), list(18.93, 18.34), list(18.93, 12.14), list(18.78, 0)), "r_leg" = list(list(13.44, 25.94), list(13.07, 18.34), list(13.07, 12.14), list(13.22, 0)), "head" = list(16, 38.31), "crown" = list(16, 52), "chest" = list(16, 25.8), "tail" = list(16, 27.26)), + "north" = list("l_arm" = list(list(12.04, 37.04), list(12.15, 31.27), list(12.04, 25.11)), "r_arm" = list(list(19.96, 37.04), list(19.85, 31.27), list(19.96, 25.11)), "l_leg" = list(list(13.44, 25.94), list(13.07, 18.34), list(13.07, 12.14), list(13.22, 0)), "r_leg" = list(list(18.56, 25.94), list(18.93, 18.34), list(18.93, 12.14), list(18.78, 0)), "head" = list(16, 38.31), "crown" = list(16, 52), "chest" = list(16, 25.8), "tail" = list(16, 27.26)), + "east" = list("l_arm" = list(list(15.89, 37.04), list(15.46, 31.27), list(16.05, 25.11)), "r_arm" = list(list(15.89, 37.04), list(15.46, 31.27), list(16.05, 25.11)), "l_leg" = list(list(16, 25.94), list(16, 18.34), list(16, 12.14), list(16, 0)), "r_leg" = list(list(16, 25.94), list(16, 18.34), list(16, 12.14), list(16, 0)), "head" = list(15.7, 38.31), "crown" = list(16, 52), "chest" = list(15.71, 25.8), "tail" = list(12.71, 27.26)), + "west" = list("l_arm" = list(list(16.11, 37.04), list(16.54, 31.27), list(15.95, 25.11)), "r_arm" = list(list(16.11, 37.04), list(16.54, 31.27), list(15.95, 25.11)), "l_leg" = list(list(16, 25.94), list(16, 18.34), list(16, 12.14), list(16, 0)), "r_leg" = list(list(16, 25.94), list(16, 18.34), list(16, 12.14), list(16, 0)), "head" = list(16.3, 38.31), "crown" = list(16, 52), "chest" = list(16.29, 25.8), "tail" = list(19.29, 27.26)), + ), + // Digitigrade: thigh forward, knee bent well back, foot forward again, in degrees. + "leg_rest" = list(27.4, 85.2, 74.3), + // The head is a good deal bigger than a human's. + "hat_scale" = 1.4, + "paw_height" = 4, + "torso_width" = 1.2, + // Thick thighs need wide trousers. + "cloth_widths" = list("thigh" = 2, "shin" = 1.3, "arm" = 1.5, "forearm" = 1), + // Tail carried a little raised. + "posture" = list( + RIG_TAIL = list("lift" = 10), + ), + ) + +// The voice. Every sound an Experiment makes is picked here (credits in sound/attribution.txt). + +/// Yelps and whines when hurt, and screams. +/proc/get_expie_pain_sound() + return pick( + 'voidcrew/modules/expie/sound/pain1.ogg', 'voidcrew/modules/expie/sound/pain2.ogg', + 'voidcrew/modules/expie/sound/pain3.ogg', 'voidcrew/modules/expie/sound/pain4.ogg', + 'voidcrew/modules/expie/sound/pain5.ogg', 'voidcrew/modules/expie/sound/pain6.ogg', + 'voidcrew/modules/expie/sound/pain7.ogg', 'voidcrew/modules/expie/sound/pain8.ogg', + 'voidcrew/modules/expie/sound/pain9.ogg', 'voidcrew/modules/expie/sound/pain10.ogg', + 'voidcrew/modules/expie/sound/pain11.ogg', 'voidcrew/modules/expie/sound/pain12.ogg', + 'voidcrew/modules/expie/sound/pain13.ogg', 'voidcrew/modules/expie/sound/pain14.ogg', + ) + +/// The last noise it makes. +/proc/get_expie_death_sound() + return pick( + 'voidcrew/modules/expie/sound/death1.ogg', 'voidcrew/modules/expie/sound/death2.ogg', + 'voidcrew/modules/expie/sound/death3.ogg', 'voidcrew/modules/expie/sound/death4.ogg', + ) + +/// A grunt of effort, swinging at something. +/proc/get_expie_exert_sound() + return pick( + 'voidcrew/modules/expie/sound/exert1.ogg', 'voidcrew/modules/expie/sound/exert2.ogg', + 'voidcrew/modules/expie/sound/exert3.ogg', 'voidcrew/modules/expie/sound/exert4.ogg', + ) + +/// Hits this hard or harder get a yelp. +#define EXPIE_YELP_DAMAGE 5 +/// And no more often than this. +#define EXPIE_YELP_COOLDOWN (1.5 SECONDS) + +/datum/species/experiment/on_species_gain(mob/living/carbon/human/human_who_gained_species, datum/species/old_species, pref_load, regenerate_icons) + . = ..() + RegisterSignal(human_who_gained_species, COMSIG_MOB_AFTER_APPLY_DAMAGE, PROC_REF(on_hurt)) + human_who_gained_species.death_sound = get_expie_death_sound() + +/datum/species/experiment/on_species_loss(mob/living/carbon/human/human, datum/species/new_species, pref_load) + . = ..() + UnregisterSignal(human, COMSIG_MOB_AFTER_APPLY_DAMAGE) + +/datum/species/experiment/proc/on_hurt(mob/living/carbon/human/source, damage, damagetype) + SIGNAL_HANDLER + if(damage < EXPIE_YELP_DAMAGE || source.stat == DEAD || damagetype == STAMINA || damagetype == OXY) + return + // A different last noise each time, since the mob only holds one. + source.death_sound = get_expie_death_sound() + if(TIMER_COOLDOWN_RUNNING(source, "expie_yelp")) + return + TIMER_COOLDOWN_START(source, "expie_yelp", EXPIE_YELP_COOLDOWN) + playsound(source, get_expie_pain_sound(), 50, TRUE) + +#undef EXPIE_YELP_DAMAGE +#undef EXPIE_YELP_COOLDOWN + +/datum/species/experiment/get_scream_sound(mob/living/carbon/human/human) + return get_expie_pain_sound() + +/datum/species/experiment/get_hiss_sound(mob/living/carbon/human/human) + return pick( + 'sound/mobs/non-humanoids/hiss/hiss1.ogg', + 'sound/mobs/non-humanoids/hiss/hiss2.ogg', + 'sound/mobs/non-humanoids/hiss/hiss3.ogg', + ) + +/datum/species/experiment/get_laugh_sound(mob/living/carbon/human/human) + return pick( + 'sound/mobs/non-humanoids/cat/cat_purr1.ogg', + 'sound/mobs/non-humanoids/cat/cat_purr2.ogg', + ) + +/datum/species/experiment/create_pref_unique_perks() + return list( + list( + SPECIES_PERK_TYPE = SPECIES_POSITIVE_PERK, + SPECIES_PERK_ICON = "paw", + SPECIES_PERK_NAME = "Claws and Teeth", + SPECIES_PERK_DESC = "Experiments slash with their claws instead of punching, and bite when their hands are tied or they have someone held tight. Both can open wounds.", + ), + list( + SPECIES_PERK_TYPE = SPECIES_NEGATIVE_PERK, + SPECIES_PERK_ICON = "band-aid", + SPECIES_PERK_NAME = "Frail", + SPECIES_PERK_DESC = "Experiments tear easily. Every limb takes wounds more readily than a human's, and they yelp when hurt.", + ), + list( + SPECIES_PERK_TYPE = SPECIES_NEUTRAL_PERK, + SPECIES_PERK_ICON = "tint", + SPECIES_PERK_NAME = "Yellow Blood", + SPECIES_PERK_DESC = "Experiments bleed a thin yellow-orange blood that nothing else can donate to them.", + ), + ) + +/datum/species/experiment/get_physical_attributes() + return "Experiments are lanky, with dark fur, long digitigrade legs, thick thighs, big orange eyes, \ + swept-back ears and a huge spiky tail." + +/datum/species/experiment/get_species_description() + return "A weird dog-rat-person with big orange eyes, long bent legs and a tail nearly as big as \ + the rest of them." + +/datum/species/experiment/get_species_lore() + return list( + "Nobody remembers signing off on them. Experiments turn up on crew manifests with no birthplace, \ + no next of kin and a lab number where a surname should be, and most of them would rather not talk about it.", + + "They learn fast, eat anything, fit through gaps nobody else would try, and flinch at loud noises. \ + Their tails give away every mood they have.", + ) + +/mob/living/carbon/human/species/experiment + race = /datum/species/experiment diff --git a/voidcrew/modules/expie/icons/bodyparts.dmi b/voidcrew/modules/expie/icons/bodyparts.dmi new file mode 100644 index 00000000000..07aee618e7c Binary files /dev/null and b/voidcrew/modules/expie/icons/bodyparts.dmi differ diff --git a/voidcrew/modules/expie/icons/cloth_masks.dmi b/voidcrew/modules/expie/icons/cloth_masks.dmi new file mode 100644 index 00000000000..fae217ae369 Binary files /dev/null and b/voidcrew/modules/expie/icons/cloth_masks.dmi differ diff --git a/voidcrew/modules/expie/icons/damage.dmi b/voidcrew/modules/expie/icons/damage.dmi new file mode 100644 index 00000000000..87e4d2e5210 Binary files /dev/null and b/voidcrew/modules/expie/icons/damage.dmi differ diff --git a/voidcrew/modules/expie/icons/milkie_bodyparts.dmi b/voidcrew/modules/expie/icons/milkie_bodyparts.dmi new file mode 100644 index 00000000000..314a2ae85fa Binary files /dev/null and b/voidcrew/modules/expie/icons/milkie_bodyparts.dmi differ diff --git a/voidcrew/modules/expie/icons/milkie_damage.dmi b/voidcrew/modules/expie/icons/milkie_damage.dmi new file mode 100644 index 00000000000..76baf46d393 Binary files /dev/null and b/voidcrew/modules/expie/icons/milkie_damage.dmi differ diff --git a/voidcrew/modules/expie/icons/milkie_rig.dmi b/voidcrew/modules/expie/icons/milkie_rig.dmi new file mode 100644 index 00000000000..87af34425b9 Binary files /dev/null and b/voidcrew/modules/expie/icons/milkie_rig.dmi differ diff --git a/voidcrew/modules/expie/icons/rig.dmi b/voidcrew/modules/expie/icons/rig.dmi new file mode 100644 index 00000000000..23efcb4fd55 Binary files /dev/null and b/voidcrew/modules/expie/icons/rig.dmi differ diff --git a/voidcrew/modules/expie/milkie.dm b/voidcrew/modules/expie/milkie.dm new file mode 100644 index 00000000000..6ef587d91e8 --- /dev/null +++ b/voidcrew/modules/expie/milkie.dm @@ -0,0 +1,103 @@ +/** + * Milkies. + * + * An Experiment subspecies from Casualties Unknown's Gunsaw (SAW-03): colour-swapped and built + * short. Fluffy white fur, cyan eyes on black, short white horns, a flatter snout and fins down + * the back, on legs half as long as an Experiment's. Otherwise they're Experiments through and + * through: the same voice, claws, frailty and yellow blood. + * + * Their pieces are the Experiment's own, recoloured, with the legs squashed to half their length + * and everything above the hips drawn 13 pixels lower to meet them. + */ +/datum/species/experiment/milkie + name = "Milkie" + plural_form = "Milkies" + id = SPECIES_MILKIE + bodypart_overrides = list( + BODY_ZONE_HEAD = /obj/item/bodypart/head/experiment/milkie, + BODY_ZONE_CHEST = /obj/item/bodypart/chest/experiment/milkie, + BODY_ZONE_L_ARM = /obj/item/bodypart/arm/left/experiment/milkie, + BODY_ZONE_R_ARM = /obj/item/bodypart/arm/right/experiment/milkie, + BODY_ZONE_L_LEG = /obj/item/bodypart/leg/left/experiment/milkie, + BODY_ZONE_R_LEG = /obj/item/bodypart/leg/right/experiment/milkie, + ) + limb_rig_shape = list( + "always" = TRUE, + "rig_type" = /datum/limb_rig/sprites/experiment/milkie, + "sprites" = 'voidcrew/modules/expie/icons/milkie_rig.dmi', + "cloth_masks" = 'voidcrew/modules/expie/icons/cloth_masks.dmi', + "damage" = 'voidcrew/modules/expie/icons/milkie_damage.dmi', + // The Experiment's, with the legs half as long and the rest brought down to meet them. + "skeleton" = list( + "south" = list("l_arm" = list(list(19.96, 24.04), list(19.85, 18.27), list(19.96, 12.11)), "r_arm" = list(list(12.04, 24.04), list(12.15, 18.27), list(12.04, 12.11)), "l_leg" = list(list(18.56, 12.97), list(18.93, 9.17), list(18.93, 6.07), list(18.78, 0)), "r_leg" = list(list(13.44, 12.97), list(13.07, 9.17), list(13.07, 6.07), list(13.22, 0)), "head" = list(16, 25.31), "crown" = list(16, 39), "chest" = list(16, 12.8), "tail" = list(16, 14.26)), + "north" = list("l_arm" = list(list(12.04, 24.04), list(12.15, 18.27), list(12.04, 12.11)), "r_arm" = list(list(19.96, 24.04), list(19.85, 18.27), list(19.96, 12.11)), "l_leg" = list(list(13.44, 12.97), list(13.07, 9.17), list(13.07, 6.07), list(13.22, 0)), "r_leg" = list(list(18.56, 12.97), list(18.93, 9.17), list(18.93, 6.07), list(18.78, 0)), "head" = list(16, 25.31), "crown" = list(16, 39), "chest" = list(16, 12.8), "tail" = list(16, 14.26)), + "east" = list("l_arm" = list(list(15.89, 24.04), list(15.46, 18.27), list(16.05, 12.11)), "r_arm" = list(list(15.89, 24.04), list(15.46, 18.27), list(16.05, 12.11)), "l_leg" = list(list(16, 12.97), list(16, 9.17), list(16, 6.07), list(16, 0)), "r_leg" = list(list(16, 12.97), list(16, 9.17), list(16, 6.07), list(16, 0)), "head" = list(15.7, 25.31), "crown" = list(16, 39), "chest" = list(15.71, 12.8), "tail" = list(12.71, 14.26)), + "west" = list("l_arm" = list(list(16.11, 24.04), list(16.54, 18.27), list(15.95, 12.11)), "r_arm" = list(list(16.11, 24.04), list(16.54, 18.27), list(15.95, 12.11)), "l_leg" = list(list(16, 12.97), list(16, 9.17), list(16, 6.07), list(16, 0)), "r_leg" = list(list(16, 12.97), list(16, 9.17), list(16, 6.07), list(16, 0)), "head" = list(16.3, 25.31), "crown" = list(16, 39), "chest" = list(16.29, 12.8), "tail" = list(19.29, 14.26)), + ), + "leg_rest" = list(27.4, 85.2, 74.3), + "hat_scale" = 1.4, + // Squashed paws. + "paw_height" = 2, + "torso_width" = 1.2, + // Stubby legs, just as thick. + "cloth_widths" = list("thigh" = 2.2, "shin" = 1.4, "arm" = 1.5, "forearm" = 1), + "posture" = list( + RIG_TAIL = list("lift" = 10), + ), + ) + +/datum/limb_rig/sprites/experiment/milkie + +/datum/species/experiment/milkie/get_physical_attributes() + return "Milkies are short and stocky, with fluffy white fur, stubby digitigrade legs, cyan eyes on black, \ + short white horns, fins down the back and a huge spiky tail." + +/datum/species/experiment/milkie/get_species_description() + return "A short, fluffy white Experiment with cyan eyes, little horns and fins down its back." + +/datum/species/experiment/milkie/get_species_lore() + return list( + "Experiments, only white, short and fluffy. The lab numbers on their manifests start SAW-03, \ + and whatever made them that way isn't in the paperwork either.", + + "They get underfoot, and they like it there. Nobody has ever managed to stay angry at one for long.", + ) + +/mob/living/carbon/human/species/experiment/milkie + race = /datum/species/experiment/milkie + +// Previews and corpses (see bodyparts.dm). + +#define MILKIE_BODYPARTS 'voidcrew/modules/expie/icons/milkie_bodyparts.dmi' + +/obj/item/bodypart/head/experiment/milkie + icon = MILKIE_BODYPARTS + icon_static = MILKIE_BODYPARTS + limb_id = SPECIES_MILKIE + +/obj/item/bodypart/chest/experiment/milkie + icon = MILKIE_BODYPARTS + icon_static = MILKIE_BODYPARTS + limb_id = SPECIES_MILKIE + +/obj/item/bodypart/arm/left/experiment/milkie + icon = MILKIE_BODYPARTS + icon_static = MILKIE_BODYPARTS + limb_id = SPECIES_MILKIE + +/obj/item/bodypart/arm/right/experiment/milkie + icon = MILKIE_BODYPARTS + icon_static = MILKIE_BODYPARTS + limb_id = SPECIES_MILKIE + +/obj/item/bodypart/leg/left/experiment/milkie + icon = MILKIE_BODYPARTS + icon_static = MILKIE_BODYPARTS + limb_id = SPECIES_MILKIE + +/obj/item/bodypart/leg/right/experiment/milkie + icon = MILKIE_BODYPARTS + icon_static = MILKIE_BODYPARTS + limb_id = SPECIES_MILKIE + +#undef MILKIE_BODYPARTS diff --git a/voidcrew/modules/expie/sound/attribution.txt b/voidcrew/modules/expie/sound/attribution.txt new file mode 100644 index 00000000000..a5784772d26 --- /dev/null +++ b/voidcrew/modules/expie/sound/attribution.txt @@ -0,0 +1,2 @@ +pain1-14.ogg, death1-4.ogg, exert1-4.ogg: the Experiment's pain, death and effort noises from +Casualties Unknown by Orsoniks, converted to mono Ogg Vorbis. diff --git a/voidcrew/modules/expie/sound/death1.ogg b/voidcrew/modules/expie/sound/death1.ogg new file mode 100644 index 00000000000..36884e510eb Binary files /dev/null and b/voidcrew/modules/expie/sound/death1.ogg differ diff --git a/voidcrew/modules/expie/sound/death2.ogg b/voidcrew/modules/expie/sound/death2.ogg new file mode 100644 index 00000000000..805123cc1dc Binary files /dev/null and b/voidcrew/modules/expie/sound/death2.ogg differ diff --git a/voidcrew/modules/expie/sound/death3.ogg b/voidcrew/modules/expie/sound/death3.ogg new file mode 100644 index 00000000000..901483fc6d2 Binary files /dev/null and b/voidcrew/modules/expie/sound/death3.ogg differ diff --git a/voidcrew/modules/expie/sound/death4.ogg b/voidcrew/modules/expie/sound/death4.ogg new file mode 100644 index 00000000000..31e464ac0e1 Binary files /dev/null and b/voidcrew/modules/expie/sound/death4.ogg differ diff --git a/voidcrew/modules/expie/sound/exert1.ogg b/voidcrew/modules/expie/sound/exert1.ogg new file mode 100644 index 00000000000..9d5fce6c5a7 Binary files /dev/null and b/voidcrew/modules/expie/sound/exert1.ogg differ diff --git a/voidcrew/modules/expie/sound/exert2.ogg b/voidcrew/modules/expie/sound/exert2.ogg new file mode 100644 index 00000000000..d1e460d6820 Binary files /dev/null and b/voidcrew/modules/expie/sound/exert2.ogg differ diff --git a/voidcrew/modules/expie/sound/exert3.ogg b/voidcrew/modules/expie/sound/exert3.ogg new file mode 100644 index 00000000000..1ba6801c010 Binary files /dev/null and b/voidcrew/modules/expie/sound/exert3.ogg differ diff --git a/voidcrew/modules/expie/sound/exert4.ogg b/voidcrew/modules/expie/sound/exert4.ogg new file mode 100644 index 00000000000..f5227e00bc3 Binary files /dev/null and b/voidcrew/modules/expie/sound/exert4.ogg differ diff --git a/voidcrew/modules/expie/sound/pain1.ogg b/voidcrew/modules/expie/sound/pain1.ogg new file mode 100644 index 00000000000..922b822dc1f Binary files /dev/null and b/voidcrew/modules/expie/sound/pain1.ogg differ diff --git a/voidcrew/modules/expie/sound/pain10.ogg b/voidcrew/modules/expie/sound/pain10.ogg new file mode 100644 index 00000000000..60c04c4b417 Binary files /dev/null and b/voidcrew/modules/expie/sound/pain10.ogg differ diff --git a/voidcrew/modules/expie/sound/pain11.ogg b/voidcrew/modules/expie/sound/pain11.ogg new file mode 100644 index 00000000000..30204dd40d6 Binary files /dev/null and b/voidcrew/modules/expie/sound/pain11.ogg differ diff --git a/voidcrew/modules/expie/sound/pain12.ogg b/voidcrew/modules/expie/sound/pain12.ogg new file mode 100644 index 00000000000..82c27100e98 Binary files /dev/null and b/voidcrew/modules/expie/sound/pain12.ogg differ diff --git a/voidcrew/modules/expie/sound/pain13.ogg b/voidcrew/modules/expie/sound/pain13.ogg new file mode 100644 index 00000000000..b6e8170ca09 Binary files /dev/null and b/voidcrew/modules/expie/sound/pain13.ogg differ diff --git a/voidcrew/modules/expie/sound/pain14.ogg b/voidcrew/modules/expie/sound/pain14.ogg new file mode 100644 index 00000000000..1cac4bb2f25 Binary files /dev/null and b/voidcrew/modules/expie/sound/pain14.ogg differ diff --git a/voidcrew/modules/expie/sound/pain2.ogg b/voidcrew/modules/expie/sound/pain2.ogg new file mode 100644 index 00000000000..2ec3915dd99 Binary files /dev/null and b/voidcrew/modules/expie/sound/pain2.ogg differ diff --git a/voidcrew/modules/expie/sound/pain3.ogg b/voidcrew/modules/expie/sound/pain3.ogg new file mode 100644 index 00000000000..e3c9d3b9614 Binary files /dev/null and b/voidcrew/modules/expie/sound/pain3.ogg differ diff --git a/voidcrew/modules/expie/sound/pain4.ogg b/voidcrew/modules/expie/sound/pain4.ogg new file mode 100644 index 00000000000..87375b68efa Binary files /dev/null and b/voidcrew/modules/expie/sound/pain4.ogg differ diff --git a/voidcrew/modules/expie/sound/pain5.ogg b/voidcrew/modules/expie/sound/pain5.ogg new file mode 100644 index 00000000000..13f6fef6507 Binary files /dev/null and b/voidcrew/modules/expie/sound/pain5.ogg differ diff --git a/voidcrew/modules/expie/sound/pain6.ogg b/voidcrew/modules/expie/sound/pain6.ogg new file mode 100644 index 00000000000..859d1714c94 Binary files /dev/null and b/voidcrew/modules/expie/sound/pain6.ogg differ diff --git a/voidcrew/modules/expie/sound/pain7.ogg b/voidcrew/modules/expie/sound/pain7.ogg new file mode 100644 index 00000000000..4efbcebb1c0 Binary files /dev/null and b/voidcrew/modules/expie/sound/pain7.ogg differ diff --git a/voidcrew/modules/expie/sound/pain8.ogg b/voidcrew/modules/expie/sound/pain8.ogg new file mode 100644 index 00000000000..aa55d6988b3 Binary files /dev/null and b/voidcrew/modules/expie/sound/pain8.ogg differ diff --git a/voidcrew/modules/expie/sound/pain9.ogg b/voidcrew/modules/expie/sound/pain9.ogg new file mode 100644 index 00000000000..e3e3d734908 Binary files /dev/null and b/voidcrew/modules/expie/sound/pain9.ogg differ diff --git a/voidcrew/modules/expie/traits.dm b/voidcrew/modules/expie/traits.dm new file mode 100644 index 00000000000..47a1fab88ec --- /dev/null +++ b/voidcrew/modules/expie/traits.dm @@ -0,0 +1,64 @@ +// What being an Experiment is like, beyond the look: yellow-orange blood, claws and teeth that +// cut, and a body that tears more easily than it should. + +/datum/blood_type/experiment + name = BLOOD_TYPE_EXPERIMENT + desc = "Thin yellow-orange blood. Nothing else on the chart matches it." + dna_string = "Experiment DNA" + color = BLOOD_COLOR_EXPERIMENT + +/// How much more easily an Experiment's limbs take wounds than a human's. +#define EXPIE_WOUND_WEAKNESS 10 + +/// Bites instead of swinging when its hands are no use, or it has someone held tight. +/obj/item/organ/brain/experiment + +/obj/item/organ/brain/experiment/get_attacking_limb(mob/living/carbon/human/target) + var/obj/item/bodypart/head/jaws = owner.get_bodypart(BODY_ZONE_HEAD) + if(jaws && (owner.handcuffed || !owner.usable_hands || (target.pulledby == owner && owner.grab_state >= GRAB_AGGRESSIVE))) + return jaws + return ..() + +/obj/item/bodypart/head/experiment + wound_resistance = 5 - EXPIE_WOUND_WEAKNESS + unarmed_attack_verbs = list("bite", "chomp", "gnaw") + unarmed_attack_verbs_continuous = list("bites", "chomps", "gnaws") + unarmed_attack_effect = ATTACK_EFFECT_BITE + unarmed_attack_sound = 'sound/items/weapons/bite.ogg' + unarmed_miss_sound = 'sound/items/weapons/bite.ogg' + unarmed_damage_low = 4 + unarmed_damage_high = 9 + unarmed_sharpness = SHARP_POINTY + +/obj/item/bodypart/chest/experiment + wound_resistance = -EXPIE_WOUND_WEAKNESS + +/obj/item/bodypart/arm/left/experiment + wound_resistance = -EXPIE_WOUND_WEAKNESS + unarmed_attack_verbs = list("slash", "scratch", "claw") + unarmed_attack_verbs_continuous = list("slashes", "scratches", "claws") + grappled_attack_verb = "lacerate" + grappled_attack_verb_continuous = "lacerates" + unarmed_attack_effect = ATTACK_EFFECT_CLAW + unarmed_attack_sound = 'sound/items/weapons/slash.ogg' + unarmed_miss_sound = 'sound/items/weapons/slashmiss.ogg' + unarmed_sharpness = SHARP_EDGED + +/obj/item/bodypart/arm/right/experiment + wound_resistance = -EXPIE_WOUND_WEAKNESS + unarmed_attack_verbs = list("slash", "scratch", "claw") + unarmed_attack_verbs_continuous = list("slashes", "scratches", "claws") + grappled_attack_verb = "lacerate" + grappled_attack_verb_continuous = "lacerates" + unarmed_attack_effect = ATTACK_EFFECT_CLAW + unarmed_attack_sound = 'sound/items/weapons/slash.ogg' + unarmed_miss_sound = 'sound/items/weapons/slashmiss.ogg' + unarmed_sharpness = SHARP_EDGED + +/obj/item/bodypart/leg/left/experiment + wound_resistance = -EXPIE_WOUND_WEAKNESS + +/obj/item/bodypart/leg/right/experiment + wound_resistance = -EXPIE_WOUND_WEAKNESS + +#undef EXPIE_WOUND_WEAKNESS diff --git a/voidcrew/modules/fnf/battle.dm b/voidcrew/modules/fnf/battle.dm new file mode 100644 index 00000000000..892fb32960d --- /dev/null +++ b/voidcrew/modules/fnf/battle.dm @@ -0,0 +1,471 @@ +/** + * A rhythm battle: two singers facing each other, the challenger on the right, the + * opponent (a person, a mob nobody's playing, or an Experiment summoned for it) on the left. + * + * Keeping the song and the notes together: + * - The song is sent to everyone listening paused, the moment it's picked, and again when a + * challenge goes out, so their game has it downloaded and ready while people decide. + * - A countdown, four beats long and never under two seconds, gives the download more time. + * The song then starts for everyone at once by unpausing it, rather than being sent at start. + * - Notes aren't timers fired off in advance. Every tick, each side works out where the song is + * from the start time, puts on screen whatever is due, and animates it to land on its exact + * time. Presses are judged against that same clock, corrected for the singer's ping. + */ +/datum/fnf_battle + var/datum/fnf_song/song + var/difficulty + var/state = FNF_STATE_READY + /// The microphone that started it. + var/obj/item/fnf_microphone/microphone + var/datum/fnf_side/left + var/datum/fnf_side/right + var/list/datum/fnf_side/sides = list() + /// The chart: list("player", "opponent", "events", "speed"). + var/list/chart + /// world.time (in deciseconds, fractional) when the song is at 0. + var/start_time = 0 + /// The real time of day the music started (see get_song_time()), or null before it has. + var/real_start + /// And where the song was then, by world.time, in milliseconds. + var/game_start_ms = 0 + /// How far world.time has fallen behind the music since, in milliseconds: when the server + /// lags, world.time slows down, while everyone's music plays on in real time. + var/lag_ms = 0 + /// How long a note takes to rise to its strum, in milliseconds. + var/travel_ms = 1100 + /// Milliseconds per beat. + var/crochet = 600 + /// When the last note is over, in song milliseconds. + var/end_ms = 0 + var/health = FNF_HEALTH_MAX / 2 + var/inst_channel + var/left_voice_channel + var/right_voice_channel + /// Everyone hearing the song, mob => volume multiplier. Onlookers get it quieter. + var/list/mob/listeners = list() + var/list/channel_volumes = list() + var/obj/effect/abstract/fnf_hud/healthbar/healthbar + var/tick_timer + var/next_event = 1 + var/last_beat = -1 + /// An Experiment summoned to sing against a lone challenger. Goes away afterwards. + var/mob/living/carbon/human/npc + /// Where the singers' cameras look, in world pixels: between them, a little above. + var/camera_x = 0 + var/camera_y = 0 + /// Which singer the camera leans toward, as the chart says: -1 left, 1 right, 0 neither. + var/camera_focus = 0 + /// Weakrefs to the singers whose cameras were moved, to put them back. + var/list/datum/weakref/panned = list() + /// Extra time before the count in, in deciseconds, for a stage that's still fading in. + var/lead_in = 0 + /// Everything going on around the singers: the girlfriend, lyrics, props, the song's events. + var/datum/fnf_stage/stage + /// A Corruption+ song's cast, playing out (see corruption.dm). + var/datum/fnf_corruption/corruption + /// The kind of the last note missed, if it had one: what finished someone off. + var/last_miss_kind + +/datum/fnf_battle/New(datum/fnf_song/song, difficulty, obj/item/fnf_microphone/microphone) + src.song = song + src.difficulty = difficulty + src.microphone = microphone + chart = song.load_chart(difficulty) + travel_ms = round(1800 / clamp(chart["speed"], 0.6, 4)) + crochet = 60000 / max(song.bpm, 30) + for(var/list/note as anything in chart["player"] + chart["opponent"]) + end_ms = max(end_ms, note[1] + note[3]) + end_ms += 1500 + // A song that plays on past its last note (Growl's flashback) runs to the end of its music. + if(song.length_ms) + end_ms = max(end_ms, song.length_ms) + // Without its length, a scripted song at least plays out to a little after its last cue. + for(var/list/cue as anything in (song.character_changes || list()) + (song.overlay_events || list())) + end_ms = max(end_ms, cue[1] + 3000) + inst_channel = SSsounds.reserve_sound_channel(src) + left_voice_channel = SSsounds.reserve_sound_channel(src) + right_voice_channel = SSsounds.reserve_sound_channel(src) + channel_volumes = list("[inst_channel]" = 1, "[left_voice_channel]" = 1, "[right_voice_channel]" = 1) + +/datum/fnf_battle/Destroy() + deltimer(tick_timer) + reset_cameras() + for(var/mob/listener as anything in listeners) + for(var/list/track as anything in get_tracks()) + SEND_SOUND(listener, sound(null, channel = track[2])) + UnregisterSignal(listener, COMSIG_QDELETING) + listeners.Cut() + QDEL_NULL(left) + QDEL_NULL(right) + sides.Cut() + QDEL_NULL(healthbar) + QDEL_NULL(corruption) + QDEL_NULL(stage) + if(npc && !QDELETED(npc)) + do_sparks(3, FALSE, npc) + npc.visible_message(span_notice("[npc] bows, and is gone.")) + qdel(npc) + npc = null + SSsounds.free_datum_channels(src) + if(microphone?.battle == src) + microphone.battle = null + microphone = null + return ..() + +/// The arrows this song's notes are drawn with: its own skin (Corruption+'s tainted ones), or ours. +/datum/fnf_battle/proc/get_note_icon() + switch(song.note_skin) + if("kapi") + return 'voidcrew/modules/fnf/icons/fnf_notes_kapi.dmi' + if("skarlet") + return 'voidcrew/modules/fnf/icons/fnf_notes_skarlet.dmi' + return 'voidcrew/modules/fnf/icons/fnf.dmi' + +/// The song position right now, in milliseconds. Negative during the countdown. +/datum/fnf_battle/proc/get_song_time() + return (FNF_NOW - start_time) * 100 + lag_ms + +/** + * Catches the song's clock up with the music after the server's lagged. The real clock only counts + * tenths of a second, so it isn't the clock itself: world.time is, and this is how far behind the + * real one it's fallen, smoothed out (and jumped to when it's a long way out). + */ +/datum/fnf_battle/proc/catch_up_with_music() + if(isnull(real_start)) + return + var/real_ms = (REALTIMEOFDAY - real_start) * 100 + game_start_ms + var/behind = max(real_ms - (world.time - start_time) * 100, 0) + if(abs(behind - lag_ms) > 150) + lag_ms = behind + else + lag_ms += (behind - lag_ms) * 0.05 + +/// list(file, channel) for everything that plays: the backing track and both singers' vocals. +/datum/fnf_battle/proc/get_tracks() + . = list(list(song.inst_file, inst_channel)) + if(song.player_voice_file) + . += list(list(song.player_voice_file, right_voice_channel)) + if(song.opponent_voice_file) + . += list(list(song.opponent_voice_file, left_voice_channel)) + +/datum/fnf_battle/proc/get_volume(mob/listener, channel) + var/pref = listener.client?.prefs.read_preference(/datum/preference/numeric/volume/sound_jukebox) + if(isnull(pref)) + pref = 100 + return 85 * pref / 100 * (listeners[listener] || 1) * (channel_volumes["[channel]"] || 0) + +/// Sends someone the song, paused, so it's downloaded by the time it starts. +/datum/fnf_battle/proc/preload(mob/listener, volume = 1) + if(!listener?.client) + return + if(!listeners[listener]) + RegisterSignal(listener, COMSIG_QDELETING, PROC_REF(on_listener_deleted)) + listeners[listener] = volume + for(var/list/track as anything in get_tracks()) + var/sound/music = sound(file(track[1]), repeat = FALSE, wait = FALSE, channel = track[2], volume = get_volume(listener, track[2])) + music.status = SOUND_PAUSED + SEND_SOUND(listener, music) + +/datum/fnf_battle/proc/on_listener_deleted(mob/source) + SIGNAL_HANDLER + listeners -= source + +/// Starts the paused song for everyone at once. +/datum/fnf_battle/proc/unpause_song() + for(var/mob/listener as anything in listeners) + for(var/list/track as anything in get_tracks()) + var/sound/update = sound(null, repeat = FALSE, wait = FALSE, channel = track[2], volume = get_volume(listener, track[2])) + update.status = SOUND_UPDATE + SEND_SOUND(listener, update) + +/datum/fnf_battle/proc/set_channel_volume(channel, multiplier) + if(channel_volumes["[channel]"] == multiplier) + return + channel_volumes["[channel]"] = multiplier + if(state != FNF_STATE_PLAYING && state != FNF_STATE_OVER) + return + for(var/mob/listener as anything in listeners) + var/sound/update = sound(null, repeat = FALSE, wait = FALSE, channel = channel, volume = get_volume(listener, channel)) + update.status = SOUND_UPDATE + SEND_SOUND(listener, update) + +/// Everyone nearby gets the song too, a little quieter. +/datum/fnf_battle/proc/preload_onlookers(atom/center) + for(var/mob/onlooker in hearers(7, center)) + if(onlooker.client && !listeners[onlooker]) + preload(onlooker, 0.6) + +/** + * Sets the stage and counts in. + * + * * challenger - sings the player's part, on the right + * * opponent - sings the other part, on the left. Nobody means an Experiment is summoned. + */ +/datum/fnf_battle/proc/start(mob/living/challenger, mob/living/opponent) + var/turf/right_turf = get_turf(challenger) + var/turf/left_turf = find_opponent_spot(right_turf, opponent) + if(!opponent) + opponent = summon_opponent(left_turf || right_turf) + else if(left_turf && get_turf(opponent) != left_turf) + opponent.forceMove(left_turf) + left_turf = get_turf(opponent) + + right = new(src, challenger, TRUE, chart["player"], right_voice_channel) + left = new(src, opponent, FALSE, chart["opponent"], left_voice_channel) + if(opponent == npc) + left.style = song.opponent_character + left.is_cpu = TRUE + // By name, even with Tankman's face covered. + left.singer_name = opponent.real_name + left.update_score_text() + // Singing as someone other than Boyfriend (Pico, in a Pico mix): moving like them, and holding + // what they hold. Nobody brings a gun to a fistfight, though. + if(song.player_character && song.player_character != "bf") + right.style = song.player_character + // Corrupted, Pico hunches over his gun, held out one-handed. + if(song.corruption && song.player_character == "pico") + right.style = "cpico" + if(GLOB.fnf_opponents[song.player_character]?["gun"] && !is_fight()) + right.give_prop(/obj/item/toy/fnf_gun) + sides = list(left, right) + // Up close in a fight, the two sets of arrows would sit on top of each other. Like Funkin', only + // the player's show. + if(is_fight()) + // Everything on the strumline ignores its alpha, so hide the lot outright. + left.strumline.invisibility = INVISIBILITY_ABSTRACT + + // The bar hangs between the two, above their strums. + var/center_x = 16 + (left_turf.x - right_turf.x) * world.icon_size / 2 + var/center_y = 16 + (left_turf.y - right_turf.y) * world.icon_size / 2 + FNF_STRUM_Y + 44 + healthbar = new(right_turf, center_x, center_y, opponent, challenger) + camera_x = (left_turf.x + right_turf.x) * world.icon_size / 2 + 16 + camera_y = (left_turf.y + right_turf.y) * world.icon_size / 2 + 16 + 36 + pan_cameras() + + preload(challenger) + preload(opponent) + preload_onlookers(right_turf) + // After the listeners are in: the stage plays its intro to them. + stage = new(src, left_turf, right_turf) + if(song.corruption) + corruption = new(src) + + state = FNF_STATE_COUNTDOWN + challenger.visible_message(span_boldnotice("[challenger] and [opponent] square up for a rhythm battle: [song.name]!")) + INVOKE_ASYNC(src, PROC_REF(await_downloads)) + +/** + * Makes sure both singers' games have the song before counting in. The files go over with + * browse_rsc, then a ping goes after them: when it comes back, everything sent before it has + * arrived. Without this a big song can still be downloading when it's meant to start, and starts + * late against the notes. Gives up waiting on someone after about fifteen seconds. + */ +/datum/fnf_battle/proc/await_downloads() + var/list/tracks = get_tracks() + var/waiting = FALSE + for(var/datum/fnf_side/side as anything in sides) + var/client/viewer = side.singer?.client + if(!viewer) + continue + if(!waiting) + healthbar?.announce("Loading...") + waiting = TRUE + for(var/i in 1 to length(tracks)) + var/list/track = tracks[i] + viewer << browse_rsc(file(track[1]), "fnf_[song.id]_[i].ogg") + viewer.browse_queue_flush(300) + if(QDELETED(src) || state != FNF_STATE_COUNTDOWN) + return + begin_countdown() + +/datum/fnf_battle/proc/begin_countdown() + var/countdown_ds = max(4 * crochet, 2000) / 100 + // Line the start up with a tick, so the song starts on exactly the tick it's meant to. + start_time = world.time + CEILING(10 + lead_in + countdown_ds, world.tick_lag) + var/static/list/counts = list("3", "2", "1", "GO!") + for(var/i in 1 to 4) + addtimer(CALLBACK(src, PROC_REF(count_in), i, counts[i]), start_time - world.time - (5 - i) * crochet / 100, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(begin_song)), start_time - world.time, TIMER_DELETE_ME) + tick_timer = addtimer(CALLBACK(src, PROC_REF(tick)), world.tick_lag, TIMER_LOOP|TIMER_STOPPABLE|TIMER_DELETE_ME) + +/// Swings both singers' cameras onto the stage, leaning toward whoever the chart is focused on. +/datum/fnf_battle/proc/pan_cameras(time = 10) + for(var/datum/fnf_side/side as anything in sides) + var/mob/living/singer = side.singer + var/client/viewer = singer?.client + if(!viewer) + continue + var/turf/singer_turf = get_turf(singer) + var/look_x = camera_x + camera_focus * 24 - (singer_turf.x * world.icon_size + 16) + var/look_y = camera_y - (singer_turf.y * world.icon_size + 16) + animate(viewer, pixel_w = look_x, pixel_z = look_y, time = time, easing = SINE_EASING) + panned |= WEAKREF(singer) + +/datum/fnf_battle/proc/reset_cameras() + for(var/datum/weakref/singer_ref as anything in panned) + var/mob/living/singer = singer_ref.resolve() + if(singer?.client) + animate(singer.client, pixel_w = 0, pixel_z = 0, time = 10, easing = SINE_EASING) + panned.Cut() + +/// Three tiles to the challenger's left is the opponent's spot, or as close as there's room. +/// Whether this song is a fistfight rather than a sing-off (Blazin'), and they need to be close. +/datum/fnf_battle/proc/is_fight() + return song.id == "blazin" + +/datum/fnf_battle/proc/find_opponent_spot(turf/right_turf, mob/living/opponent) + // Four tiles apart, so both singers' arrows and scores have room. Nose to nose for a fight. + for(var/distance in (is_fight() ? list(1, 2) : list(4, 3, 5))) + var/turf/spot = locate(right_turf.x - distance, right_turf.y, right_turf.z) + if(!spot) + continue + if(opponent && get_turf(opponent) == spot) + return spot + if(!spot.is_blocked_turf(exclude_mobs = FALSE)) + return spot + return null + +/datum/fnf_battle/proc/summon_opponent(turf/spot) + npc = fnf_summon_opponent(song.opponent_character, spot, song.player_character == "pico" ? /datum/species/experiment/milkie : /datum/species/experiment) + npc.setDir(EAST) + do_sparks(3, FALSE, npc) + npc.visible_message(span_notice("[npc] steps out of nowhere, ready to sing.")) + return npc + +/datum/fnf_battle/proc/count_in(number, text) + if(state == FNF_STATE_OVER) + return + healthbar?.announce(text, number == 4 ? "#ffe066" : "#ffffff") + var/sound_file = number == 4 ? "voidcrew/modules/fnf/sound/countgo.ogg" : "voidcrew/modules/fnf/sound/count[4 - number].ogg" + for(var/mob/listener as anything in listeners) + SEND_SOUND(listener, sound(sound_file, volume = 50 * (listeners[listener] || 1))) + for(var/datum/fnf_side/side as anything in sides) + side.bop(crochet / 100) + +/datum/fnf_battle/proc/begin_song() + if(state != FNF_STATE_COUNTDOWN) + return + state = FNF_STATE_PLAYING + real_start = REALTIMEOFDAY + game_start_ms = (world.time - start_time) * 100 + unpause_song() + +/datum/fnf_battle/proc/tick() + if(state == FNF_STATE_OVER) + return + catch_up_with_music() + var/now = get_song_time() + for(var/datum/fnf_side/side as anything in sides) + side.process_notes(now) + if(state == FNF_STATE_OVER) + return + var/beat = floor(now / crochet) + if(beat > last_beat && beat >= 0) + last_beat = beat + healthbar?.bop() + for(var/datum/fnf_side/side as anything in sides) + side.bop(crochet / 100) + stage?.bop(crochet / 100) + stage?.tick(now) + corruption?.tick(now) + var/list/events = chart["events"] + while(next_event <= length(events)) + var/list/event = events[next_event] + if(event["t"] > now) + break + next_event++ + run_event(event) + if(state == FNF_STATE_PLAYING && now >= end_ms) + finish() + +/// Chart events: the camera turning to whoever's singing, and anyone on stage doing something. +/datum/fnf_battle/proc/run_event(list/event) + if(event["e"] == "FocusCamera") + // 0 is the player, 1 the opponent, 2 the girlfriend in the middle. + var/focus = event["v"] + if(islist(focus)) + var/list/focus_data = focus + focus = focus_data["char"] + camera_focus = focus == 0 ? 1 : (focus == 1 ? -1 : 0) + pan_cameras(6) + return + if(event["e"] != "PlayAnimation") + return + var/list/value = event["v"] + if(islist(value)) + stage?.play_animation(value["target"], value["anim"]) + +/// Pushes the health toward whoever did well (or away from whoever did badly). +/datum/fnf_battle/proc/adjust_health(amount, datum/fnf_side/side) + if(state == FNF_STATE_OVER) + return + // Between two people nobody's run off the bar: it's settled at the end, by the bar and then the score. + var/edge = is_pvp() ? 1 : 0 + health = clamp(health + (side == right ? amount : -amount), edge, FNF_HEALTH_MAX - edge) + healthbar?.update(health) + if(health <= 0) + finish(left, right) + else if(health >= FNF_HEALTH_MAX && !left.is_cpu) + // Against a person, running them off the bar wins outright. The CPU can't be beaten early. + finish(right, left) + +/// Whether it's two people singing, not someone against the CPU. +/datum/fnf_battle/proc/is_pvp() + return !left.is_cpu && !right.is_cpu + +/datum/fnf_battle/proc/forfeit(datum/fnf_side/quitter, reason) + if(state == FNF_STATE_OVER) + return + var/datum/fnf_side/other = quitter == left ? right : left + for(var/mob/listener as anything in listeners) + to_chat(listener, span_warning("[quitter.singer_name] [reason], and forfeits!")) + finish(other, null) + +/** + * Ends the battle. + * + * * winner - who won, or null to go by the health bar and then the score + * * knocked_out - whoever was run off the health bar, who takes it badly + */ +/datum/fnf_battle/proc/finish(datum/fnf_side/winner, datum/fnf_side/knocked_out) + if(state == FNF_STATE_OVER) + return + state = FNF_STATE_OVER + deltimer(tick_timer) + tick_timer = null + if(!winner) + if(health != FNF_HEALTH_MAX / 2) + winner = health > FNF_HEALTH_MAX / 2 ? right : left + else if(left.score != right.score) + winner = left.score > right.score ? left : right + var/datum/fnf_side/loser = winner ? (winner == left ? right : left) : null + + var/list/lines = list("[song.name] ([difficulty])") + lines += winner ? "[winner.singer_name] wins!" : "It's a draw!" + lines += right.get_results() + lines += left.get_results() + var/message = boxed_message(jointext(lines, "
")) + for(var/mob/listener as anything in listeners) + to_chat(listener, message) + healthbar?.announce(winner ? "[winner.singer_name] wins!" : "Draw!", "#ffe066") + + winner?.hey() + reset_cameras() + // The game over goes up before anyone lets go of anything, so the loser's mic stays in their hand. + if(loser?.singer && !QDELETED(loser.singer)) + if(loser == knocked_out && loser.singer.client) + new /datum/fnf_game_over(loser.singer, song, difficulty, loser == right && left.singer == npc, loser.facing, last_miss_kind, null, get_song_time()) + loser.singer.fnf_lose(loser == knocked_out) + for(var/datum/fnf_side/side as anything in sides) + side.unlock() + + // Fade the song out rather than cutting it dead. + for(var/step in 1 to 4) + addtimer(CALLBACK(src, PROC_REF(fade_step), 1 - step / 4), step * 5, TIMER_DELETE_ME) + QDEL_IN(src, 3 SECONDS) + +/datum/fnf_battle/proc/fade_step(multiplier) + for(var/list/track as anything in get_tracks()) + var/channel = track[2] + if(channel_volumes["[channel]"]) + set_channel_volume(channel, multiplier) diff --git a/voidcrew/modules/fnf/corruption.dm b/voidcrew/modules/fnf/corruption.dm new file mode 100644 index 00000000000..b39ad4b4b08 --- /dev/null +++ b/voidcrew/modules/fnf/corruption.dm @@ -0,0 +1,986 @@ +/** + * Corruption, from Carlito049's Corruption+ (a fan extension of Phantom Fear and Pincer's + * Corruption mod): songs where a dark violet corruption eats its singers. + * + * A corruption song says who it starts with and when anyone changes, the way the mod does it: its + * chart names the player, the opponent and whoever's in the girlfriend's place, and its "Change + * Character" events swap them mid-song. Each of the mod's characters is a look and a level of + * corruption here (GLOB.fnf_corruption_cast): Kapi goes from himself to fully corrupted over the + * arcade songs, the corrupted Pico and Kapi fight it off and lose it again, the "bait" versions are + * flashbacks to before. Whoever sings as the player keeps their own look and takes on the level; + * the opponent and whoever's behind them are summoned as the mod's characters. + * + * Corruption spreads as it does in the mod's sprites: the far side first (the arm and leg away + * from the crowd, the tail), then the hands go red, then the near limbs, the body and the head, + * each piece taken over in growing dark violet splotches while every colour drains. Once the + * head's gone, the face glows hot pink: "( • )" eyes turned on their side and a candy-corn grin. + * Clothes it's taken go dark violet, and their bright trims glow hot pink. + * + * Fighting it off doesn't run that backwards. Like the mod's Pico and Kapi breaking free for a + * while, the head comes back first (their hair, then half their face: the near eye), and the near + * hand with it, while the body comes back only slowly and the far side stays taken. + */ + +/** + * The mod's characters: each a look (see opponents.dm; null for nobody, just the speakers), how + * corrupted they are, and anything else about them: "eyes" ("red" instead of pink, "blank" for no + * pupils glowing in them), "wings" ("angel", "demon"), "flying", "peak": how far it had them before + * they started fighting it off (the ones partway free), "face": "manic" for a desperate grin, + * sweating, between notes, and "bar": their colour on the health bar, "seat": what they're sat on in the girlfriend's place (see + * /datum/fnf_stage/proc/set_seat()), "prop": what they have by them (see set_singer_prop()), and + * "facing": "enemy" for someone in the girlfriend's place turned to face the opponent, not the front. + */ +GLOBAL_LIST_INIT(fnf_corruption_cast, list( + // Pico, corrupted from the start of the arcade; the second and third fight back (orange hair and + // a green collar through the dark, then half his face), then it takes him again. + "corruptedpico" = list("bar" = "#1a152a", "look" = "pico", "level" = 1), + "corruptedpico2" = list("bar" = "#1a152a", "look" = "pico", "level" = 0.85, "peak" = 1, "eyes" = "blank"), + "corruptedpico3" = list("bar" = "#1a152a", "look" = "pico", "level" = 0.7, "peak" = 1), + "corruptedpico4" = list("bar" = "#1a152a", "look" = "pico", "level" = 1), + "corruptedpico5" = list("bar" = "#1a152a", "look" = "pico", "level" = 1), + "pico-bait" = list("bar" = "#b7d855", "look" = "pico", "level" = 0), + // Kapi at his arcade machine, taken a little more each time: a red hand, then the far side, then + // half his face, then all of him. + "kapi" = list("bar" = "#76719e", "look" = "kapi", "level" = 0, "prop" = "dance_pad"), + "kapi1" = list("bar" = "#76719e", "look" = "kapi", "level" = 0.08, "prop" = "dance_pad"), + "kapi2" = list("bar" = "#76719e", "look" = "kapi", "level" = 0.25, "prop" = "dance_pad"), + "kapi3" = list("bar" = "#76719e", "look" = "kapi", "level" = 0.45, "prop" = "dance_pad"), + "kapi4" = list("bar" = "#1f1a2f", "look" = "kapi", "level" = 0.65, "prop" = "dance_pad"), + "kapi5" = list("bar" = "#1f1a2f", "look" = "kapi", "level" = 0.85, "eyes" = "blank", "prop" = "dance_pad"), + "kapi6" = list("bar" = "#1f1a2f", "look" = "kapi", "level" = 1, "prop" = "dance_pad"), + "kapi-bait" = list("bar" = "#76719e", "look" = "kapi", "level" = 0, "prop" = "dance_pad"), + // Kapi corrupted, hunting: he fights it (a grey ear and his blue coming back), then his eyes go red. + "corruptedkapi" = list("bar" = "#1f1a2f", "look" = "kapi", "level" = 1, "prop" = "dance_pad_corrupt"), + "corruptedkapi2" = list("bar" = "#1f1a2f", "look" = "kapi", "level" = 1, "prop" = "dance_pad_corrupt"), + "corruptedkapi3" = list("bar" = "#1f1a2f", "look" = "kapi", "level" = 0.75, "peak" = 1, "prop" = "dance_pad_corrupt"), + "corruptedkapi35" = list("bar" = "#1f1a2f", "look" = "kapi", "level" = 0.85, "peak" = 1, "prop" = "dance_pad_corrupt"), + "corruptedkapi4" = list("bar" = "#1f1a2f", "look" = "kapi", "level" = 1, "eyes" = "red", "prop" = "dance_pad_corrupt"), + "morabait" = list("bar" = "#1f1a2f", "look" = "mora", "level" = 0), + // Skarlet Bunny, with the corruption at her boots from the start, then in patches all over, then + // nearly all of her, though her face is still her own. + "skarlet1" = list("bar" = "#ff006e", "look" = "skarlet", "level" = 0.1), + "skarlet2" = list("bar" = "#ff006e", "look" = "skarlet", "level" = 0.35), + "skarlet3" = list("bar" = "#1a152a", "look" = "skarlet", "level" = 0.62), + // Marble, behind the speaker, long gone. + "corruptedmarble" = list("bar" = "#1f1a2f", "look" = "marble", "level" = 1, "prop" = "speaker_small"), + "corruptedmarble2" = list("bar" = "#1f1a2f", "look" = "marble", "level" = 0.9, "peak" = 1, "eyes" = "blank", "prop" = "speaker_small"), + "corruptedmarble3" = list("bar" = "#1f1a2f", "look" = "marble", "level" = 1, "prop" = "speaker_small"), + // Carol in the church ruins, then flying, half angel and half demon, then all demon. + "carol1" = list("bar" = "#666699", "look" = "carol", "level" = 0.05), + "carol2" = list("bar" = "#666699", "look" = "carol", "level" = 0.15), + "carolBait" = list("bar" = "#666699", "look" = "carol", "level" = 0), + "Acarol3" = list("bar" = "#ff7900", "look" = "carol", "level" = 0.6, "wings" = "angel", "flying" = TRUE), + "Acarol4" = list("bar" = "#ff7900", "look" = "carol", "level" = 0.6, "wings" = "angel", "flying" = TRUE, "face" = "manic"), + "Acarol5" = list("bar" = "#1a152a", "look" = "carol", "level" = 0.9, "wings" = "demon", "eyes" = "blank", "flying" = TRUE), + // Girlfriend, corrupted, singing and flying; Boyfriend corrupted on the speakers. + "corruptedgirlfriend" = list("bar" = "#1f1a2f", "look" = "gf", "level" = 1), + "corruptedgirlfriendBait" = list("bar" = "#a5004d", "look" = "gf", "level" = 0), + "corruptedgirlfriendflying" = list("bar" = "#1f1a2f", "look" = "gf", "level" = 0.9, "flying" = TRUE), + "corruptedgirlfriendflying2" = list("bar" = "#1f1a2f", "look" = "gf", "level" = 1, "flying" = TRUE), + "EVILspeakersGF" = list("bar" = "#1f1a2f", "look" = "gf", "level" = 1, "seat" = "speakers_evil"), + "EVILspeakersBF" = list("bar" = "#1f1a2f", "look" = "bf", "level" = 1, "seat" = "speakers_evil"), + "EVILspeakersBFbait" = list("bar" = "#31b0d1", "look" = "bf", "level" = 0, "seat" = "speakers"), + "GFPcorruptedBF" = list("bar" = "#1f1a2f", "look" = "bf", "level" = 1, "facing" = "enemy"), + "corruptedbf2" = list("bar" = "#1f1a2f", "look" = "bf", "level" = 1), + // Nobody: just the speakers, or an empty stage. + "speakers" = list("bar" = "#3d3d65", "look" = null, "seat" = "speakers"), + "EVILspeakers" = list("bar" = "#3d3d65", "look" = null, "seat" = "speakers_evil"), + "nocharacter" = list("bar" = "#000000", "look" = null, "hidden" = TRUE), +)) + +/** + * How each song's own script drains the player whenever the opponent hits a note: list of list(from + * step, to step or null, only while health is over this, how much), in Funkin's units (the bar is 2). + */ +GLOBAL_LIST_INIT(fnf_corruption_drains, list( + "claws" = list(list(0, null, 0.1, 0.016)), + "vile-vice" = list(list(0, null, 0.1, 0.025)), + "feral" = list(list(0, null, 0.25, 0.024)), + "broken-wires" = list(list(0, 509, 0.1, 0.025), list(509, null, 1, 0.02)), + "cursed" = list(list(0, null, 0.1, 0.005)), + "purification" = list(list(0, 1408, 0.1, 0.009), list(1408, 2432, 0.1, 0.02)), +)) + +// The body. + +/mob/living/carbon + /// Corruption on this body, kept on the mob so a rebuilt rig (a species change, or one that isn't + /// made yet) picks it up: list(level, peak, eyes), or null when clean. + var/list/fnf_corruption_state + +/// Corrupts this body (see /datum/limb_rig/proc/set_corruption()), rig or no rig yet. +/mob/living/carbon/proc/fnf_set_corruption(level, eyes, peak) + if(limb_rig) + limb_rig.set_corruption(level, eyes, peak) + return + level = clamp(level, 0, 1) + fnf_corruption_state = level ? list(level, max(fnf_corruption_state?[2] || 0, level, peak || 0), eyes || "pink") : null + +/datum/limb_rig/New(mob/living/carbon/owner) + . = ..() + var/list/state = owner.fnf_corruption_state + if(state) + corruption = state[1] + corruption_peak = state[2] + corruption_eyes = state[3] + refresh_corruption() + +/datum/limb_rig + /// How far corruption has taken this body, 0 to 1. + var/corruption = 0 + /// The furthest it's got since the body was last clean: anything less is being fought off. + var/corruption_peak = 0 + /// The colour its corrupted eyes glow: "pink", or "red". + var/corruption_eyes = "pink" + /// What corruption's drawn on each piece, by piece. + var/list/corruption_images + +/** + * Corrupts the body this far (0 to 1), its eyes glowing pink or red once its face is taken. With a + * peak, it's fighting its way back down from there. + */ +/datum/limb_rig/proc/set_corruption(level, eyes = "pink", peak) + corruption = clamp(level, 0, 1) + corruption_peak = corruption ? max(corruption_peak, corruption, peak || 0) : 0 + corruption_eyes = eyes || "pink" + owner.fnf_corruption_state = corruption ? list(corruption, corruption_peak, corruption_eyes) : null + refresh_corruption() + refresh_fnf_face() + +/datum/limb_rig/proc/refresh_corruption() + for(var/atom/movable/piece as anything in corruption_images) + piece.cut_overlay(corruption_images[piece]) + piece.color = null + corruption_images = null + set_sprite_saturation(1 - corruption * 0.7) + if(!corruption) + return + corruption_images = list() + var/far_side = get_facing() == EAST ? "l" : (get_facing() == WEST ? "r" : null) + var/list/drained = color_matrix_saturation(1 - corruption * 0.7) + var/list/tainted_clothes + var/list/coated_flesh + // A taken face sits on a head that's all coat, whatever the splotches are doing. + var/face_taken = is_face_corrupted() && !is_face_half_freed() + // Until then, never so dark the face can't be made out on it. + var/head_most = face_taken ? 1 : 0.5 + for(var/list/piece_info as anything in get_corruption_pieces()) + var/atom/movable/piece = piece_info[1] + var/part_id = piece_info[2] + var/kind = piece_info[3] + if(!piece) + continue + var/list/images = list() + var/taken = get_corruption_of(part_id, far_side) + if(part_id == RIG_HEAD) + taken = face_taken ? 1 : min(taken, head_most) + var/amount = round(taken * 8) + switch(kind) + if("clothes") + // Clothes go over to dark violet as it takes them, their bright trims to hot pink. + tainted_clothes = tainted_clothes || get_tainted_clothes_matrix() + piece.color = fnf_blend_colour_matrices(drained, tainted_clothes, taken) + if("flesh") + // A species' own head, hair, tail and wings (drawn as they draw them): into the coat. + coated_flesh = coated_flesh || get_coated_flesh_matrix() + piece.color = fnf_blend_colour_matrices(drained, coated_flesh, taken) + else + // The body's own sprite (drained, see set_sprite_saturation()): the coat, baked to + // each piece's shape, in splotches, over it. + if(amount) + var/image/coat = image('voidcrew/modules/fnf/icons/corruption_coats.dmi', "coat_[kind]_[amount]") + coat.color = FNF_CORRUPTION_COLOUR + coat.alpha = 235 + coat.pixel_w = -16 + coat.layer = FLOAT_LAYER + 0.01 + images += coat + // The Experiment's orange tail tip glows hot pink as it's taken. + var/tip = part_id == RIG_TAIL && kind != "flesh" ? get_corruption_tail_tip() : null + if(tip && amount) + var/image/glow = image('voidcrew/modules/fnf/icons/corruption_64.dmi', tip) + glow.color = FNF_CORRUPTION_TRIM + glow.alpha = amount * 32 - 1 + glow.appearance_flags = RESET_COLOR + glow.pixel_w = -16 + glow.layer = FLOAT_LAYER + 0.03 + images += glow + var/hands = piece_info[4] + // Blood red hands, until the hand's fought free. + if(hands && corruption >= 0.2 && get_corruption_fought(part_id, far_side) < 0.5) + var/image/red = image('voidcrew/modules/fnf/icons/corruption_64.dmi', hands) + red.color = FNF_CORRUPTION_HANDS + // Blood red whatever's drained the rest. + red.appearance_flags = RESET_COLOR + red.pixel_w = -16 + red.layer = FLOAT_LAYER + 0.02 + images += red + piece.add_overlay(images) + corruption_images[piece] = images + +/// A species' own flesh taken by corruption: its dark violet, keeping just enough shading to read. +/datum/limb_rig/proc/get_coated_flesh_matrix() + var/list/violet = rgb2num(FNF_CORRUPTION_COLOUR) + var/list/weights = list(0.3, 0.59, 0.11) + . = new /list(12) + for(var/channel in 1 to 3) + var/shade = violet[channel] / 255 + for(var/source in 1 to 3) + .[(source - 1) * 3 + channel] = shade * weights[source] * 0.9 + .[9 + channel] = shade * 0.55 + +/// Blends two colour matrices (3 by 4 or 4 by 5) this far from the first to the second. +/proc/fnf_blend_colour_matrices(list/start_matrix, list/end_matrix, amount) + start_matrix = fnf_full_colour_matrix(start_matrix) + end_matrix = fnf_full_colour_matrix(end_matrix) + amount = clamp(amount, 0, 1) + . = new /list(20) + for(var/i in 1 to 20) + .[i] = start_matrix[i] + (end_matrix[i] - start_matrix[i]) * amount + +/// A colour matrix as the full 4 by 5 (rows for red, green, blue, alpha, then the constants). +/proc/fnf_full_colour_matrix(list/matrix) + if(length(matrix) == 20) + return matrix + return list( + matrix[1], matrix[2], matrix[3], 0, + matrix[4], matrix[5], matrix[6], 0, + matrix[7], matrix[8], matrix[9], 0, + 0, 0, 0, 1, + matrix[10], matrix[11], matrix[12], 0, + ) + +/** + * The colour matrix for clothes corruption's taken whole: their main colour turns its dark violet, + * anything darker goes on down to black, and anything brighter (trims, stripes, buttons, highlights) + * climbs to hot pink. + */ +/datum/limb_rig/proc/get_tainted_clothes_matrix() + var/list/main = rgb2num(get_main_clothes_colour()) + // Dark clothes stay dark, only what really stands out on them glowing: going by a dark main + // colour itself would send every shade on them pink. + var/main_brightness = max((main[1] * 0.3 + main[2] * 0.59 + main[3] * 0.11) / 255, FNF_CORRUPTION_DARKEST_MAIN) + var/list/violet = rgb2num(FNF_CORRUPTION_CLOTHES_COLOUR) + var/list/trim = rgb2num(FNF_CORRUPTION_TRIM) + var/list/weights = list(0.3, 0.59, 0.11) + . = new /list(12) + for(var/channel in 1 to 3) + // How fast this channel climbs from violet to pink with brightness. + var/climb = (trim[channel] - violet[channel]) / 255 / FNF_CORRUPTION_TRIM_RISE + for(var/source in 1 to 3) + .[(source - 1) * 3 + channel] = climb * weights[source] + .[9 + channel] = violet[channel] / 255 - climb * main_brightness + +/// What colour the owner's clothes mostly are: their outer suit's, or else their jumpsuit's. +/datum/limb_rig/proc/get_main_clothes_colour() + var/mob/living/carbon/human/wearer = owner + if(istype(wearer)) + for(var/obj/item/worn in list(wearer.wear_suit, wearer.w_uniform)) + var/list/colours = worn.greyscale_colors ? splittext(worn.greyscale_colors, "#") : null + if(length(colours) > 1 && length(colours[2]) == 6) + return "#[colours[2]]" + if(istext(worn.color)) + return worn.color + if(worn) + return get_icon_main_colour(worn.icon, worn.icon_state) + return "#737373" + +/// The colour most of an icon is (its outline and anything nearly black aside), worked out once per icon. +/proc/get_icon_main_colour(icon_file, icon_state) + var/static/list/found = list() + var/key = "[icon_file]-[icon_state]" + if(found[key]) + return found[key] + var/icon/sprite = icon(icon_file, icon_state, SOUTH, 1) + var/list/counts = list() + for(var/x in 1 to sprite.Width()) + for(var/y in 1 to sprite.Height()) + var/pixel = sprite.GetPixel(x, y) + if(!pixel) + continue + var/list/rgb = rgb2num(pixel) + if(length(rgb) > 3 && rgb[4] < 128) + continue + if(rgb[1] + rgb[2] + rgb[3] < 60) + continue + var/colour = copytext(pixel, 1, 8) + counts[colour] = (counts[colour] || 0) + 1 + var/best = "#737373" + var/most = 0 + for(var/colour in counts) + if(counts[colour] > most) + most = counts[colour] + best = colour + found[key] = best + return best + +/** + * How far corruption has taken one piece of the body, 0 to 1: the far side first, then the near + * limbs, the body, and the head last. Whatever's been fought off since its peak is taken back out. + */ +/datum/limb_rig/proc/get_corruption_of(part_id, far_side) + return get_corruption_spread(part_id, far_side, corruption_peak) * (1 - get_corruption_fought(part_id, far_side)) + +/** + * How much of one piece has been fought free, 0 to 1, going by how far the body's come back from + * its peak: the head first, the near hand and the body next, the far side last. + */ +/datum/limb_rig/proc/get_corruption_fought(part_id, far_side) + var/fought = corruption_peak - corruption + if(fought <= 0) + return 0 + var/delay + switch(part_id) + if(RIG_HEAD) + delay = 0 + if(RIG_CHEST) + delay = 0.2 + if(RIG_TAIL, "wings") + delay = 0.3 + else + var/side = copytext(part_id, 1, 2) + var/is_arm = findtext(part_id, "arm") + if(!far_side) + delay = 0.2 + else if(side == far_side) + delay = is_arm ? 0.4 : 0.35 + else if(findtext(part_id, "forearm")) + delay = 0.03 + else + delay = is_arm ? 0.2 : 0.25 + return clamp((fought - delay) / FNF_CORRUPTION_FIGHT, 0, 1) + +/// How far corruption at this level spreads over one piece, 0 to 1. +/datum/limb_rig/proc/get_corruption_spread(part_id, far_side, level) + var/start + switch(part_id) + if(RIG_CHEST) + start = 0.3 + if(RIG_HEAD) + start = 0.5 + if(RIG_TAIL) + start = 0.05 + if("wings") + start = 0.1 + else + var/side = copytext(part_id, 1, 2) + var/is_arm = findtext(part_id, "arm") + if(!far_side) + start = 0.15 + else if(side == far_side) + start = is_arm ? 0 : 0.05 + else + start = is_arm ? 0.35 : 0.25 + return clamp((level - start) / FNF_CORRUPTION_SPAN, 0, 1) + +/// The mask of the tail's own bright tip, to glow once it's taken, if it has one. +/datum/limb_rig/proc/get_corruption_tail_tip() + return null + +/datum/limb_rig/sprites/get_corruption_tail_tip() + return fnf_face_set ? "tail_[fnf_face_set]" : null + +/** + * Every piece corruption draws on: list(piece, piece id, how it's taken, its hand mask or null). How + * it's taken is "clothes", "flesh" (drawn by the species itself), or the coat state for a body sprite. + */ +/datum/limb_rig/proc/get_corruption_pieces() + return list() + +/datum/limb_rig/sprites/get_corruption_pieces() + . = list() + var/list/zones = get_rig_piece_zones() + for(var/part_id in parts) + // A humanoid's pieces show a body sprite only where a limb is. + if(istype(src, /datum/limb_rig/sprites/humanoid) && !zones[part_id]) + continue + var/hands = findtext(part_id, "_forearm") ? "hands_[get_corruption_sheet()]_[part_id]" : null + . += list(list(parts[part_id], part_id, "[get_corruption_sheet()]_[get_corruption_sprite_state(part_id)]", hands)) + for(var/part_id in cloth_parts) + . += list(list(cloth_parts[part_id], part_id, "clothes", null)) + for(var/side in shoe_parts) + . += list(list(shoe_parts[side], "[side]_foot", "clothes", null)) + +/datum/limb_rig/sprites/humanoid/get_corruption_pieces() + . = ..() + // The head's piece carries the species' own head, hair and all: flesh, not clothes. + for(var/list/piece_info as anything in .) + if(piece_info[1] == cloth_parts[RIG_HEAD]) + piece_info[3] = "flesh" + . += list(list(wings_part, "wings", "flesh", null)) + . += list(list(tail_part, RIG_TAIL, "flesh", null)) + +/// Which body sprites the coat's baked for (see icons/corruption_coats.dmi). +/datum/limb_rig/sprites/proc/get_corruption_sheet() + return fnf_face_set || "humanoid" + +/// Which of them a piece shows. +/datum/limb_rig/sprites/proc/get_corruption_sprite_state(part_id) + return part_id + +/datum/limb_rig/sprites/humanoid/get_corruption_sprite_state(part_id) + var/mob/living/carbon/human/human_owner = owner + if(part_id == RIG_CHEST && istype(human_owner) && human_owner.physique == FEMALE) + return "chest_f" + return part_id + +/// Which side's far changes with facing: corruption goes with it. +/datum/limb_rig/refresh_facing() + . = ..() + if(corruption) + refresh_corruption() + +// Singing it. + +/** + * A corruption song's cast, played out: the player taking on whoever the song says they are now, + * the summoned opponent and the one behind them changing with the song. + */ +/datum/fnf_corruption + var/datum/fnf_battle/battle + /// The changes still to come: list(list(ms, role, character), ...), in order. + var/list/changes + var/next_change = 1 + /// The song's overlays, flashes, animations and shakes (see /datum/fnf_song/var/overlay_events), in order. + var/list/overlay_events + var/next_overlay = 1 + /// Everyone it's touched, to put right after. + var/list/mob/living/carbon/touched = list() + /// Who each role ("player", "opponent", "gf") is now, as the mod's cast: kept to, in case a + /// singer's body is rebuilt, or wasn't ready yet, when they were made it. + var/list/current_cast = list() + /// When the cast was last checked on. + var/next_check = 0 + /// Health each of the opponent's notes takes, as set by the song's "HealthDrainCustom" (Funkin's + /// units, the bar being 2), on top of its own drain (see GLOB.fnf_corruption_drains). + var/opponent_drain = 0 + /// And health each of the player's notes gives back. + var/player_bonus = 0 + /// What the song lays over the stage (see get_layer()), by name. + var/list/obj/effect/abstract/fnf_hud/layers = list() + /// Who's drawn as a silhouette right now (the "Bad Apple" events), and in what colour. + var/list/mob/silhouetted = list() + /// Which silhouettes are up: "white" (black figures on white), "black" (white on black), + /// "colour" (each in their colour, on black), or null. + var/apple + /// Words the song puts on screen (see show_subtitle()). + var/obj/effect/abstract/fnf_hud/text/subtitle + /// How much of the stage each listener's map window really shows, in pixels, by listener's ref: + /// list(width, height). A zoomed-in map in a small window shows less than its whole view. + var/list/visible_sizes = list() + /// When the listeners' windows are next measured. + var/next_measure = 0 + +/datum/fnf_corruption/New(datum/fnf_battle/battle) + src.battle = battle + var/datum/fnf_song/song = battle.song + changes = song.character_changes || list() + overlay_events = song.overlay_events || list() + apply("player", song.legacy_cast["player"]) + apply("opponent", song.legacy_cast["opponent"]) + apply("gf", song.legacy_cast["gf"]) + make_screens() + +/datum/fnf_corruption/Destroy() + for(var/mob/living/carbon/singer as anything in touched) + if(QDELETED(singer)) + continue + singer.fnf_face_mood = null + singer.fnf_set_corruption(0) + singer.alpha = initial(singer.alpha) + if(HAS_TRAIT_FROM(singer, TRAIT_MOVE_FLOATING, FNF_BATTLE_TRAIT)) + REMOVE_TRAIT(singer, TRAIT_MOVE_FLOATING, FNF_BATTLE_TRAIT) + touched.Cut() + set_apple(null, 0) + for(var/name in layers) + battle?.healthbar?.vis_contents -= layers[name] + QDEL_LIST_ASSOC_VAL(layers) + battle?.healthbar?.vis_contents -= subtitle + QDEL_NULL(subtitle) + battle = null + return ..() + +/datum/fnf_corruption/proc/tick(now) + if(world.time >= next_check) + next_check = world.time + 5 + keep_cast() + if(world.time >= next_measure) + next_measure = world.time + 5 SECONDS + for(var/mob/listener as anything in battle.listeners) + if(listener.client) + INVOKE_ASYNC(src, PROC_REF(measure_view), listener) + while(next_change <= length(changes)) + var/list/change = changes[next_change] + if(change[1] > now) + break + next_change++ + apply(change[2], change[3]) + while(next_overlay <= length(overlay_events)) + var/list/event = overlay_events[next_overlay] + if(event[1] > now) + break + next_overlay++ + show(event[2], event[3], event[4]) + +/// In Skarlet's songs, her tainted frame round the health bar. +/datum/fnf_corruption/proc/make_screens() + var/obj/effect/abstract/fnf_hud/healthbar/bar = battle.healthbar + if(!bar || battle.song.note_skin != "skarlet") + return + bar.set_frame('voidcrew/modules/fnf/icons/fnf_healthbar_tainted.dmi') + +/** + * One of the things the song lays over the stage, as the mod lays them over its screen: over the + * world and under the arrows, centred where the singers' cameras look, and sized to cover their + * views (with room for the camera's sway between the singers). + * - "overlay", "flash", "dark": one of the mod's overlays (320 by 180), across the whole width of + * the view so no words are cut off; "_top" and "_bottom" carry its edges on above and below. + * - "light", "black": a flash of colour, or the dark, over everything. + * - "backdrop": the stage blacked or whited out behind whoever's on it, for the silhouettes. + */ +/datum/fnf_corruption/proc/get_layer(name) + var/obj/effect/abstract/fnf_hud/healthbar/bar = battle.healthbar + if(!bar) + return null + var/obj/effect/abstract/fnf_hud/layer_obj = layers[name] + if(!layer_obj) + layer_obj = new + layer_obj.alpha = 0 + var/is_fill = (name in list("light", "black", "backdrop")) + if(is_fill) + layer_obj.icon_state = "bar" + layer_obj.color = "#000000" + layer_obj.layer = ABOVE_ALL_MOB_LAYER + 0.004 + else + layer_obj.icon = 'voidcrew/modules/fnf/icons/fnf_corruption_overlays.dmi' + layer_obj.layer = ABOVE_ALL_MOB_LAYER + (findtext(name, "flash") ? 0.007 : (findtext(name, "dark") ? 0.006 : 0.005)) + if(name == "backdrop") + // Down among the singers, just under them. + layer_obj.vis_flags = NONE + SET_PLANE_EXPLICIT(layer_obj, GAME_PLANE, bar) + layer_obj.layer = MOB_LAYER - 0.01 + bar.vis_contents += layer_obj + layers[name] = layer_obj + fit_layer(layer_obj, name) + return layer_obj + +/// Measures how much of the stage one listener's map window shows (see visible_sizes). +/datum/fnf_corruption/proc/measure_view(mob/listener) + var/client/viewer = listener.client + var/list/size = getviewsize(viewer.view) + var/width = size[1] * ICON_SIZE_X + var/height = size[2] * ICON_SIZE_Y + // Stretched to fit (no zoom), the whole view's in the window. Zoomed, only what fits. + var/zoom = viewer.view_size?.zoom + if(zoom) + var/list/window = splittext(winget(viewer, "mapwindow.map", "size"), "x") + if(length(window) == 2 && text2num(window[1]) && text2num(window[2])) + width = min(width, text2num(window[1]) / zoom) + height = min(height, text2num(window[2]) / zoom) + if(QDELETED(src) || !battle) + return + // A singer's screen is zoomed in on the stage (see /datum/fnf_side/proc/zoom_screen()). + for(var/datum/fnf_side/side as anything in battle.sides) + if(side.singer == listener && side.zoomed) + width /= FNF_ZOOM + height /= FNF_ZOOM + var/list/old = visible_sizes[REF(listener)] + if(old && old[1] == width && old[2] == height) + return + visible_sizes[REF(listener)] = list(width, height) + for(var/name in layers) + fit_layer(layers[name], name) + +/// Sizes and places one of the song's layers (see get_layer()) for the views it's seen in now. +/datum/fnf_corruption/proc/fit_layer(obj/effect/abstract/fnf_hud/layer_obj, name) + var/obj/effect/abstract/fnf_hud/healthbar/bar = battle.healthbar + // Fills cover the biggest view; overlays fit in the smallest window, words and all. + var/view_width = 15 * ICON_SIZE_X + var/view_height = 15 * ICON_SIZE_Y + var/seen_width + var/seen_height + for(var/mob/listener as anything in battle.listeners) + if(!listener.client) + continue + var/list/size = getviewsize(listener.client.view) + view_width = max(view_width, size[1] * ICON_SIZE_X) + view_height = max(view_height, size[2] * ICON_SIZE_Y) + var/list/seen = visible_sizes[REF(listener)] || list(size[1] * ICON_SIZE_X / FNF_ZOOM, size[2] * ICON_SIZE_Y / FNF_ZOOM) + seen_width = isnull(seen_width) ? seen[1] : min(seen_width, seen[1]) + seen_height = isnull(seen_height) ? seen[2] : min(seen_height, seen[2]) + seen_width = seen_width || view_width + seen_height = seen_height || view_height + // The cameras sway 24 pixels either way between the singers. + view_width += 48 + view_height += 16 + var/center_x = battle.camera_x - bar.x * ICON_SIZE_X + var/center_y = battle.camera_y - bar.y * ICON_SIZE_Y + if(layer_obj.icon_state == "bar" || (name in list("light", "black", "backdrop"))) + layer_obj.pixel_w = center_x - 16 + layer_obj.pixel_z = center_y - 16 + layer_obj.transform = matrix((view_width + 64) / ICON_SIZE_X, 0, 0, 0, (view_height + 64) / ICON_SIZE_Y, 0) + return + layer_obj.pixel_w = center_x - 160 + layer_obj.pixel_z = center_y - 90 + // Right across the smallest window, edge to edge (with the zoom on a singer's screen taken into + // account, its words fit): its edges carry on above and below to the top and bottom. + // The cameras lean 24 pixels either way, so it reaches that much further. + var/scale = (seen_width + 48) / 320 + var/part = findtext(name, "_top") ? "top" : (findtext(name, "_bottom") ? "bottom" : null) + if(!part) + layer_obj.transform = matrix(scale, 0, 0, 0, scale, 0) + return + // A pixel of overlap, so no seam. + var/strip = max((view_height - 180 * scale) / 2, 0) + 2 + var/offset = (180 * scale + strip) / 2 - 1 + layer_obj.transform = matrix(scale, 0, 0, 0, strip / 180, part == "top" ? offset : -offset) + +/** + * The mod's screen events, as it plays them: + * - "overlay": an overlay fades in over three quarters of a second (value 1) or back out (0) + * - "flash": an overlay up for its value in seconds, then quickly gone + * - "light": the screen flashes a colour (the image), fading over the value in seconds + * - "black": the screen fades to black over the value in seconds (image "1"), or comes back ("0") + * - "apple": silhouettes (see set_apple()), the image saying which, over the value in seconds + * - "blammed": the lights go down and everyone's lit a colour (the value, 1 to 5), or back up (0) + * - "anim": someone on stage acts something out (the image), the value saying who + * - "shake": the screen shakes for the value in seconds + * - "drain", "health_drain": health taken or given (see drain()) + */ +/datum/fnf_corruption/proc/show(kind, image_name, value) + switch(kind) + if("anim") + act_out(image_name, value) + if("shake") + for(var/mob/listener as anything in battle.listeners) + shake_camera(listener, max(round(value * 10), 1), 0.5) + if("drain") + drain(value) + if("health_drain") + opponent_drain = value + player_bonus = text2num(image_name) || 0 + if("overlay", "flash") + var/static/list/images = icon_states('voidcrew/modules/fnf/icons/fnf_corruption_overlays.dmi') + // Missing ones (the mod names a "yourcalls" it doesn't have) are skipped, as the mod skips them. + if(!(image_name in images)) + return + // The overlay itself, and its edges carried on above and below it. + for(var/part in list("", "_top", "_bottom")) + var/obj/effect/abstract/fnf_hud/layer_obj = get_layer("[kind][part]") + if(!layer_obj) + return + if(kind == "flash") + layer_obj.icon_state = "[image_name][part]" + layer_obj.alpha = 255 + animate(layer_obj, alpha = 255, time = max(value, 0.1) * 10) + animate(alpha = 0, time = 3) + else if(value) + layer_obj.icon_state = "[image_name][part]" + animate(layer_obj, alpha = 255, time = 7.5, easing = QUAD_EASING) + else if(layer_obj.icon_state == "[image_name][part]") + animate(layer_obj, alpha = 0, time = 7.5, easing = QUAD_EASING) + if("light") + var/obj/effect/abstract/fnf_hud/layer_obj = get_layer("light") + if(layer_obj) + layer_obj.color = image_name + layer_obj.alpha = 255 + animate(layer_obj, alpha = 0, time = max(value, 0.1) * 10) + if("black") + var/obj/effect/abstract/fnf_hud/layer_obj = get_layer("black") + if(layer_obj) + if(image_name == "1") + animate(layer_obj, alpha = 255, time = max(value, 0.1) * 10, easing = QUAD_EASING) + else + animate(layer_obj, alpha = 0, time = 0) + if("apple") + var/mode = image_name + if(mode == "toggle-black") + mode = apple == "black" ? null : "black" + else if(mode == "toggle-colour") + mode = apple == "colour" ? null : "colour" + else if(mode == "off") + mode = null + set_apple(mode, value) + if("subtitle") + show_subtitle(image_name, value) + if("alt_idle") + // Purification's Girlfriend, once Boyfriend's swooped in to take over: the corruption eases + // off her a little, and her eyes go blank. + var/list/cast = current_cast["player"] + if(image_name != "bf" || !cast || value != "-alt") + return + var/list/eased = cast.Copy() + eased["peak"] = max(cast["peak"] || 0, cast["level"] || 0) + eased["level"] = max((cast["level"] || 0) - 0.06, 0) + eased["face"] = "blank" + current_cast["player"] = eased + corrupt(get_singer("player"), eased) + // And she just hangs there, twitching, while he sings. + battle.right?.hanging = TRUE + if("blammed") + var/static/list/colours = list("#31a2fd", "#31fd8c", "#f794f7", "#f96d63", "#fba633") + set_apple(value ? "blammed" : null, 1, colours[clamp(value, 1, 5)]) + +/** + * The mod's "Bad Apple" moments: the stage blacked or whited out behind everyone on it, and them + * drawn as flat silhouettes against it: black on white, white on black, or each in their own colour. + * And its "Blammed Lights": the stage dark, and everyone lit one colour. + */ +/datum/fnf_corruption/proc/set_apple(mode, seconds, light_colour) + for(var/mob/living/figure as anything in silhouetted) + if(!QDELETED(figure)) + figure.remove_atom_colour(ADMIN_COLOUR_PRIORITY, silhouetted[figure]) + silhouetted.Cut() + apple = mode + if(!battle) + return + battle.healthbar?.set_portrait_tints(null, null) + var/obj/effect/abstract/fnf_hud/backdrop = get_layer("backdrop") + if(backdrop) + if(mode) + backdrop.color = mode == "white" ? "#ffffff" : "#000000" + animate(backdrop, alpha = 255, time = max(seconds, 0.1) * 10) + else + animate(backdrop, alpha = 0, time = max(seconds, 0.1) * 10) + if(!mode) + return + // Each in their colour on the health bar (the girlfriend in hers). + var/list/figures = list() + if(battle.left?.singer) + figures[battle.left.singer] = "#e0453a" + if(battle.right?.singer) + figures[battle.right.singer] = "#5de83a" + if(battle.stage?.girlfriend) + figures[battle.stage.girlfriend] = "#a5004d" + var/left_tint + var/right_tint + for(var/mob/living/figure in figures) + if(QDELETED(figure)) + continue + var/list/rgb = rgb2num(mode == "blammed" ? light_colour : figures[figure]) + var/silhouette + switch(mode) + if("white") + silhouette = "#000000" + if("black") + silhouette = list(0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1) + if("blammed") + // Lit, not flattened: the colour over their own shading. + silhouette = light_colour + else + silhouette = list(0, 0, 0, 0, 0, 0, 0, 0, 0, rgb[1] / 255, rgb[2] / 255, rgb[3] / 255) + figure.add_atom_colour(silhouette, ADMIN_COLOUR_PRIORITY) + silhouetted[figure] = silhouette + // Their heads on the health bar go with them. + if(figure == battle.left?.singer) + left_tint = silhouette + else if(figure == battle.right?.singer) + right_tint = silhouette + battle.healthbar?.set_portrait_tints(left_tint, right_tint) + +/// Words on screen under the stage, in a colour (or none, to clear them). +/datum/fnf_corruption/proc/show_subtitle(text, colour) + var/obj/effect/abstract/fnf_hud/healthbar/bar = battle.healthbar + if(!bar) + return + if(!subtitle) + subtitle = new + subtitle.maptext_width = 256 + subtitle.maptext_x = -112 + subtitle.layer = ABOVE_ALL_MOB_LAYER + 0.05 + subtitle.pixel_w = bar.center_x - 16 + subtitle.pixel_z = bar.center_y - FNF_STRUM_Y - 44 - 30 + bar.vis_contents += subtitle + subtitle.maptext = text ? MAPTEXT("[html_encode(text)]") : null + +/// Someone acting out one of the mod's animations, the way the stage acts out Funkin's own. +/datum/fnf_corruption/proc/act_out(animation, who) + var/static/list/roles = list("bf" = "bf", "boyfriend" = "bf", "0" = "bf", "dad" = "dad", "1" = "dad", "gf" = "gf", "girlfriend" = "gf", "2" = "gf") + battle.stage?.play_animation(roles[lowertext(who)], lowertext(animation)) + +/** + * A miss in Skarlet's songs: the dark comes down over everything at once and the music drops out, + * then both come back over a second or two. + */ +/datum/fnf_corruption/proc/player_missed() + if(battle.song.note_skin != "skarlet") + return + for(var/part in list("", "_top", "_bottom")) + var/obj/effect/abstract/fnf_hud/layer_obj = get_layer("dark[part]") + if(!layer_obj) + break + layer_obj.icon_state = "overlayskarlet[part]" + animate(layer_obj, alpha = 255, time = 1, easing = QUAD_EASING|EASE_OUT) + animate(alpha = 255, time = 10) + animate(alpha = 0, time = 10) + for(var/channel in list(battle.inst_channel, battle.left_voice_channel)) + battle.set_channel_volume(channel, 0) + for(var/step in 1 to 5) + addtimer(CALLBACK(battle, TYPE_PROC_REF(/datum/fnf_battle, set_channel_volume), channel, step / 5), 5 + step * 3, TIMER_DELETE_ME) + +/** + * The opponent hitting a note, in a song whose script drains the player for it (the mod's songs push + * back hard): whatever its own script takes, then any drain its events have set. Funkin' counts + * every step's piece of a held note as a note of its own, so a held one drains on all the way + * through (length in milliseconds). + */ +/datum/fnf_corruption/proc/opponent_hit(length = 0) + opponent_piece() + var/step_ms = battle.crochet / 4 + for(var/piece in 1 to floor(length / step_ms)) + addtimer(CALLBACK(src, PROC_REF(opponent_piece)), piece * step_ms / 100, TIMER_DELETE_ME) + +/// One piece of an opponent's note (see opponent_hit()). +/datum/fnf_corruption/proc/opponent_piece() + // The drains are the song's character pushing back; a person singing it doesn't get them. + if(!battle || battle.state != FNF_STATE_PLAYING || battle.is_pvp()) + return + var/step = battle.get_song_time() / (battle.crochet / 4) + for(var/list/rule as anything in GLOB.fnf_corruption_drains[battle.song.id]) + if(step < rule[1] || (rule[2] && step >= rule[2])) + continue + if(battle.health > rule[3] * FNF_HEALTH_MAX / 2) + battle.adjust_health(-rule[4] * FNF_HEALTH_MAX / 2, battle.right) + break + if(opponent_drain && battle.health > 0.1 * FNF_HEALTH_MAX / 2) + battle.adjust_health(-opponent_drain * FNF_HEALTH_MAX / 2, battle.right) + +/// The player hitting a note: any extra health the song's events take for it, held pieces and all. +/datum/fnf_corruption/proc/player_hit(length = 0) + if(!player_bonus) + return + player_piece() + var/step_ms = battle.crochet / 4 + for(var/piece in 1 to floor(length / step_ms)) + addtimer(CALLBACK(src, PROC_REF(player_piece)), piece * step_ms / 100, TIMER_DELETE_ME) + +/// One piece of the player's note (see player_hit()). +/datum/fnf_corruption/proc/player_piece() + if(!battle || battle.state != FNF_STATE_PLAYING || !player_bonus || battle.is_pvp()) + return + battle.adjust_health(-player_bonus * FNF_HEALTH_MAX / 2, battle.right) + +/// The mod's "Drain" event: takes 0.02 more than its value (a negative value gives health back), unless that would finish the player. +/datum/fnf_corruption/proc/drain(value) + if(battle.is_pvp()) + return + var/damage = (0.02 + value) * FNF_HEALTH_MAX / 2 + if(battle.health > damage) + battle.adjust_health(-damage, battle.right) + +/// Makes sure everyone on stage looks as corrupted as their role says, whatever happened to their body. +/datum/fnf_corruption/proc/keep_cast() + for(var/role in current_cast) + var/list/cast = current_cast[role] + // Nobody behind the speakers: she's just hidden (see apply()). + if(role == "gf" && !cast["look"]) + continue + var/mob/living/carbon/singer = get_singer(role) + if(!istype(singer) || QDELETED(singer) || !singer.limb_rig) + continue + var/datum/limb_rig/rig = singer.limb_rig + var/level = cast["level"] || 0 + if(rig.corruption != level || rig.corruption_eyes != (cast["eyes"] || "pink") || rig.corruption_peak < (cast["peak"] || 0)) + corrupt(singer, cast) + else if(level && !rig.corruption_images) + rig.refresh_corruption() + rig.refresh_fnf_face() + +/// Who's in a role on stage now. +/datum/fnf_corruption/proc/get_singer(role) + switch(role) + if("player") + return battle.right?.singer + if("opponent") + return battle.left?.singer + if("gf") + return battle.stage?.girlfriend + +/// Makes someone on stage whoever the song says they are now. +/datum/fnf_corruption/proc/apply(role, character) + var/list/cast = GLOB.fnf_corruption_cast[character] + if(!cast) + return + current_cast[role] = cast + // A new character for the player moves as usual again (see the "alt_idle" event). + if(role == "player" && battle.right) + battle.right.hanging = FALSE + // Their colour on the health bar, as the mod has it. + if(role == "player") + battle.healthbar?.set_colours(null, cast["bar"]) + else if(role == "opponent") + battle.healthbar?.set_colours(cast["bar"], null) + switch(role) + if("player") + corrupt(battle.right?.singer, cast) + if("opponent") + corrupt(battle.left?.singer, cast) + // Moving as the mod's character does (see fnf_apply_style()), up on their wings or not. + if(battle.left && cast["look"]) + battle.left.style = cast["flying"] ? "[cast["look"]]_flying" : cast["look"] + if("gf") + var/datum/fnf_stage/stage = battle.stage + if(!stage) + return + if(!cast["look"]) + if(stage.girlfriend) + stage.girlfriend.alpha = 0 + // Just the speakers, if anything. + stage.set_seat(get_seat(cast)) + return + if(!stage.girlfriend || stage.girlfriend.fnf_look != cast["look"]) + QDEL_NULL(stage.girlfriend) + stage.summon_girlfriend_as(cast["look"]) + if(stage.girlfriend) + stage.girlfriend.alpha = 255 + corrupt(stage.girlfriend, cast) + stage.girlfriend_facing = cast["facing"] == "enemy" ? stage.get_enemy_side(stage.girlfriend) : SOUTH + stage.girlfriend.setDir(stage.girlfriend_facing) + stage.set_seat(get_seat(cast)) + +/// What someone in the girlfriend's place is sat on. Kapi's arcade has no speakers to sit on. +/datum/fnf_corruption/proc/get_seat(list/cast) + if(battle.song.note_skin == "kapi") + return null + return cast["seat"] + +/datum/fnf_corruption/proc/corrupt(mob/living/carbon/singer, list/cast) + if(!istype(singer)) + return + touched |= singer + singer.alpha = cast["hidden"] ? 0 : initial(singer.alpha) + battle.healthbar?.show_portrait(singer, !cast["hidden"]) + // With the player gone (the end of Purification), the camera's all on the opponent. + if(cast["hidden"] && singer == battle.right?.singer) + battle.camera_focus = -battle.stage?.gap_px / 48 || -1 + battle.pan_cameras() + // And zoomed right in on them, once the player's last notes are played: zoomed, they'd lose them. + for(var/datum/fnf_side/side as anything in battle.sides) + if(side.zoomed) + side.zoom_when_done = FNF_ZOOM + 1 + singer.fnf_face_mood = cast["face"] + // Kapi's dance pad, Marble's speaker. + battle.stage?.set_singer_prop(singer, cast["prop"]) + singer.fnf_set_corruption(cast["level"] || 0, cast["eyes"], cast["peak"]) + // Only the song's own characters grow wings; a player stays as they are. + if(singer == battle.npc || singer == battle.stage?.girlfriend) + fnf_set_wings(singer, cast["wings"]) + if(cast["flying"]) + ADD_TRAIT(singer, TRAIT_MOVE_FLOATING, FNF_BATTLE_TRAIT) + else + REMOVE_TRAIT(singer, TRAIT_MOVE_FLOATING, FNF_BATTLE_TRAIT) + +/// Gives a summoned singer wings for the song ("angel", "demon"), or takes them away. +/proc/fnf_set_wings(mob/living/carbon/singer, wings) + var/obj/item/organ/wings/current = singer.get_organ_slot(ORGAN_SLOT_EXTERNAL_WINGS) + var/wanted_type = wings == "angel" ? /obj/item/organ/wings/functional/angel : (wings == "demon" ? /obj/item/organ/wings/functional/dragon : null) + if(current && (!wanted_type || current.type != wanted_type)) + // Only wings the song gave: a singer's own stay. + if(!HAS_TRAIT_FROM(current, TRAIT_NODROP, FNF_BATTLE_TRAIT)) + return + current.Remove(singer) + qdel(current) + current = null + if(wanted_type && !current) + var/obj/item/organ/wings/given = new wanted_type + ADD_TRAIT(given, TRAIT_NODROP, FNF_BATTLE_TRAIT) + given.Insert(singer, special = TRUE) diff --git a/voidcrew/modules/fnf/faces.dm b/voidcrew/modules/fnf/faces.dm new file mode 100644 index 00000000000..eaf05f9bf1d --- /dev/null +++ b/voidcrew/modules/fnf/faces.dm @@ -0,0 +1,205 @@ +/** + * Faces in a rhythm battle: while someone sings, their head (side-on, the way singers face each + * other) pulls faces with the song. An open mouth for every note, shaped for which one; a wince on + * a miss; a grin for a hey; blinking in between. Seen from the front or back, the head is as it + * always is. + * + * Faces are drawn in the head's own pixels, over its own sprite, in pure black: solid black eyes, + * as Funkin' draws most people, or white ones in a black rim for the characters drawn that way + * (Pico and his crowd), or Daddy and Mommy Dearest's red glare. Experiments keep their own orange + * eyes. Each face covers the head's own eye pixel with the head's own shade, tinted to its skin, + * so a squint or a blink hides it. Nothing's drawn under a mask or helmet that covers the face, + * and plasmamen, all skull, go without. + */ + +/mob/living/carbon + /// Which eyes this singer's face has in a rhythm battle, if not solid black: "white" or "demon". + var/fnf_face_eyes + /// Which of opponents.dm's looks this is, if it was summoned as one. + var/fnf_look + /// The face pulled between notes instead of the usual, if any: "manic" for a desperate grin and + /// a bead of sweat (Corruption+'s Carol, losing it). + var/fnf_face_mood + +/datum/limb_rig + /// The face being pulled, while a rhythm battle has one on. + var/fnf_face + /// Turns the face back to idle once a note's pose is over. + var/fnf_face_timer + /// The images drawn on the head for it. + var/list/fnf_face_images + +/** + * Pulls a face: one of "idle" (blinking), "left", "down", "up", "right", "miss", "hey" or "dead". + * With a duration, it goes back to idle after it. + */ +/datum/limb_rig/proc/set_fnf_face(expression, duration) + deltimer(fnf_face_timer) + fnf_face_timer = null + fnf_face = expression + if(duration) + fnf_face_timer = addtimer(CALLBACK(src, PROC_REF(set_fnf_face), "idle"), duration, TIMER_STOPPABLE|TIMER_DELETE_ME) + refresh_fnf_face() + +/// Stops pulling faces. +/datum/limb_rig/proc/clear_fnf_face() + deltimer(fnf_face_timer) + fnf_face_timer = null + fnf_face = null + refresh_fnf_face() + +/// Whether a face is being held (a note, a miss) rather than idling. +/datum/limb_rig/proc/is_pulling_fnf_face() + return !!fnf_face_timer + +/datum/limb_rig/proc/refresh_fnf_face() + var/atom/movable/holder = get_fnf_face_holder() + holder?.cut_overlay(fnf_face_images) + fnf_face_images = null + if(!fnf_face || !holder || !can_show_fnf_face()) + return + fnf_face_images = get_fnf_face_images(fnf_face) + holder.add_overlay(fnf_face_images) + +/// The piece the head's drawn on. +/datum/limb_rig/proc/get_fnf_face_holder() + return null + +/// The images for a face, bottom first. +/datum/limb_rig/proc/get_fnf_face_images(expression) + return list() + + +/// Whether the face can be seen at all: there's a head, and nothing hides it. +/datum/limb_rig/proc/can_show_fnf_face() + if(!owner.get_bodypart(BODY_ZONE_HEAD)) + return FALSE + for(var/obj/item/worn as anything in list(owner.wear_mask, owner.head)) + if(worn && (worn.flags_inv & HIDEFACE)) + return FALSE + return TRUE + +// The Experiment's own head. + +/datum/limb_rig/sprites + /// Which faces in the 64px face icons fit this head ("experiment", "milkie"), or null for none. + var/fnf_face_set + +/datum/limb_rig/sprites/experiment + fnf_face_set = "experiment" + +/datum/limb_rig/sprites/experiment/milkie + fnf_face_set = "milkie" + +/datum/limb_rig/sprites/get_fnf_face_holder() + return face_part + +/datum/limb_rig/sprites/get_fnf_face_images(expression) + if(!fnf_face_set) + return list() + var/half_freed = is_face_half_freed() + if(is_face_corrupted() && !half_freed) + return get_corrupted_face_images('voidcrew/modules/fnf/icons/fnf_faces_experiment_corrupt.dmi', fnf_face_set, expression, -16) + // Half fought free: its own eye in a patch of its own fur, over the coat. + var/image/face = image('voidcrew/modules/fnf/icons/fnf_faces_experiment.dmi', "[fnf_face_set][half_freed ? "_window" : ""]_[expression]") + // Its sprites are 64 wide, drawn from 16 pixels left of the piece. + face.pixel_w = -16 + face.layer = FLOAT_LAYER + 0.05 + if(half_freed) + return list(face) + get_corrupted_face_images('voidcrew/modules/fnf/icons/fnf_faces_experiment_corrupt.dmi', fnf_face_set, expression, -16) + return list(face) + +// Everyone else's heads, as tg draws them. + +/datum/limb_rig/sprites/humanoid/get_fnf_face_images(expression) + var/obj/item/bodypart/head/head = owner.get_bodypart(BODY_ZONE_HEAD) + var/face_set = get_fnf_face_set(head?.limb_id) + if(!face_set) + return list() + var/half_freed = is_face_half_freed() + // Sunglasses, a visor or a mask over the eyes: the face is drawn over everything on the head, so + // it keeps to the mouth, or the corruption's grin. + var/eyes_covered = !!owner.is_eyes_covered() + if(is_face_corrupted() && (!half_freed || eyes_covered)) + return get_corrupted_face_images('voidcrew/modules/fnf/icons/fnf_faces_corrupt.dmi', face_set, expression, 0, eyes_covered) + // Only people's faces come in other eyes. + var/eyes = face_set == "human" && owner.fnf_face_eyes ? "_[owner.fnf_face_eyes]" : "" + // Between notes, a mood of their own, where there's a face for it. + var/static/list/face_states = icon_states('voidcrew/modules/fnf/icons/fnf_faces.dmi') + if((expression == "idle" || expression == "blink") && owner.fnf_face_mood == "manic" && ("[face_set][eyes]_laugh" in face_states)) + expression = "laugh" + if(eyes_covered) + var/image/mouth = image('voidcrew/modules/fnf/icons/fnf_faces.dmi', "[face_set][eyes]_mouth_[expression]") + mouth.layer = FLOAT_LAYER + return list(mouth) + // Half fought free: a patch of skin round the near eye, their own eye in it, over the coat. + var/image/cover = image('voidcrew/modules/fnf/icons/fnf_faces.dmi', "[face_set]_[half_freed ? "windowcover" : "cover"]") + cover.layer = FLOAT_LAYER + // The head's own shade, tinted as the head is (a head drawn in its own colours isn't), and + // coloured as its piece is (by corruption, coating it). + // A patch fought free of it isn't, though: that's the point of it. + var/obj/effect/abstract/limb_rig_part/head_piece = cloth_parts[RIG_HEAD] + cover.color = fnf_tint_then_colour(head.should_draw_greyscale ? head.draw_color : null, half_freed ? null : head_piece?.color) + var/image/face = image('voidcrew/modules/fnf/icons/fnf_faces.dmi', "[face_set][eyes][half_freed ? "_window" : ""]_[expression]") + face.layer = FLOAT_LAYER + if(half_freed) + return list(cover, face) + get_corrupted_face_images('voidcrew/modules/fnf/icons/fnf_faces_corrupt.dmi', face_set, expression, 0) + return list(cover, face) + +/// A colour to tint by, then a colour or colour matrix over that, as one colour matrix (or either alone). +/proc/fnf_tint_then_colour(tint, colour) + if(!colour) + return tint + if(!tint) + return colour + var/list/over = islist(colour) ? fnf_full_colour_matrix(colour) : list(rgb2num(colour)[1] / 255, 0, 0, 0, 0, rgb2num(colour)[2] / 255, 0, 0, 0, 0, rgb2num(colour)[3] / 255, 0, 0, 0, 0, 1, 0, 0, 0, 0) + var/list/scale = rgb2num(tint) + . = over.Copy() + // Each input channel's row of the matrix, scaled by how much of that channel the tint lets through. + for(var/channel in 1 to 3) + for(var/out in 1 to 4) + .[(channel - 1) * 4 + out] = over[(channel - 1) * 4 + out] * scale[channel] / 255 + +/// Which faces fit a head, by its limb id: null for a head that can't pull one. +/proc/get_fnf_face_set(limb_id) + switch(limb_id) + if(SPECIES_PLASMAMAN) + return null + if(SPECIES_LIZARD) + return "lizard" + if(SPECIES_MOTH) + return "moth" + if(SPECIES_ETHEREAL, SPECIES_ETHEREAL_LUSTROUS) + return "ethereal" + if(SPECIES_SKELETON) + return "skeleton" + return "human" + +/// Whether corruption has the face (see corruption.dm), all of it or its grin. +/datum/limb_rig/proc/is_face_corrupted() + if(!corruption || get_corruption_spread(RIG_HEAD, null, corruption_peak) < FNF_CORRUPTION_FACE) + return FALSE + return get_corruption_fought(RIG_HEAD, null) < FNF_CORRUPTION_FACE_FREED + +/// Whether the face is half fought free: its own near eye back in a clean patch, the grin still glowing. +/datum/limb_rig/proc/is_face_half_freed() + return is_face_corrupted() && get_corruption_fought(RIG_HEAD, null) >= FNF_CORRUPTION_FACE_HALF + +/** + * A corrupted face: glowing, so drawn in its own colours whatever's drained the rest, and lit in + * the dark. Just the grin when half fought free, or with grin_only (the eyes covered). + */ +/datum/limb_rig/proc/get_corrupted_face_images(face_icon, face_set, expression, offset, grin_only = FALSE) + var/half = grin_only || is_face_half_freed() ? "half" : "" + var/tag = corruption_eyes == "red" ? "corruptred" : "corrupt" + // Eyes gone blank, the pupils out of them: Purification's Girlfriend with Boyfriend taking over, and + // the ones whose sprites keep their pupils dark. + if(!half && (corruption_eyes == "blank" || owner.fnf_face_mood == "blank")) + tag = "corruptblank" + var/state = "[face_set]_[tag][half]_[expression == "blink" ? "idle" : expression]" + var/image/face = image(face_icon, state) + face.pixel_w = offset + face.layer = FLOAT_LAYER + 0.06 + var/mutable_appearance/glow = emissive_appearance(face_icon, state, owner, FLOAT_LAYER + 0.06, appearance_flags = RESET_COLOR) + glow.pixel_w = offset + return list(face, glow) diff --git a/voidcrew/modules/fnf/game_over.dm b/voidcrew/modules/fnf/game_over.dm new file mode 100644 index 00000000000..d7567709845 --- /dev/null +++ b/voidcrew/modules/fnf/game_over.dm @@ -0,0 +1,611 @@ +/** + * Getting run off the health bar, Funkin' style, seen only by whoever lost. + * + * Everything goes black. An Experiment is left standing alone, drawn like a health doll, + * cowering, and gets shot in the head: its head outline goes red, its head whips back, its arms + * fling up, its knees buckle and it goes over backwards, bouncing and spraying blood, then lies + * twitching in the spreading pool (all baked into fnf_dead.dmi, after Boyfriend's death). + * + * Anyone else dies like Pico does, to his game over music, acted out by their own body with the + * rest of the world blacked out: a knife in the neck and a fountain of blood (RETRY written in the + * blood); or, missing a shot in 2hot, the spray can blowing up in their face, leaving them charred, + * dazed and coughing up smoke (RETRY in pink smoke); or in Blazin' a punch to the gut that folds + * them up and drops them in their own blood (RETRY splattered under them). + * + * Any of them can be watched from the battle microphone's settings, without losing a song first. + * + * Then it waits, the game over music looping, for the singer to pick: RETRY (against the game + * only) or GIVE UP. Until then they're down on the floor, can't touch anything, and their mic + * stays in their hand. + * + * An Experiment that retries makes its last stand, as in Casualties Unknown: "Let's not give up + * just yet." flickers over everything to a drone while the world slowly fades back in, and the + * song counts in again as the drone ends. The new battle is set up while it's still dark, so the + * singer is already back in their stance when the world comes back. + * + * The screens go through the mob's fullscreen overlays, so a HUD rebuild puts them back rather + * than wiping them, and they land on the right plane whatever z-level the singer is on. + */ +/// Burnt black-blue, after a can goes off in your face. +#define FNF_CHARRED "#2a3470" +/// Where a prop sits to be at the neck and at the face: offsets for a 32 by 32 sprite's middle, on +/// a body a quarter bigger than a human. +#define FNF_NECK_Y 11.5 +#define FNF_FACE_Y 15 + +/datum/fnf_game_over + var/mob/living/singer + var/datum/fnf_song/song + var/difficulty + /// Whether RETRY? starts the song again (only against the game). + var/can_retry = FALSE + /// Which way the fallen singer faces. + var/facing = WEST + /// The fullscreen categories this put up, to take down again. + var/list/categories = list() + var/atom/movable/screen/fullscreen/fnf/blackout + /// The Experiment, standing, dying and then twitching. + var/atom/movable/screen/fullscreen/fnf/figure + var/atom/movable/screen/fullscreen/fnf/button/retry_button + var/atom/movable/screen/fullscreen/fnf/button/give_up_button + var/atom/movable/screen/fullscreen/fnf/laststand + /// Held items glued to the singer's hands, as weakrefs. + var/list/datum/weakref/stuck_items = list() + var/music_channel + var/retrying = FALSE + /// How they die: "shot" (an Experiment), "knife", "explode" or "gutpunch". + var/death = "knife" + /// Knives, cans, blood and smoke acted out around the body, on the figure. + var/list/obj/effect/abstract/fnf_prop/props = list() + /// The singer's own vis_flags, while they're drawn over the blackout. + var/old_vis_flags + /// Whether they're still on show over the blackout. + var/showing_body = FALSE + /// The knife in their neck, to go down with them. + var/obj/effect/abstract/fnf_prop/knife + /// Where the neck is, between the bottom of the head and the top of the torso: where the knife + /// goes in and the blood comes out. In pixels from the middle of the body, as props are placed. + var/neck_x = 0 + var/neck_y = 0 + /// The song's own game over (Corruption+'s): list("death", "loop", "end" = file), or null for Funkin's. + var/list/own_sounds + +/** + * * kill_kind - the kind of the note whose miss ended it, if it had one + * * forced_death - how to die, for a preview: "shot", "knife", "explode" or "gutpunch" + * * died_at - where in the song it ended, in milliseconds, for a song whose game over changes partway + */ +/datum/fnf_game_over/New(mob/living/singer, datum/fnf_song/song, difficulty, can_retry, facing, kill_kind, forced_death, died_at = 0) + src.singer = singer + src.song = song + own_sounds = song?.get_game_over_sounds(died_at) + src.difficulty = difficulty + src.can_retry = can_retry + src.facing = facing + var/client/viewer = singer.client + RegisterSignals(singer, list(COMSIG_QDELETING, COMSIG_MOB_LOGOUT), PROC_REF(on_singer_gone)) + music_channel = SSsounds.reserve_sound_channel(src) + animate(viewer, pixel_w = 0, pixel_z = 0, time = 3) + // Down, and out of it: no touching the world, and the mic doesn't fall out of their hand when + // they hit the floor. + for(var/obj/item/held in singer.held_items) + ADD_TRAIT(held, TRAIT_NODROP, FNF_GAME_OVER_TRAIT) + stuck_items += WEAKREF(held) + var/expie = is_species(singer, /datum/species/experiment) + death = forced_death || (expie ? "shot" : (kill_kind == "weekend-1-firegun" ? "explode" : (song?.id == "blazin" ? "gutpunch" : "knife"))) + expie = death == "shot" + // An Experiment is drawn separately, dying on the floor. Anyone else stays up to act it out. + singer.add_traits(expie ? list(TRAIT_FLOORED, TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED, TRAIT_UI_BLOCKED) : list(TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED, TRAIT_UI_BLOCKED), FNF_GAME_OVER_TRAIT) + + blackout = add_screen("fnf_blackout", /atom/movable/screen/fullscreen/fnf) + blackout.alpha = 0 + animate(blackout, alpha = 255, time = 6) + + if(!expie) + show_body() + switch(death) + if("explode") + SEND_SOUND(singer, sound("voidcrew/modules/fnf/sound/loss_pico_explode.ogg", volume = 60)) + addtimer(CALLBACK(src, PROC_REF(throw_can)), 4, TIMER_DELETE_ME) + // Left standing, dazed, coughing up smoke: no falling over for this one. + addtimer(CALLBACK(src, PROC_REF(daze)), 12, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(show_buttons)), 22, TIMER_DELETE_ME) + if("gutpunch") + SEND_SOUND(singer, sound("voidcrew/modules/fnf/sound/loss_pico_gutpunch.ogg", volume = 60)) + // A moment late: the battle puts everyone back at rest as it ends. + addtimer(CALLBACK(src, PROC_REF(gut_punch)), 1, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(fall)), 8, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(show_buttons)), 14, TIMER_DELETE_ME) + else + SEND_SOUND(singer, sound(own_sounds?["death"] || "voidcrew/modules/fnf/sound/loss_pico.ogg", volume = 60)) + addtimer(CALLBACK(src, PROC_REF(throw_knife)), 3, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(fall)), 13, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(show_buttons)), 20, TIMER_DELETE_ME) + return + + SEND_SOUND(singer, sound(own_sounds?["death"] || "voidcrew/modules/fnf/sound/loss.ogg", volume = 60)) + if(expie) + // 160 by 96, standing on the middle of its bottom edge, drawn twice size with its feet + // kept on the singer's tile. + figure = add_screen("fnf_figure", /atom/movable/screen/fullscreen/fnf/figure) + figure.dir = facing + figure.alpha = 0 + animate(figure, alpha = 255, time = 6) + addtimer(CALLBACK(src, PROC_REF(cock_gun)), 10, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(shoot)), 18, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(show_buttons)), 32, TIMER_DELETE_ME) + +/datum/fnf_game_over/Destroy() + if(singer) + UnregisterSignal(singer, list(COMSIG_QDELETING, COMSIG_MOB_LOGOUT, COMSIG_LIVING_POST_UPDATE_TRANSFORM)) + release_singer() + for(var/category in categories) + singer.clear_fullscreen(category, FALSE) + SEND_SOUND(singer, sound(null, channel = music_channel)) + categories.Cut() + QDEL_LIST(props) + knife = null + blackout = null + figure = null + retry_button = null + give_up_button = null + laststand = null + SSsounds.free_datum_channels(src) + singer = null + return ..() + +/// Lets the singer up and gives them their hands back. +/datum/fnf_game_over/proc/release_singer() + hide_body() + singer.remove_atom_colour(TEMPORARY_COLOUR_PRIORITY, FNF_CHARRED) + // Stop swaying about dazed. + singer.fnf_rest() + singer.remove_traits(list(TRAIT_FLOORED, TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED, TRAIT_UI_BLOCKED), FNF_GAME_OVER_TRAIT) + for(var/datum/weakref/item_ref as anything in stuck_items) + var/obj/item/held = item_ref.resolve() + if(held) + REMOVE_TRAIT(held, TRAIT_NODROP, FNF_GAME_OVER_TRAIT) + stuck_items.Cut() + +/datum/fnf_game_over/proc/add_screen(category, type) + categories += category + return singer.overlay_fullscreen(category, type) + +/datum/fnf_game_over/proc/on_singer_gone(datum/source) + SIGNAL_HANDLER + qdel(src) + +/** + * Draws the singer themselves over the blackout, right where they stand on screen, and twice the + * size: the figure holds them, and they draw on its plane for as long as it does. + */ +/datum/fnf_game_over/proc/show_body() + figure = add_screen("fnf_figure", /atom/movable/screen/fullscreen/fnf/body) + old_vis_flags = singer.vis_flags + singer.vis_flags |= VIS_INHERIT_PLANE|VIS_INHERIT_LAYER + figure.vis_contents += singer + showing_body = TRUE + +/datum/fnf_game_over/proc/hide_body() + if(!showing_body) + return + showing_body = FALSE + figure?.vis_contents -= singer + singer.vis_flags = old_vis_flags + +/// Something acted out around the body: a knife, a can, blood, smoke. In pixels from the singer's feet. +/datum/fnf_game_over/proc/add_prop(icon_file, state, x_offset, y_offset, prop_layer = 52) + var/obj/effect/abstract/fnf_prop/prop = new + prop.icon = icon_file + prop.icon_state = state + prop.pixel_w = x_offset + prop.pixel_z = y_offset + prop.layer = prop_layer + figure?.vis_contents += prop + props += prop + return prop + +/// Which way is toward the rival, as a sign for pixel offsets. +/datum/fnf_game_over/proc/ahead() + return facing == EAST ? 1 : -1 + +/// The usual Pico death: a knife flies in and sticks in their head, and the blood goes everywhere. +/datum/fnf_game_over/proc/throw_knife() + // Thrown from in front, so the blade points back into the neck and the handle sticks out front. + knife = add_prop('icons/mob/inhands/equipment/kitchen_righthand.dmi', "knife", ahead() * 90, 0) + knife.dir = facing == EAST ? WEST : EAST + neck_x = ahead() * 2 + neck_y = FNF_NECK_Y + var/list/blade = knife_blade_offset() + knife.pixel_z = neck_y - blade[2] + 8 + animate(knife, pixel_w = neck_x - blade[1], pixel_z = neck_y - blade[2], transform = turn(matrix(), 720), time = 2.5, easing = LINEAR_EASING) + addtimer(CALLBACK(src, PROC_REF(knife_hits)), 2.5, TIMER_DELETE_ME) + +/// Where the in-hand knife sprite's blade sits, from the middle of its 32 by 32 icon. +/datum/fnf_game_over/proc/knife_blade_offset() + return knife.dir == WEST ? list(-6.5, -2.5) : list(1.5, -2.5) + +/datum/fnf_game_over/proc/knife_hits() + SEND_SOUND(singer, sound('sound/items/weapons/bladeslice.ogg', volume = 60)) + singer.fnf_act("hit_high", singer.fnf_mic_arm(facing), facing, null, 6) + animate(blackout, color = "#5a0000", time = 0) + animate(color = "#000000", time = 3) + var/obj/effect/abstract/fnf_prop/pool = add_prop('icons/effects/blood.dmi', "floor[rand(1, 7)]", ahead() * -8, -6, 50.5) + pool.color = get_blood_colour() + pool.alpha = 0 + pool.transform = matrix() * 0.3 + animate(pool, alpha = 255, transform = matrix() * 1.4, time = 25, delay = 8, easing = SINE_EASING) + gush() + +/// The fountain: blood gushing up out of the neck and raining back down, on and on until they pick. +/datum/fnf_game_over/proc/gush() + if(retrying || !figure || !knife) + return + var/blood_colour = get_blood_colour() + for(var/i in 1 to 3) + var/obj/effect/abstract/fnf_prop/drop = add_prop('icons/effects/blood.dmi', "drip[rand(1, 5)]", neck_x, neck_y) + drop.color = blood_colour + var/drift = rand(-22, 22) + animate(drop, pixel_w = neck_x + drift * 0.4, pixel_z = neck_y + rand(24, 44), time = 3 + i, easing = SINE_EASING|EASE_OUT) + animate(pixel_w = neck_x + drift, pixel_z = rand(-14, -10), alpha = 160, time = 5, easing = QUAD_EASING|EASE_IN) + QDEL_IN(drop, 12) + addtimer(CALLBACK(src, PROC_REF(forget_prop), drop), 11, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(gush)), 2, TIMER_DELETE_ME) + +/// 2hot's: the can Pico should have shot comes down on them and goes off in their face. +/datum/fnf_game_over/proc/throw_can() + var/obj/effect/abstract/fnf_prop/can = add_prop('icons/obj/art/crayons.dmi', "spraycan", ahead() * 60, FNF_FACE_Y + 30) + animate(can, pixel_w = ahead() * 4, pixel_z = FNF_FACE_Y, transform = turn(matrix(), 540), time = 3, easing = QUAD_EASING|EASE_IN) + addtimer(CALLBACK(src, PROC_REF(can_explodes), can), 3, TIMER_DELETE_ME) + +/datum/fnf_game_over/proc/can_explodes(obj/effect/abstract/fnf_prop/can) + props -= can + qdel(can) + var/obj/effect/abstract/fnf_prop/bang = add_prop('icons/effects/96x96.dmi', "explosionfast", ahead() * 4 - 32, FNF_FACE_Y - 32) + bang.transform = matrix() * 0.7 + QDEL_IN(bang, 12) + animate(blackout, color = "#ffffff", time = 0) + animate(color = "#000000", time = 4) + // Burnt to a crisp, and a pink plume of spray paint going up. + singer.add_atom_colour(FNF_CHARRED, TEMPORARY_COLOUR_PRIORITY) + singer.fnf_act("hit_high", singer.fnf_mic_arm(facing), facing, null, 8) + for(var/i in 1 to 6) + var/obj/effect/abstract/fnf_prop/puff = add_prop('icons/effects/effects.dmi', "smoke", rand(-10, 10), FNF_FACE_Y) + puff.color = "#ff5ac8" + puff.alpha = 220 + puff.transform = matrix() * 0.5 + animate(puff, pixel_w = rand(-24, 24), pixel_z = FNF_FACE_Y + 14 + i * 8, transform = matrix() * (1.2 + i * 0.2), alpha = 0, time = 20 + i * 3, easing = SINE_EASING|EASE_OUT) + +/// After the bang: swaying on the spot, stars round the head, coughing up smoke until they choose. +/datum/fnf_game_over/proc/daze() + if(!figure) + return + // Drawn round a human's head: lifted up to this one's. + var/obj/effect/abstract/fnf_prop/stars = add_prop('icons/effects/effects.dmi', "dazed", 0, 6) + stars.alpha = 0 + animate(stars, alpha = 255, time = 3) + var/mic_arm = singer.fnf_mic_arm(facing) + var/mob/living/carbon/rigged = singer + if(istype(rigged) && rigged.limb_rig) + var/list/rest = fnf_pose("rest", mic_arm, facing, null) + var/list/sway_left = fnf_nudge_pose(fnf_nudge_pose(rest, RIG_HEAD, "tilt", 14), RIG_CHEST, "lean", 5) + var/list/sway_right = fnf_nudge_pose(fnf_nudge_pose(rest, RIG_HEAD, "tilt", -14), RIG_CHEST, "lean", -5) + sway_left = fnf_nudge_pose(sway_left, RIG_HEAD, "nod", 12) + sway_right = fnf_nudge_pose(sway_right, RIG_HEAD, "nod", 12) + rigged.limb_rig.play(list(list(sway_left, 8, SINE_EASING), list(sway_right, 8, SINE_EASING)), loop = -1, settle_after = FALSE) + cough() + +/datum/fnf_game_over/proc/cough() + if(retrying || !figure) + return + var/sound_file = "sound/mobs/humanoids/human/cough/[singer.gender == FEMALE ? "female_cough[rand(1, 6)]" : "male_cough[rand(1, 4)]"].ogg" + SEND_SOUND(singer, sound(sound_file, volume = 45)) + for(var/i in 1 to 3) + var/obj/effect/abstract/fnf_prop/puff = add_prop('icons/effects/effects.dmi', "smoke", ahead() * 6, FNF_FACE_Y - 2) + puff.color = pick("#3a3a3a", "#ff5ac8") + puff.transform = matrix() * 0.25 + animate(puff, pixel_w = ahead() * (14 + i * 6), pixel_z = FNF_FACE_Y + 4 + i * 3, transform = matrix() * 0.7, alpha = 0, time = 10 + i * 2, easing = SINE_EASING|EASE_OUT) + QDEL_IN(puff, 16) + addtimer(CALLBACK(src, PROC_REF(forget_prop), puff), 15, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(cough)), rand(20, 35), TIMER_DELETE_ME) + +/datum/fnf_game_over/proc/forget_prop(obj/effect/abstract/fnf_prop/prop) + props -= prop + +/// Blazin's: folded in half by a punch to the gut. +/datum/fnf_game_over/proc/gut_punch() + SEND_SOUND(singer, sound("sound/items/weapons/punch[rand(1, 4)].ogg", volume = 70)) + singer.fnf_act("hit_low", singer.fnf_mic_arm(facing), facing, null, 6) + +/// The colour of the singer's blood, to bleed with. +/datum/fnf_game_over/proc/get_blood_colour() + return singer.get_bloodtype()?.get_damage_color(singer) || "#a10808" + +/// Down they go, and the blood spreads out under them. +/datum/fnf_game_over/proc/fall() + singer.add_traits(list(TRAIT_FLOORED), FNF_GAME_OVER_TRAIT) + if(knife) + // Lying down turns them to face the viewer, so the neck is dead centre, then the whole body + // turns onto its side: the knife goes with it. + RegisterSignal(singer, COMSIG_LIVING_POST_UPDATE_TRANSFORM, PROC_REF(on_body_turned)) + if(death == "gutpunch") + // Keeled over in their own blood, coughed up with the punch. + var/obj/effect/abstract/fnf_prop/pool = add_prop('icons/effects/blood.dmi', "floor[rand(1, 7)]", ahead() * 6, -8, 50.5) + pool.color = get_blood_colour() + pool.alpha = 0 + pool.transform = matrix() * 0.4 + animate(pool, alpha = 255, transform = matrix() * 1.3, time = 18, easing = SINE_EASING) + +/datum/fnf_game_over/proc/on_body_turned(mob/living/source, resize, lying_angle, is_opposite_angle) + SIGNAL_HANDLER + UnregisterSignal(singer, COMSIG_LIVING_POST_UPDATE_TRANSFORM) + if(lying_angle) + knife_follows(lying_angle) + +/// Keeps the knife in the neck as the body turns onto its side. +/datum/fnf_game_over/proc/knife_follows(angle) + if(!knife) + return + // The neck, from the middle of the body (dead centre now they face the viewer), turned the way + // the body turned. The fountain carries on from there. + neck_x = FNF_NECK_Y * sin(angle) + neck_y = FNF_NECK_Y * cos(angle) + PIXEL_Y_OFFSET_LYING + var/list/blade = knife_blade_offset() + animate(knife, pixel_w = neck_x - blade[1], pixel_z = neck_y - blade[2], transform = turn(matrix(), angle), time = UPDATE_TRANSFORM_ANIMATION_TIME, easing = EASE_IN|EASE_OUT) + +/datum/fnf_game_over/proc/cock_gun() + SEND_SOUND(singer, sound("voidcrew/modules/fnf/sound/gun_cock.ogg", volume = 60)) + +/// Bang. The death plays through once, then it lies there twitching. +/datum/fnf_game_over/proc/shoot() + SEND_SOUND(singer, sound("voidcrew/modules/fnf/sound/shot[rand(1, 4)].ogg", volume = 70)) + animate(blackout, color = "#ffffff", time = 0) + animate(color = "#000000", time = 2) + // A kick of recoil on the whole figure, on top of what the frames do. + var/back = facing == EAST ? -1 : 1 + animate(figure, transform = matrix(2, 0, back * 6, 0, 2, 48), time = 0.5, easing = CUBIC_EASING|EASE_OUT) + animate(transform = matrix(2, 0, 0, 0, 2, 48), time = 2) + figure.icon_state = "expie_death" + // 31 frames at half a decisecond each. + addtimer(CALLBACK(src, PROC_REF(twitch)), 15.5, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(yelp)), 1, TIMER_DELETE_ME) + +/datum/fnf_game_over/proc/twitch() + figure.icon_state = "expie_twitch" + +/datum/fnf_game_over/proc/yelp() + SEND_SOUND(singer, sound(get_expie_death_sound(), volume = 50)) + +/datum/fnf_game_over/proc/show_buttons() + if(retrying) + return + var/music_file = own_sounds?["loop"] || (death == "shot" ? "voidcrew/modules/fnf/sound/gameover_loop.ogg" : "voidcrew/modules/fnf/sound/gameover_loop_pico.ogg") + SEND_SOUND(singer, sound(music_file, repeat = TRUE, channel = music_channel, volume = 45)) + if(can_retry) + retry_button = add_screen("fnf_retry", /atom/movable/screen/fullscreen/fnf/button/retry) + retry_button.game_over = src + var/static/list/retry_styles = list("knife" = "retry_blood", "explode" = "retry_smoke", "gutpunch" = "retry_splat") + retry_button.base_state = retry_styles[death] || "retry" + retry_button.icon_state = retry_button.base_state + retry_button.pop_in() + give_up_button = add_screen("fnf_give_up", /atom/movable/screen/fullscreen/fnf/button/give_up) + give_up_button.game_over = src + give_up_button.pop_in() + +/// Clicked RETRY: an Experiment makes its last stand, anyone else hears the jingle; then the song +/// starts again. +/datum/fnf_game_over/proc/retry() + if(retrying || !can_retry) + return + retrying = TRUE + SEND_SOUND(singer, sound(null, channel = music_channel)) + retry_button.confirm() + animate(give_up_button, alpha = 0, time = 3) + if(death == "shot") + last_stand() + return + SEND_SOUND(singer, sound(own_sounds?["end"] || (death == "shot" ? "voidcrew/modules/fnf/sound/gameover_retry.ogg" : "voidcrew/modules/fnf/sound/gameover_retry_pico.ogg"), volume = 60)) + // The body goes back into the dark with everything else, to be set up again. + if(figure) + animate(figure, alpha = 0, time = 12) + for(var/obj/effect/abstract/fnf_prop/prop as anything in props) + animate(prop, alpha = 0, time = 12) + animate(blackout, alpha = 255, time = 15) + addtimer(CALLBACK(src, PROC_REF(sing_again), 0, 20), 25, TIMER_DELETE_ME) + // Back into the light as the count in starts. + animate(blackout, alpha = 255, time = 25) + animate(alpha = 0, time = 20, easing = SINE_EASING) + +/** + * "Let's not give up just yet." Eight seconds of drone, the screen flickering through its three + * grainy takes, fading back slowly to the world as it ends. Once it's fully dark the new battle is + * set up, counting in two seconds late, as the drone runs out. + */ +/datum/fnf_game_over/proc/last_stand() + SEND_SOUND(singer, sound("voidcrew/modules/fnf/sound/laststand_drone.ogg", volume = 70)) + laststand = add_screen("fnf_laststand", /atom/movable/screen/fullscreen/fnf/laststand) + laststand.alpha = 0 + // In over the dead body, held a while, then eased away as the world comes back. + animate(laststand, alpha = 255, time = 8) + animate(alpha = 255, time = 14) + animate(alpha = 0, time = 50, easing = SINE_EASING) + animate(figure, alpha = 0, time = 8) + animate(retry_button, alpha = 0, time = 10, delay = 5) + animate(blackout, alpha = 255, time = 30) + animate(alpha = 0, time = 50, easing = SINE_EASING) + addtimer(CALLBACK(src, PROC_REF(sing_again), 2 SECONDS, 50), 30, TIMER_DELETE_ME) + +/** + * Starts the song again, straight from the floor into the new battle so they never stand around + * in between. + * + * * lead_in - extra deciseconds before the count in + * * linger - deciseconds to leave the game over screens up, to finish fading + */ +/datum/fnf_game_over/proc/sing_again(lead_in = 0, linger = 0) + var/mob/living/again = singer + var/datum/fnf_song/song_again = song + var/difficulty_again = difficulty + if(linger) + QDEL_IN(src, linger) + else + qdel(src) + if(QDELETED(again) || again.stat != CONSCIOUS) + return + var/obj/item/fnf_microphone/microphone = locate() in again.held_items + if(microphone?.battle) + if(microphone.battle.state != FNF_STATE_OVER) + return + microphone.battle = null + var/datum/fnf_battle/battle = new(song_again, difficulty_again, microphone) + battle.lead_in = lead_in + if(microphone) + microphone.battle = battle + battle.preload(again) + battle.start(again, null) + // Only now let go, so they go from lying down straight into their stance. + if(!QDELETED(src)) + release_singer() + +/// Clicked GIVE UP: everything fades out and the singer gets up. +/datum/fnf_game_over/proc/give_up() + if(retrying) + return + retrying = TRUE + SEND_SOUND(singer, sound(null, channel = music_channel)) + for(var/atom/movable/screen/fullscreen/fnf/screen as anything in list(blackout, figure, retry_button, give_up_button)) + if(screen) + animate(screen, alpha = 0, time = 10) + for(var/obj/effect/abstract/fnf_prop/prop as anything in props) + animate(prop, alpha = 0, time = 10) + QDEL_IN(src, 10) + +/// The black that covers everything. +/atom/movable/screen/fullscreen/fnf + icon = 'voidcrew/modules/fnf/icons/fnf.dmi' + icon_state = "bar" + color = "#000000" + screen_loc = "WEST,SOUTH to EAST,NORTH" + layer = 50 + appearance_flags = PIXEL_SCALE|KEEP_TOGETHER + show_when_dead = TRUE + +/// The fallen Experiment. 160 by 96, standing on the middle of its bottom edge, drawn twice size +/// with its feet kept on the singer's tile. +/atom/movable/screen/fullscreen/fnf/figure + icon = 'voidcrew/modules/fnf/icons/fnf_dead.dmi' + icon_state = "expie_doomed" + color = null + screen_loc = "CENTER:-64,CENTER" + layer = 51 + +/atom/movable/screen/fullscreen/fnf/figure/Initialize(mapload, datum/hud/hud_owner) + . = ..() + transform = matrix(2, 0, 0, 0, 2, 48) + +/// Holds the singer themselves over the blackout, right where they are on screen, twice the size +/// with their feet kept where they stand. +/atom/movable/screen/fullscreen/fnf/body + icon = null + icon_state = null + color = null + screen_loc = "CENTER,CENTER" + layer = 51 + appearance_flags = PIXEL_SCALE + +/atom/movable/screen/fullscreen/fnf/body/Initialize(mapload, datum/hud/hud_owner) + . = ..() + transform = matrix(2, 0, 0, 0, 2, 16) + +/// A knife, a can, a drop of blood or a puff of smoke, drawn with the body over the blackout. +/obj/effect/abstract/fnf_prop + name = "" + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + appearance_flags = PIXEL_SCALE + vis_flags = VIS_INHERIT_PLANE + +/// "Let's not give up just yet.", 640 by 360, centred and blown up to cover the screen. +/atom/movable/screen/fullscreen/fnf/laststand + icon = 'voidcrew/modules/fnf/icons/laststand.dmi' + icon_state = "laststand" + color = null + screen_loc = "CENTER-9:-16,CENTER-5:-4" + layer = 55 + +/atom/movable/screen/fullscreen/fnf/laststand/Initialize(mapload, datum/hud/hud_owner) + . = ..() + transform = matrix() * 1.4 + +/** + * A game over button. These sit on the HUD plane: the fullscreen plane doesn't take clicks at all. + * They light up under the mouse. + */ +/atom/movable/screen/fullscreen/fnf/button + icon = 'voidcrew/modules/fnf/icons/fnf_buttons.dmi' + color = null + plane = HUD_PLANE + layer = 60 + mouse_opacity = MOUSE_OPACITY_OPAQUE + appearance_flags = PIXEL_SCALE + var/datum/fnf_game_over/game_over + /// Whether it's been clicked, and stays lit. + var/pressed = FALSE + /// The icon state it shows unlit: the RETRY styles differ with how you died. + var/base_state + +/atom/movable/screen/fullscreen/fnf/button/Initialize(mapload, datum/hud/hud_owner) + . = ..() + base_state = icon_state + +/atom/movable/screen/fullscreen/fnf/button/Destroy() + game_over = null + return ..() + +/atom/movable/screen/fullscreen/fnf/button/proc/pop_in() + transform = matrix() * 1.8 + alpha = 0 + animate(src, transform = matrix(), alpha = 255, time = 3, easing = BACK_EASING|EASE_OUT) + +/atom/movable/screen/fullscreen/fnf/button/MouseEntered(location, control, params) + . = ..() + if(!pressed) + icon_state = "[base_state]_lit" + +/atom/movable/screen/fullscreen/fnf/button/MouseExited(location, control, params) + . = ..() + if(!pressed) + icon_state = base_state + +/atom/movable/screen/fullscreen/fnf/button/proc/confirm() + pressed = TRUE + icon_state = "[base_state]_lit" + animate(src, transform = matrix() * 1.3, time = 1) + animate(transform = matrix() * 1.1, time = 3) + +/// RETRY, throbbing gently until it's clicked. +/atom/movable/screen/fullscreen/fnf/button/retry + icon_state = "retry" + screen_loc = "CENTER:-80,CENTER+3" + +/atom/movable/screen/fullscreen/fnf/button/retry/pop_in() + transform = matrix() * 1.8 + alpha = 0 + animate(src, transform = matrix(), alpha = 255, time = 3, easing = BACK_EASING|EASE_OUT) + animate(transform = matrix() * 0.94, time = 6, loop = -1, easing = SINE_EASING) + animate(transform = matrix() * 1.04, time = 6, easing = SINE_EASING) + +/atom/movable/screen/fullscreen/fnf/button/retry/Click(location, control, params) + if(usr == game_over?.singer) + game_over.retry() + +/atom/movable/screen/fullscreen/fnf/button/give_up + icon_state = "giveup" + screen_loc = "CENTER:-80,CENTER-3" + +/atom/movable/screen/fullscreen/fnf/button/give_up/Click(location, control, params) + if(usr == game_over?.singer) + game_over.give_up() + +#undef FNF_CHARRED +#undef FNF_NECK_Y +#undef FNF_FACE_Y diff --git a/voidcrew/modules/fnf/hud.dm b/voidcrew/modules/fnf/hud.dm new file mode 100644 index 00000000000..a610dda2a00 --- /dev/null +++ b/voidcrew/modules/fnf/hud.dm @@ -0,0 +1,274 @@ +/// Everything a rhythm battle draws over the stage. It sits above the lighting, so a battle in +/// a dark corridor can still be played. +/obj/effect/abstract/fnf_hud + name = "" + icon = 'voidcrew/modules/fnf/icons/fnf.dmi' + icon_state = null + plane = ABOVE_LIGHTING_PLANE + layer = ABOVE_ALL_MOB_LAYER + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + appearance_flags = RESET_COLOR|RESET_TRANSFORM|RESET_ALPHA|PIXEL_SCALE|KEEP_APART + vis_flags = VIS_INHERIT_PLANE + +/obj/effect/abstract/fnf_hud/text + maptext_width = 160 + maptext_height = 32 + maptext_x = -64 + +/** + * The health bar, strung between the two singers: the opponent's colour from the left, the + * challenger's from the right, meeting where the battle stands. + */ +/obj/effect/abstract/fnf_hud/healthbar + /// Where the middle of the bar is, in pixels from this tile's bottom left. + var/center_x = 16 + var/center_y = 16 + var/obj/effect/abstract/fnf_hud/frame + var/obj/effect/abstract/fnf_hud/left_bar + var/obj/effect/abstract/fnf_hud/right_bar + /// The singers' heads, facing each other on either side of where the colours meet. + var/obj/effect/abstract/fnf_hud/portrait/left_portrait + var/obj/effect/abstract/fnf_hud/portrait/right_portrait + /// Big text in the middle, for the countdown. + var/obj/effect/abstract/fnf_hud/text/announcer + /// A frame of the song's own instead of the plain one (see set_frame()), and the colours filling + /// its opening, the challenger's cut off where the colours meet. + var/obj/effect/abstract/fnf_hud/shaped_frame + var/obj/effect/abstract/fnf_hud/shaped_left + var/obj/effect/abstract/fnf_hud/shaped_right + /// The health it was last shown at. + var/shown_health = FNF_HEALTH_MAX / 2 + /// Colours the heads are drawn in for now (silhouettes, while the song blacks the stage out), + /// list(left, right), each a colour or colour matrix; null for their own. + var/list/portrait_tints + +/obj/effect/abstract/fnf_hud/healthbar/Initialize(mapload, center_x, center_y, mob/living/left_mob, mob/living/right_mob) + . = ..() + src.center_x = center_x + src.center_y = center_y + frame = make_bar("#000000", FNF_BAR_WIDTH + 4, FNF_BAR_HEIGHT + 4, 0.01) + left_bar = make_bar("#e0453a", FNF_BAR_WIDTH, FNF_BAR_HEIGHT, 0.02) + right_bar = make_bar("#5de83a", FNF_BAR_WIDTH, FNF_BAR_HEIGHT, 0.03) + announcer = new + announcer.layer = ABOVE_ALL_MOB_LAYER + 0.05 + announcer.pixel_w = center_x - 16 + announcer.pixel_z = center_y + 8 + vis_contents += announcer + if(left_mob) + left_portrait = new(null, left_mob, EAST) + vis_contents += left_portrait + if(right_mob) + right_portrait = new(null, right_mob, WEST) + vis_contents += right_portrait + update(FNF_HEALTH_MAX / 2, instant = TRUE) + +/obj/effect/abstract/fnf_hud/healthbar/Destroy() + vis_contents.Cut() + QDEL_NULL(frame) + QDEL_NULL(left_bar) + QDEL_NULL(right_bar) + QDEL_NULL(left_portrait) + QDEL_NULL(right_portrait) + QDEL_NULL(announcer) + QDEL_NULL(shaped_frame) + QDEL_NULL(shaped_left) + QDEL_NULL(shaped_right) + return ..() + +/obj/effect/abstract/fnf_hud/healthbar/proc/make_bar(colour, width, height, layer_offset) + var/obj/effect/abstract/fnf_hud/bar = new + bar.icon_state = "bar" + bar.color = colour + bar.layer = ABOVE_ALL_MOB_LAYER + layer_offset + bar.pixel_w = center_x - 16 + bar.pixel_z = center_y - 16 + bar.transform = matrix(width / 32, 0, 0, 0, height / 32, 0) + vis_contents += bar + return bar + +/** + * Puts the bar in a frame of its own (Corruption+'s tainted one), the plain one gone: an icon of + * FNF_HUD_FRAME_WIDTH by 32 pixels, centred on the bar, with states "frame", "hole" (its opening, + * which the colours fill, whatever its shape) and "block" (solid, to cut the challenger's colour off + * with). The singers' heads stay in front of it. + */ +/obj/effect/abstract/fnf_hud/healthbar/proc/set_frame(frame_icon) + for(var/obj/effect/abstract/fnf_hud/plain as anything in list(frame, left_bar, right_bar)) + plain.alpha = 0 + shaped_left = make_shaped(frame_icon, "hole", left_bar.color, 0.02) + shaped_right = make_shaped(frame_icon, "hole", right_bar.color, 0.03) + shaped_right.add_filter("split", 1, alpha_mask_filter(icon = icon(frame_icon, "block"))) + shaped_frame = make_shaped(frame_icon, "frame", null, 0.035) + update(shown_health, instant = TRUE) + +/obj/effect/abstract/fnf_hud/healthbar/proc/make_shaped(frame_icon, state, colour, layer_offset) + var/obj/effect/abstract/fnf_hud/shaped = new + shaped.icon = frame_icon + shaped.icon_state = state + shaped.color = colour + shaped.layer = ABOVE_ALL_MOB_LAYER + layer_offset + shaped.pixel_w = center_x - FNF_HUD_FRAME_WIDTH / 2 + shaped.pixel_z = center_y - 16 + vis_contents += shaped + return shaped + +/// Draws the heads in other colours for now (see portrait_tints), or (with nothing) their own. +/obj/effect/abstract/fnf_hud/healthbar/proc/set_portrait_tints(left_tint, right_tint) + portrait_tints = (left_tint || right_tint) ? list(left_tint, right_tint) : null + update(shown_health, instant = TRUE) + +/// Hides a singer's head, or shows it again. +/obj/effect/abstract/fnf_hud/healthbar/proc/show_portrait(mob/living/singer, shown) + for(var/obj/effect/abstract/fnf_hud/portrait/portrait as anything in list(left_portrait, right_portrait)) + if(portrait?.singer == singer) + portrait.alpha = shown ? 255 : 0 + +/// Colours the bar: the opponent's side and the challenger's. +/obj/effect/abstract/fnf_hud/healthbar/proc/set_colours(left_colour, right_colour) + if(left_colour) + left_bar.color = left_colour + shaped_left?.color = left_colour + if(right_colour) + right_bar.color = right_colour + shaped_right?.color = right_colour + +/// Moves the split to match the health, which is how much of the bar is the challenger's. +/obj/effect/abstract/fnf_hud/healthbar/proc/update(health, instant = FALSE) + shown_health = health + var/width = max(FNF_BAR_WIDTH * health / FNF_HEALTH_MAX, 0.5) + var/time = instant ? 0 : 1 + animate(right_bar, transform = matrix(width / 32, 0, (FNF_BAR_WIDTH - width) / 2, 0, FNF_BAR_HEIGHT / 32, 0), time = time) + // Where the colours meet, from the bar's middle. + var/split = FNF_BAR_WIDTH / 2 - width + // In a frame of its own, the challenger's colour starts there. + if(shaped_right) + shaped_right.transition_filter("split", list("x" = FNF_HUD_FRAME_WIDTH / 2 + split), time) + for(var/obj/effect/abstract/fnf_hud/portrait/portrait as anything in list(left_portrait, right_portrait)) + if(!portrait) + continue + var/side_offset = portrait == left_portrait ? -13 : 13 + animate(portrait, pixel_w = center_x - 16 + split + side_offset, pixel_z = center_y - 16, time = time) + // Whoever's losing badly goes pale. + left_portrait?.color = portrait_tints?[1] || (health > 80 ? "#8888ff" : null) + right_portrait?.color = portrait_tints?[2] || (health < 20 ? "#8888ff" : null) + +/// Both portraits bob to the beat. +/obj/effect/abstract/fnf_hud/healthbar/proc/bop() + for(var/obj/effect/abstract/fnf_hud/portrait/portrait as anything in list(left_portrait, right_portrait)) + if(!portrait) + continue + portrait.refresh() + animate(portrait, transform = portrait.get_matrix(1.2), time = 0) + animate(transform = portrait.get_matrix(1), time = 2, easing = CUBIC_EASING|EASE_OUT) + +/// Takes a singer's portrait down, when the singer is going away. +/obj/effect/abstract/fnf_hud/healthbar/proc/drop_portrait(mob/living/singer) + if(left_portrait?.singer == singer) + QDEL_NULL(left_portrait) + if(right_portrait?.singer == singer) + QDEL_NULL(right_portrait) + +/obj/effect/abstract/fnf_hud/healthbar/proc/announce(text, colour = "#ffffff") + announcer.maptext = MAPTEXT("[text]") + animate(announcer, alpha = 255, transform = matrix() * 1.4, time = 0) + animate(transform = matrix(), time = 1.5, easing = BACK_EASING|EASE_OUT) + animate(alpha = 0, time = 3, delay = 1) + +/** + * A singer's head beside the health bar, facing their rival, like Funkin's health icons. For a + * rigged singer it's the rig's own head pieces, so it nods and sings along; for anyone else, a + * picture of them taken at the start. + */ +/obj/effect/abstract/fnf_hud/portrait + layer = ABOVE_ALL_MOB_LAYER + 0.04 + appearance_flags = RESET_COLOR|RESET_ALPHA|PIXEL_SCALE|KEEP_TOGETHER|KEEP_APART + var/mob/living/singer + /// Where the middle of the head is on the singer's tile, in pixels, and how much to blow it up. + var/head_x = 16 + var/head_y = 16 + var/head_scale = 1 + var/list/obj/effect/abstract/limb_rig_part/pieces = list() + +/obj/effect/abstract/fnf_hud/portrait/Initialize(mapload, mob/living/singer, facing) + . = ..() + src.singer = singer + dir = facing + refresh() + +/// Puts the singer's head up. A singer summoned for the battle only gets its rig a moment after +/// it appears, and a rig can be rebuilt mid-song, so this is checked again on every beat. +/obj/effect/abstract/fnf_hud/portrait/proc/refresh() + if(length(pieces)) + return + var/mob/living/carbon/carbon_singer = singer + var/datum/limb_rig/rig = istype(carbon_singer) ? carbon_singer.limb_rig : null + cut_overlays() + if(rig) + var/list/head = rig.get_portrait_head(dir) + head_x = head[1] + head_y = head[2] + head_scale = head[3] + for(var/obj/effect/abstract/limb_rig_part/piece as anything in rig.get_head_pieces()) + if(!piece) + continue + pieces += piece + vis_contents += piece + RegisterSignal(piece, COMSIG_QDELETING, PROC_REF(on_piece_deleted)) + else if(singer) + var/mutable_appearance/look = new(singer) + look.dir = dir + look.plane = FLOAT_PLANE + look.layer = FLOAT_LAYER + add_overlay(look) + head_x = 16 + head_y = 20 + head_scale = 0.8 + transform = get_matrix(1) + +/obj/effect/abstract/fnf_hud/portrait/Destroy() + for(var/obj/effect/abstract/limb_rig_part/piece as anything in pieces) + UnregisterSignal(piece, COMSIG_QDELETING) + pieces.Cut() + vis_contents.Cut() + cut_overlays() + singer = null + return ..() + +/obj/effect/abstract/fnf_hud/portrait/proc/on_piece_deleted(datum/source) + SIGNAL_HANDLER + pieces -= source + vis_contents -= source + +/// Centres the head and scales it, times bop for bobbing to the beat. +/obj/effect/abstract/fnf_hud/portrait/proc/get_matrix(bop = 1) + var/matrix/centred = matrix() + centred.Translate(16 - head_x, 16 - head_y) + centred.Scale(head_scale * bop) + return centred + +/// The pieces that draw the head, for showing it somewhere else. +/datum/limb_rig/proc/get_head_pieces() + return list(parts[RIG_HEAD]) + +/// list(x, y, scale): where the middle of the head is on the tile, and how much to scale it by +/// for a health bar portrait. +/datum/limb_rig/proc/get_portrait_head(facing) + var/list/neck = get_rig_joint(RIG_HEAD, facing) + return list(neck[1], neck[2] + 4, 1.6) + +/datum/limb_rig/sprites/get_head_pieces() + return list(parts[RIG_HEAD], cloth_parts[RIG_HEAD], face_part) + +/datum/limb_rig/sprites/get_portrait_head(facing) + var/list/bones = skeleton[dir2text(facing)] + var/list/neck = bones[RIG_HEAD] + var/list/crown = bones["crown"] || list(neck[1], neck[2] + 14) + return list(neck[1], neck[2] * 0.7 + crown[2] * 0.3, 1) + +/// A species' own head on the generated body is a human head drawn a quarter bigger: smaller +/// than an Experiment's, so it's framed closer. +/datum/limb_rig/sprites/humanoid/get_portrait_head(facing) + var/list/bones = skeleton[dir2text(facing)] + var/list/neck = bones[RIG_HEAD] + var/list/crown = bones["crown"] + return list(neck[1], (neck[2] + crown[2]) / 2, 1.4) diff --git a/voidcrew/modules/fnf/icons/corruption_32.dmi b/voidcrew/modules/fnf/icons/corruption_32.dmi new file mode 100644 index 00000000000..3f59f0a3612 Binary files /dev/null and b/voidcrew/modules/fnf/icons/corruption_32.dmi differ diff --git a/voidcrew/modules/fnf/icons/corruption_64.dmi b/voidcrew/modules/fnf/icons/corruption_64.dmi new file mode 100644 index 00000000000..47aeb44f521 Binary files /dev/null and b/voidcrew/modules/fnf/icons/corruption_64.dmi differ diff --git a/voidcrew/modules/fnf/icons/corruption_coats.dmi b/voidcrew/modules/fnf/icons/corruption_coats.dmi new file mode 100644 index 00000000000..f428ec64809 Binary files /dev/null and b/voidcrew/modules/fnf/icons/corruption_coats.dmi differ diff --git a/voidcrew/modules/fnf/icons/fnf.dmi b/voidcrew/modules/fnf/icons/fnf.dmi new file mode 100644 index 00000000000..2ee432604a4 Binary files /dev/null and b/voidcrew/modules/fnf/icons/fnf.dmi differ diff --git a/voidcrew/modules/fnf/icons/fnf_buttons.dmi b/voidcrew/modules/fnf/icons/fnf_buttons.dmi new file mode 100644 index 00000000000..8de9df8b423 Binary files /dev/null and b/voidcrew/modules/fnf/icons/fnf_buttons.dmi differ diff --git a/voidcrew/modules/fnf/icons/fnf_corruption_overlays.dmi b/voidcrew/modules/fnf/icons/fnf_corruption_overlays.dmi new file mode 100644 index 00000000000..f9bf6f3b3cc Binary files /dev/null and b/voidcrew/modules/fnf/icons/fnf_corruption_overlays.dmi differ diff --git a/voidcrew/modules/fnf/icons/fnf_dead.dmi b/voidcrew/modules/fnf/icons/fnf_dead.dmi new file mode 100644 index 00000000000..0052a221a12 Binary files /dev/null and b/voidcrew/modules/fnf/icons/fnf_dead.dmi differ diff --git a/voidcrew/modules/fnf/icons/fnf_faces.dmi b/voidcrew/modules/fnf/icons/fnf_faces.dmi new file mode 100644 index 00000000000..300f2754460 Binary files /dev/null and b/voidcrew/modules/fnf/icons/fnf_faces.dmi differ diff --git a/voidcrew/modules/fnf/icons/fnf_faces_corrupt.dmi b/voidcrew/modules/fnf/icons/fnf_faces_corrupt.dmi new file mode 100644 index 00000000000..65e86bd0eb7 Binary files /dev/null and b/voidcrew/modules/fnf/icons/fnf_faces_corrupt.dmi differ diff --git a/voidcrew/modules/fnf/icons/fnf_faces_experiment.dmi b/voidcrew/modules/fnf/icons/fnf_faces_experiment.dmi new file mode 100644 index 00000000000..54c02723b5b Binary files /dev/null and b/voidcrew/modules/fnf/icons/fnf_faces_experiment.dmi differ diff --git a/voidcrew/modules/fnf/icons/fnf_faces_experiment_corrupt.dmi b/voidcrew/modules/fnf/icons/fnf_faces_experiment_corrupt.dmi new file mode 100644 index 00000000000..8de0634b3aa Binary files /dev/null and b/voidcrew/modules/fnf/icons/fnf_faces_experiment_corrupt.dmi differ diff --git a/voidcrew/modules/fnf/icons/fnf_healthbar_tainted.dmi b/voidcrew/modules/fnf/icons/fnf_healthbar_tainted.dmi new file mode 100644 index 00000000000..63c34732961 Binary files /dev/null and b/voidcrew/modules/fnf/icons/fnf_healthbar_tainted.dmi differ diff --git a/voidcrew/modules/fnf/icons/fnf_notes_kapi.dmi b/voidcrew/modules/fnf/icons/fnf_notes_kapi.dmi new file mode 100644 index 00000000000..1724b52d827 Binary files /dev/null and b/voidcrew/modules/fnf/icons/fnf_notes_kapi.dmi differ diff --git a/voidcrew/modules/fnf/icons/fnf_notes_skarlet.dmi b/voidcrew/modules/fnf/icons/fnf_notes_skarlet.dmi new file mode 100644 index 00000000000..f3892d428dd Binary files /dev/null and b/voidcrew/modules/fnf/icons/fnf_notes_skarlet.dmi differ diff --git a/voidcrew/modules/fnf/icons/fnf_props.dmi b/voidcrew/modules/fnf/icons/fnf_props.dmi new file mode 100644 index 00000000000..2a89b869813 Binary files /dev/null and b/voidcrew/modules/fnf/icons/fnf_props.dmi differ diff --git a/voidcrew/modules/fnf/icons/fnf_speakers.dmi b/voidcrew/modules/fnf/icons/fnf_speakers.dmi new file mode 100644 index 00000000000..06714cb04ef Binary files /dev/null and b/voidcrew/modules/fnf/icons/fnf_speakers.dmi differ diff --git a/voidcrew/modules/fnf/icons/laststand.dmi b/voidcrew/modules/fnf/icons/laststand.dmi new file mode 100644 index 00000000000..fb89d9f3a67 Binary files /dev/null and b/voidcrew/modules/fnf/icons/laststand.dmi differ diff --git a/voidcrew/modules/fnf/microphone.dm b/voidcrew/modules/fnf/microphone.dm new file mode 100644 index 00000000000..d9487868eab --- /dev/null +++ b/voidcrew/modules/fnf/microphone.dm @@ -0,0 +1,157 @@ +/// Starts rhythm battles. Pick a song in hand, then sing it at someone, or at nobody and see +/// who turns up. +/obj/item/fnf_microphone + name = "battle microphone" + desc = "A chunky wireless microphone with four coloured arrows down its handle. It feels like it wants a rival." + icon = 'icons/obj/service/broadcast.dmi' + icon_state = "microphone" + inhand_icon_state = "microphone" + lefthand_file = 'icons/mob/inhands/items/devices_lefthand.dmi' + righthand_file = 'icons/mob/inhands/items/devices_righthand.dmi' + w_class = WEIGHT_CLASS_SMALL + /// The battle this microphone has lined up or is running. + var/datum/fnf_battle/battle + +/obj/item/fnf_microphone/Destroy() + if(battle?.state == FNF_STATE_READY) + QDEL_NULL(battle) + battle = null + return ..() + +/obj/item/fnf_microphone/examine(mob/user) + . = ..() + if(battle?.state == FNF_STATE_READY) + . += span_notice("It's cued up to [battle.song.name] ([battle.difficulty]).") + . += span_notice("Alt-click it to set how late your speakers are, or to turn the zoom off.") + +/obj/item/fnf_microphone/attack_self(mob/user, modifiers) + . = ..() + if(.) + return + if(battle && battle.state != FNF_STATE_READY) + balloon_alert(user, "mid-song!") + return TRUE + INVOKE_ASYNC(src, PROC_REF(pick_song), user) + return TRUE + +/obj/item/fnf_microphone/proc/pick_song(mob/living/user) + var/list/weeks = get_fnf_weeks() + if(!length(weeks)) + balloon_alert(user, "no songs!") + return + var/list/songs + if(length(weeks) == 1) + songs = weeks[1]["songs"] + else + var/list/week_names = list() + for(var/list/week as anything in weeks) + week_names[week["name"]] = week["songs"] + var/week_choice = tgui_input_list(user, "Pick a week", "Rhythm battle", week_names) + songs = week_names[week_choice] + if(!songs || !user.is_holding(src)) + return + var/list/song_names = list() + for(var/datum/fnf_song/listed as anything in songs) + song_names[listed.list_name] = listed + var/choice = tgui_input_list(user, "Pick a song", "Rhythm battle", song_names) + var/datum/fnf_song/song = song_names[choice] + if(!song || !user.is_holding(src)) + return + var/difficulty = song.difficulties[1] + if(length(song.difficulties) > 1) + var/default = ("hard" in song.difficulties) ? "hard" : song.difficulties[1] + difficulty = tgui_input_list(user, "How hard?", "Rhythm battle", song.difficulties, default) + if(!difficulty || !user.is_holding(src)) + return + var/rival = tgui_alert(user, "Who are you singing against?", "Rhythm battle", list("Summon someone", "I'll pick someone")) + if(!rival || !user.is_holding(src)) + return + if(battle && battle.state != FNF_STATE_READY) + return + QDEL_NULL(battle) + battle = new(song, difficulty, src) + battle.preload(user) + if(rival == "Summon someone") + battle.start(user, null) + return + to_chat(user, span_notice("You cue up [song.name]. Hit someone with the microphone to challenge them.")) + user.visible_message(span_notice("[user] taps [src], looking for someone to sing at.")) + +/obj/item/fnf_microphone/interact_with_atom(atom/interacting_with, mob/living/user, list/modifiers) + if(!isliving(interacting_with) || interacting_with == user || battle?.state != FNF_STATE_READY) + return NONE + INVOKE_ASYNC(src, PROC_REF(challenge), user, interacting_with) + return ITEM_INTERACT_SUCCESS + +/obj/item/fnf_microphone/proc/challenge(mob/living/user, mob/living/rival) + if(rival.stat != CONSCIOUS) + balloon_alert(user, "they're in no state to sing!") + return + var/datum/fnf_battle/offered = battle + if(rival.client) + // While they make up their mind, their game downloads the song. + offered.preload(rival) + user.visible_message(span_notice("[user] shoves [src] at [rival], challenging [rival.p_them()] to a rhythm battle!")) + var/answer = tgui_alert(rival, "[user] challenges you to a rhythm battle: [offered.song.name] ([offered.difficulty]). Sing?", "Rhythm battle", list("Bring it on", "No thanks"), 20 SECONDS) + if(answer != "Bring it on") + to_chat(user, span_warning("[rival] doesn't want to sing.")) + return + if(QDELETED(offered) || battle != offered || offered.state != FNF_STATE_READY || !user.is_holding(src)) + return + if(get_dist(user, rival) > 7 || rival.stat != CONSCIOUS || user.stat != CONSCIOUS) + return + offered.start(user, rival) + +/obj/item/fnf_microphone/click_alt(mob/user) + var/client/player = user.client + if(!player) + return CLICK_ACTION_BLOCKING + var/zoom_label = GLOB.fnf_no_zoom[player.ckey] ? "Zoom: off" : "Zoom: on" + var/bot_label = GLOB.fnf_botplay[player.ckey] ? "Botplay: on" : "Botplay: off" + var/choice = tgui_input_list(user, "Battle settings", "Battle microphone", list("Audio offset", zoom_label, bot_label, "Preview a game over")) + if(!choice || QDELETED(player)) + return CLICK_ACTION_BLOCKING + if(choice == bot_label) + if(GLOB.fnf_botplay[player.ckey]) + GLOB.fnf_botplay -= player.ckey + to_chat(user, span_notice("Botplay off: you'll sing your own notes.")) + else + GLOB.fnf_botplay[player.ckey] = TRUE + to_chat(user, span_notice("Botplay on: your notes will hit themselves, every one dead on.")) + return CLICK_ACTION_SUCCESS + if(choice == "Preview a game over") + INVOKE_ASYNC(src, PROC_REF(preview_game_over), user) + return CLICK_ACTION_SUCCESS + if(choice == zoom_label) + if(GLOB.fnf_no_zoom[player.ckey]) + GLOB.fnf_no_zoom -= player.ckey + to_chat(user, span_notice("Your screen will zoom in on the stage while you sing.")) + else + GLOB.fnf_no_zoom[player.ckey] = TRUE + to_chat(user, span_notice("Your screen won't zoom in while you sing.")) + return CLICK_ACTION_SUCCESS + var/offset = tgui_input_number(user, "How late your sound comes out, in milliseconds. Raise it if you're always hitting late, lower it if you're early.", "Audio offset", GLOB.fnf_offsets[player.ckey] || 0, 500, -300) + if(isnull(offset) || QDELETED(player)) + return CLICK_ACTION_BLOCKING + GLOB.fnf_offsets[player.ckey] = offset + to_chat(user, span_notice("Your audio offset is now [offset]ms. Your ping ([round(player.avgping)]ms) is already taken off every press.")) + return CLICK_ACTION_SUCCESS + +/// Plays a game over on the spot, without losing a song first, to see how it looks. +/obj/item/fnf_microphone/proc/preview_game_over(mob/living/user) + var/list/deaths = list("Knife (most songs)" = "knife", "Spray can (missing a shot in 2hot)" = "explode", "Gut punch (Blazin')" = "gutpunch") + if(is_species(user, /datum/species/experiment)) + deaths = list("Shot (every song)" = "shot") + var/choice = tgui_input_list(user, "Which game over?", "Battle microphone", deaths) + if(!choice || !user.is_holding(src) || user.stat != CONSCIOUS || user.client == null) + return + if(battle && battle.state != FNF_STATE_READY) + balloon_alert(user, "mid-song!") + return + if(user.screens["fnf_blackout"]) + return + new /datum/fnf_game_over(user, null, null, FALSE, user.dir == EAST ? EAST : WEST, null, deaths[choice]) + +/datum/loadout_item/pocket_items/fnf_microphone + name = "Battle Microphone" + item_path = /obj/item/fnf_microphone diff --git a/voidcrew/modules/fnf/opponents.dm b/voidcrew/modules/fnf/opponents.dm new file mode 100644 index 00000000000..6802ec434a6 --- /dev/null +++ b/voidcrew/modules/fnf/opponents.dm @@ -0,0 +1,392 @@ +/** + * Who turns up to sing against a lone challenger: the song's own opponent, matched as closely as + * the station gets to their Funkin' sprite. Skin, hair and eyes are coloured to the sprite, and + * the outfit is recoloured to match (most station clothes can take any colour). Unknown + * characters (and the song that ships with the game) get Experiment Dearest. + * + * Everyone summoned holds a battle microphone (Pico and Tankman a gun as well), moves in their own + * style (see fnf_apply_style) and is rigged like a roundstart species even if they aren't one. + * + * Each entry, by character (as Funkin' names them, without variants): + * - "name", "species" + * - "skin": a colour for the skin, or "tone": one of the human skin tones + * - "female": TRUE for a woman's body + * - "hair", "hair_colour", "eyes" + * - "outfit": list(list(item type, greyscale colours or null), ...) + * - "mutant_colour", "ethereal_colour": for lizards and ethereals + * - "gun": TRUE to hold a gun as well as the mic, or the prop gun's type + * - "size": how big they're drawn, for the ones who tower over (or come up short of) Boyfriend + * - "socks": which socks they wear; "hair_gradient": list(gradient style, colour) for their hair + * - "face_eyes": the eyes their face has in a battle (see faces.dm), if not solid black: "white" for + * the ones Funkin' draws with white eyes, "demon" for Daddy and Mommy Dearest's red glare + */ +GLOBAL_LIST_INIT(fnf_opponents, list( + "dad" = list( + "face_eyes" = "demon", + "name" = "Daddy Dearest", + "species" = /datum/species/human, + "size" = 1.35, + "skin" = "#c9a2dc", + "hair" = "Swept Back Hair", + "hair_colour" = "#5b6187", + "eyes" = "#6b2f8c", + "outfit" = list( + list(/obj/item/clothing/under/suit/black, null), + list(/obj/item/clothing/shoes/laceup, null), + ), + ), + "mom" = list( + "face_eyes" = "demon", + "name" = "Mommy Mearest", + "species" = /datum/species/human, + "size" = 1.3, + "female" = TRUE, + "skin" = "#e2c4ee", + "hair" = "Very Long Hair", + "hair_colour" = "#6e1510", + "eyes" = "#7a2a9c", + "outfit" = list( + list(/obj/item/clothing/under/dress/eveninggown, "#d0102c"), + list(/obj/item/clothing/shoes/jackboots, null), + ), + ), + "pico" = list( + "face_eyes" = "white", + "name" = "Pico", + "species" = /datum/species/human, + "tone" = "caucasian1", + "hair" = "Spiky", + "hair_colour" = "#ff7a2a", + "eyes" = "#3fbf4f", + "gun" = TRUE, + "outfit" = list( + // Green shirt, tan trousers (the colours are shirt, buckle, belt, trousers). + list(/obj/item/clothing/under/costume/buttondown/slacks, "#3fa34d#c0c0c0#5a3a22#c89a64"), + list(/obj/item/clothing/shoes/sneakers, "#8a1f2a#f2e6d8"), + ), + ), + "darnell" = list( + "face_eyes" = "white", + // Red-brown skin, a huge purple flat-top, purple hoodie, near-black grey-green trousers, and + // big white sneakers with orange flames. + "name" = "Darnell", + "species" = /datum/species/human, + "skin" = "#a83838", + "hair" = "Flat Top (Big)", + "hair_colour" = "#4a3c78", + "eyes" = "#1a1a1a", + "outfit" = list( + list(/obj/item/clothing/under/color, "#283028"), + list(/obj/item/clothing/suit/jacket/oversized, "#803888"), + list(/obj/item/clothing/shoes/sneakers, "#f8f4f8#e8952a"), + ), + ), + "nene" = list( + "face_eyes" = "white", + "name" = "Nene", + "species" = /datum/species/human, + "female" = TRUE, + "tone" = "caucasian1", + "hair" = "Messy", + "hair_colour" = "#2b2e55", + "eyes" = "#d02030", + "outfit" = list( + list(/obj/item/clothing/under/dress/skirt, "#d83a52#f28cb0"), + list(/obj/item/clothing/shoes/sneakers, "#f28cb0#ffffff"), + ), + ), + "gf" = list( + "name" = "Girlfriend", + "species" = /datum/species/human, + "female" = TRUE, + "tone" = "caucasian1", + "hair" = "Very Long Hair", + "hair_colour" = "#7a1a14", + "eyes" = "#35b7c7", + "outfit" = list( + list(/obj/item/clothing/under/dress/eveninggown, "#e0203a"), + list(/obj/item/clothing/shoes/sneakers, "#d01a2a#d01a2a"), + ), + ), + "senpai" = list( + // Orange hair, a pale blue-lavender shirt with a pink tie, dark grey trousers, black shoes. + "name" = "Senpai", + "species" = /datum/species/human, + "tone" = "caucasian1", + "hair" = "Long Fringe", + "hair_colour" = "#f2a65a", + "eyes" = "#3a3a5a", + "outfit" = list( + list(/obj/item/clothing/under/costume/buttondown/slacks, "#9aa0f0#c0c0c8#2a2a30#3a3a46"), + list(/obj/item/clothing/neck/tie, "#e0507a"), + list(/obj/item/clothing/shoes/laceup, null), + ), + ), + "tankman" = list( + "name" = "Tankman", + "species" = /datum/species/human, + "size" = 1.1, + "tone" = "caucasian2", + "hair" = "Crewcut", + "hair_colour" = "#1a1410", + "eyes" = "#1a1a1a", + "gun" = TRUE, + "outfit" = list( + // All in black, as he's drawn. + list(/obj/item/clothing/under/color, "#1c1c1c"), + list(/obj/item/clothing/head/helmet, null), + list(/obj/item/clothing/mask/bandana/black, null), + list(/obj/item/clothing/shoes/jackboots, null), + ), + ), + "bf" = list( + "name" = "Boyfriend", + "species" = /datum/species/human, + "tone" = "caucasian1", + "hair" = "Spiky", + "hair_colour" = "#35c6ff", + "eyes" = "#1a1a2a", + "outfit" = list( + // White shirt, blue shorts, red cap and sneakers. + list(/obj/item/clothing/under/costume/buttondown/shorts, "#f4f4f4#d02030#2d5bc8#2d2d33"), + list(/obj/item/clothing/head/soft/red, null), + list(/obj/item/clothing/shoes/sneakers, "#d02030#ffffff"), + ), + ), + "monster" = list( + // A black body with a lemon for a head. + "name" = "Monster", + "species" = /datum/species/lizard, + "size" = 1.4, + "mutant_colour" = "#f0dc3a", + "eyes" = "#1a1a1a", + "outfit" = list( + list(/obj/item/clothing/under/color, "#141414"), + list(/obj/item/clothing/shoes/sneakers, "#141414#141414"), + ), + ), + "spooky" = list( + // Skid in his skeleton costume, Pump's pumpkin on his head. + "name" = "Skid & Pump", + "species" = /datum/species/skeleton, + "size" = 0.85, + "outfit" = list( + list(/obj/item/clothing/under/costume/skeleton, null), + list(/obj/item/clothing/head/utility/hardhat/pumpkinhead, null), + ), + ), + "henchman" = list( + // Mommy Mearest's limo dancers: pink skin, spiky black hair, black shades, a purple suit + // jacket over a pink shirt and red tie, baggy grey-green trousers and dark green shoes. + "name" = "Mommy's henchman", + "species" = /datum/species/human, + "skin" = "#f07ab4", + "hair" = "Spiky", + "hair_colour" = "#241826", + "eyes" = "#1a1a1a", + "outfit" = list( + list(/obj/item/clothing/under/costume/buttondown/slacks, "#f08ab8#c0283a#5c6e5a#2a3a2a"), + list(/obj/item/clothing/suit/jacket/oversized, "#5a3a8a"), + list(/obj/item/clothing/glasses/sunglasses, null), + list(/obj/item/clothing/shoes/sneakers, "#3a5a44#2a3a30"), + ), + ), + "otis" = list( + "face_eyes" = "white", + // Darnell's mate, up on the speaker in the Pico mix of Stress with his rifle: pale, messy black + // hair, a purple-indigo jacket, grey trousers, orange and white sneakers. + "name" = "Otis", + "species" = /datum/species/human, + "tone" = "caucasian1", + "hair" = "Messy", + "hair_colour" = "#141418", + "eyes" = "#2a1a10", + "gun" = /obj/item/toy/fnf_gun/rifle, + "outfit" = list( + list(/obj/item/clothing/under/costume/buttondown/slacks, "#4a3aa0#2a2266#6a6a72#2a2a2a"), + list(/obj/item/clothing/suit/jacket/oversized, "#3e2e8e"), + list(/obj/item/clothing/shoes/sneakers, "#f0a020#f4f4f4"), + ), + ), + "spirit" = list( + "name" = "Spirit", + "species" = /datum/species/ethereal, + "ethereal_colour" = "#ff3a3a", + ), + // Corruption+'s (see corruption.dm), as their sprites have them. + "kapi" = list( + // PaperKitty's arcade cat: grey fur and hair, a blue jacket, dark grey trousers, blue sneakers. + "name" = "Kapi", + "species" = /datum/species/human/felinid, + "skin" = "#b8b8c4", + "hair" = "Messy", + "hair_colour" = "#848484", + "eyes" = "#1a1a24", + "outfit" = list( + list(/obj/item/clothing/under/color, "#3c3c48"), + list(/obj/item/clothing/suit/jacket/oversized, "#4860c0"), + list(/obj/item/clothing/shoes/sneakers, "#4860c0#f4f4f4"), + ), + ), + "skarlet" = list( + // Rechi's Skarlet Bunny: pink-white fur, hot pink hair, bunny ears, a blue jacket over white, + // black boots. + "name" = "Skarlet", + "species" = /datum/species/human, + "female" = TRUE, + "skin" = "#f8d4e8", + "hair" = "Long Fringe", + "hair_colour" = "#fc006c", + "eyes" = "#78006c", + "outfit" = list( + list(/obj/item/clothing/under/costume/buttondown/shorts, "#fce4f0#fc006c#b4a8fc#303048"), + list(/obj/item/clothing/suit/jacket/oversized, "#0c609c"), + list(/obj/item/clothing/head/costume/rabbitears, null), + list(/obj/item/clothing/shoes/jackboots, null), + ), + ), + "carol" = list( + // bb-panzu's Carol: brown skin, a big black afro, a maroon turtleneck over a black skirt, + // striped knee socks and light brown boots. + "name" = "Carol", + "species" = /datum/species/human, + "female" = TRUE, + "skin" = "#6e4424", + "hair" = "Afro (Large)", + "hair_colour" = "#161214", + "eyes" = "#1a1a1a", + "socks" = "Knee-high (Striped)", + "outfit" = list( + list(/obj/item/clothing/under/dress/skirt/turtleskirt, "#6e1e3c#1a1a1e"), + list(/obj/item/clothing/shoes/winterboots, null), + ), + ), + "mora" = list( + // Broken Wires' last surprise: pale, black hair going purple, a purple top, black cargo + // trousers, white sneakers. + "name" = "Mora", + "species" = /datum/species/human, + "female" = TRUE, + "skin" = "#f0f0fc", + "hair" = "Messy", + "hair_colour" = "#24243c", + "hair_gradient" = list("Fade Down", "#543ca8"), + "eyes" = "#301890", + "outfit" = list( + list(/obj/item/clothing/under/costume/buttondown/slacks, "#543ca8#c0c0c8#24243c#24243c"), + list(/obj/item/clothing/shoes/sneakers, "#f0f0fc#f0f0fc"), + ), + ), + "marble" = list( + // A cat girl behind the speaker, only ever seen long corrupted: dark all over. + "name" = "Marble", + "species" = /datum/species/human/felinid, + "female" = TRUE, + "skin" = "#3c3c48", + "hair" = "Long Fringe", + "hair_colour" = "#242424", + "eyes" = "#1a1a1a", + "outfit" = list( + list(/obj/item/clothing/under/color, "#242424"), + list(/obj/item/clothing/shoes/jackboots, null), + ), + ), +)) + +/// Anyone without a look of their own is played by an Experiment (a Milkie, stand_in, in a Pico mix). +/proc/fnf_summon_opponent(character, turf/spot, stand_in = /datum/species/experiment) + // Week 5's "parents" are Mommy and Daddy together; Daddy comes. + if(character == "parents") + character = "dad" + var/list/look = GLOB.fnf_opponents[character] + var/mob/living/carbon/human/npc = new(spot) + if(!look) + npc.set_species(stand_in) + npc.fully_replace_character_name(npc.real_name, "[npc.dna.species.name] Dearest") + npc.put_in_r_hand(new /obj/item/fnf_microphone(npc)) + return npc + + if(look["female"]) + npc.gender = FEMALE + npc.physique = FEMALE + if(look["tone"]) + npc.skin_tone = look["tone"] + if(look["mutant_colour"]) + npc.dna.features[FEATURE_MUTANT_COLOR] = look["mutant_colour"] + if(look["ethereal_colour"]) + npc.dna.features[FEATURE_ETHEREAL_COLOR] = look["ethereal_colour"] + npc.set_species(look["species"]) + npc.fully_replace_character_name(npc.real_name, look["name"]) + npc.set_facial_hairstyle("Shaved", update = FALSE) + if(look["hair"]) + npc.set_hairstyle(look["hair"], update = FALSE) + npc.set_haircolor(look["hair_colour"], update = FALSE) + var/list/gradient = look["hair_gradient"] + if(gradient) + npc.grad_style[GRADIENT_HAIR_KEY] = gradient[1] + npc.grad_color[GRADIENT_HAIR_KEY] = gradient[2] + if(look["socks"]) + npc.socks = look["socks"] + if(look["eyes"]) + npc.set_eye_color(look["eyes"], look["eyes"]) + // Skin no human skin tone covers (Daddy and Mommy are lavender). + if(look["skin"]) + for(var/obj/item/bodypart/limb as anything in npc.bodyparts) + limb.add_color_override(look["skin"], FNF_SKIN_PRIORITY) + npc.update_body(is_creating = TRUE) + + for(var/list/piece as anything in look["outfit"]) + var/item_type = piece[1] + var/obj/item/clothing/worn = new item_type(npc) + if(piece[2]) + worn.set_greyscale(piece[2]) + if(!npc.equip_to_appropriate_slot(worn)) + qdel(worn) + + if(look["size"]) + npc.update_transform(look["size"]) + npc.fnf_face_eyes = look["face_eyes"] + npc.fnf_look = character + if(!npc.dna.species.limb_rig_shape?["always"]) + npc.add_quirk(/datum/quirk/overanimated) + // Everyone faces east, so the right hand is the one the crowd sees. Gunners keep the gun there + // and the mic in the other. + if(look["gun"]) + var/gun_type = ispath(look["gun"]) ? look["gun"] : /obj/item/toy/fnf_gun + npc.put_in_r_hand(new gun_type(npc)) + npc.put_in_l_hand(new /obj/item/fnf_microphone(npc)) + else + npc.put_in_r_hand(new /obj/item/fnf_microphone(npc)) + return npc + +/// Pico's (and Tankman's) gun: a stage prop, as big and mean-looking as a real submachine gun, that +/// fires nothing. Handed out for a song and taken back after. +/obj/item/toy/fnf_gun + name = "prop submachine gun" + desc = "A chunky plastic submachine gun painted up to look like the real thing. The barrel is solid, and it rattles when shaken." + icon = 'icons/obj/weapons/guns/ballistic.dmi' + icon_state = "c20r" + inhand_icon_state = "c20r" + lefthand_file = 'icons/mob/inhands/weapons/guns_lefthand.dmi' + righthand_file = 'icons/mob/inhands/weapons/guns_righthand.dmi' + w_class = WEIGHT_CLASS_NORMAL + +/// Stress's tankmen, running in to be shot off the stage: security officers in their armour, with +/// prop guns (glued in their hands) and nothing real on them. +/datum/outfit/fnf_soldier + name = "Rhythm battle soldier" + uniform = /obj/item/clothing/under/rank/security/officer + suit = /obj/item/clothing/suit/armor/vest/alt/sec + head = /obj/item/clothing/head/helmet/sec + shoes = /obj/item/clothing/shoes/jackboots + gloves = /obj/item/clothing/gloves/color/black + r_hand = /obj/item/toy/fnf_gun + +/// Otis's rifle, a wooden-stocked prop. +/obj/item/toy/fnf_gun/rifle + name = "prop rifle" + desc = "A wooden-stocked plastic rifle, painted up to look like the real thing. The bolt doesn't move." + icon = 'icons/obj/weapons/guns/wide_guns.dmi' + icon_state = "sakhno" + inhand_icon_state = "sakhno" + w_class = WEIGHT_CLASS_BULKY diff --git a/voidcrew/modules/fnf/poses.dm b/voidcrew/modules/fnf/poses.dm new file mode 100644 index 00000000000..93e72f8ab5c --- /dev/null +++ b/voidcrew/modules/fnf/poses.dm @@ -0,0 +1,813 @@ +/** + * How singers move in a rhythm battle, after how Boyfriend moves in Friday Night Funkin': + * - Between notes, they bop on every beat, dipping down and springing back up, mic bobbing. + * - A note snaps them into its pose, overshooting and settling, and a long note makes them + * vibrate in it for as long as it's held. + * - The mic stays in one hand, up at the mouth. Singing at the rival thrusts it at them. + * + * The two singers face each other, like in Funkin': the challenger on the right looks left, the + * opponent looks right. So the arrow pointing at the rival means leaning in, and the one pointing + * away means rearing back. Rigged mobs (Experiments, and anyone Overanimated) do all that with the + * limb rig; anyone else gets a little bump. + */ + +/// Mic up at the mouth. +#define FNF_MIC_UP list("swing" = 50, "raise" = 10, "hand_y" = 27) +/// The free hand balled into a fist at the side. +#define FNF_FIST list("swing" = 15, "raise" = 10, "elbow" = 75) + +/// A deep copy of a pose, safe to change. +/proc/fnf_copy_pose(list/pose) + . = list() + for(var/part_id in pose) + var/list/entry = pose[part_id] + .[part_id] = entry.Copy() + +/// A pose with every angle scaled, for overshooting into it. Hand heights stay put. +/proc/fnf_scale_pose(list/pose, factor) + . = fnf_copy_pose(pose) + for(var/part_id in .) + var/list/entry = .[part_id] + for(var/key in entry) + if(key != "hand_y") + entry[key] *= factor + +/// A pose with one value nudged, for a held note's vibrato. +/proc/fnf_nudge_pose(list/pose, part_id, key, amount) + . = fnf_copy_pose(pose) + var/list/entry = .[part_id] + if(!entry) + entry = list() + .[part_id] = entry + entry[key] = (entry[key] || 0) + amount + +/** + * A battle pose. + * + * * kind - "rest", "bop", "hey", "miss", or a lane: 0 to 3 for left, down, up, right. Also the + * one-off moves events and special notes call for (see fnf_act()): "ugh", "kick", "cock", + * "shoot", and Blazin's fight: "punch_high", "punch_low", "block", "dodge_high", "dodge_low", + * "hit_high", "hit_low", "prep", "uppercut", "uppercut_hit", "taunt". And "aim", the gun + * levelled straight out ("aim_both" for two), and the backup dancers' "dance_left" and + * "dance_right". And Corruption+'s "scream", Kapi's "meow", "confused", and Marble's "stare". + * * mic_arm - the arm with the mic in it, RIG_L_ARM or RIG_R_ARM + * * facing - which way the singer faces, WEST or EAST, to tell which arrow points at the rival + * * style - a character's own way of moving on top (see fnf_apply_style), or null + */ +/proc/fnf_pose(kind, mic_arm, facing, style) + var/free_arm = mic_arm == RIG_L_ARM ? RIG_R_ARM : RIG_L_ARM + if(isnum(kind) && (kind == 0 || kind == 3)) + kind = (kind == 0) == (facing == WEST) ? "toward" : "away" + . = list() + .[mic_arm] = FNF_MIC_UP + .[free_arm] = FNF_FIST + switch(kind) + if("bop") + // Dipped: knees bent, squashed down, head and mic bobbing with it. + .[RIG_CHEST] = list("breath" = -0.035, "bend" = 8) + .[RIG_HEAD] = list("nod" = 10) + .[RIG_L_LEG] = list("swing" = 12, "knee" = 22) + .[RIG_R_LEG] = list("swing" = 12, "knee" = 22) + .[mic_arm] = list("swing" = 45, "raise" = 10, "hand_y" = 24) + .[RIG_TAIL] = list("lift" = -8) + if("toward") + // Leaning in at the rival, mic thrust right in their face, a foot stepping in. + .[RIG_CHEST] = list("bend" = 15) + .[RIG_HEAD] = list("nod" = -6) + .[mic_arm] = list("swing" = 90, "raise" = 5, "elbow" = 0) + .[free_arm] = list("swing" = -25, "raise" = 10, "elbow" = 60) + .[RIG_L_LEG] = list("swing" = 30, "knee" = 15) + .[RIG_R_LEG] = list("swing" = -15) + .[RIG_TAIL] = list("lift" = 25) + if("away") + // Rearing back, the free fist flung behind, mic still at the mouth. + .[RIG_CHEST] = list("bend" = -14) + .[RIG_HEAD] = list("nod" = -12) + .[mic_arm] = list("swing" = 60, "raise" = 10, "hand_y" = 28) + .[free_arm] = list("swing" = -70, "raise" = 15, "elbow" = 25) + .[RIG_L_LEG] = list("swing" = -20, "knee" = 10) + .[RIG_R_LEG] = list("swing" = 15) + .[RIG_TAIL] = list("lift" = -15) + if(1) + // Down: a deep crouch, head down, mic thrust down at the floor. + .[RIG_CHEST] = list("bend" = 28, "breath" = -0.05) + .[RIG_HEAD] = list("nod" = 20) + .[RIG_L_LEG] = list("swing" = 35, "knee" = 55) + .[RIG_R_LEG] = list("swing" = 35, "knee" = 55) + .[mic_arm] = list("swing" = 35, "raise" = 5, "elbow" = 0) + .[free_arm] = list("swing" = -20, "raise" = 15, "elbow" = 50) + .[RIG_TAIL] = list("lift" = -25) + if(2) + // Up: chin up, mic up high, up on the toes, fist in the air. + .[RIG_CHEST] = list("bend" = -10, "breath" = 0.05, "air" = 3) + .[RIG_HEAD] = list("nod" = -25) + .[mic_arm] = list("swing" = 100, "raise" = 5, "hand_y" = 32) + .[free_arm] = list("swing" = 150, "raise" = 10, "elbow" = 30) + .[RIG_TAIL] = list("lift" = 35) + if("miss") + // Recoiling: leaning back, arms up in a flinch. + .[RIG_CHEST] = list("bend" = -15, "breath" = -0.03) + .[RIG_HEAD] = list("nod" = -15) + .[mic_arm] = list("swing" = 60, "raise" = 15, "elbow" = 90) + .[free_arm] = list("swing" = 70, "raise" = 15, "elbow" = 90) + .[RIG_TAIL] = list("lift" = -30) + if("hey") + // Hey! Free arm thrown up, mic still at the mouth, up on the toes. + .[free_arm] = list("swing" = 170, "raise" = 10, "elbow" = 10) + .[RIG_CHEST] = list("air" = 4, "breath" = 0.04, "bend" = -6) + .[RIG_HEAD] = list("nod" = -15) + .[RIG_TAIL] = list("lift" = 35, "wag" = 20) + if("ugh") + // Ugh! Hunched over the mic, head dropped, free fist shaken low. + .[RIG_CHEST] = list("bend" = 22, "breath" = -0.04) + .[RIG_HEAD] = list("nod" = 28, "tilt" = 10) + .[free_arm] = list("swing" = 30, "raise" = 20, "elbow" = 100) + .[RIG_L_LEG] = list("swing" = 15, "knee" = 25) + .[RIG_R_LEG] = list("swing" = 15, "knee" = 25) + if("scream") + // Screaming in agony: rearing back, head thrown back and to the side, the free hand + // clamped to it and the mic arm up across the face. + .[RIG_CHEST] = list("bend" = -14, "breath" = 0.05) + .[RIG_HEAD] = list("nod" = -22, "tilt" = 14) + .[free_arm] = list("swing" = 130, "raise" = 25, "hand_y" = 29) + .[mic_arm] = list("swing" = 95, "raise" = 10, "elbow" = 75) + .[RIG_L_LEG] = list("swing" = 10, "knee" = 25) + .[RIG_R_LEG] = list("swing" = 10, "knee" = 25) + .[RIG_TAIL] = list("lift" = -35) + if("meow") + // Meow! Both paws curled up by the face, head cocked, tail up and swishing. + .[RIG_CHEST] = list("bend" = 6, "breath" = 0.03) + .[RIG_HEAD] = list("nod" = -8, "tilt" = 12) + .[free_arm] = list("swing" = 60, "raise" = 15, "elbow" = 110) + .[mic_arm] = list("swing" = 55, "raise" = 15, "elbow" = 115) + .[RIG_TAIL] = list("lift" = 30, "wag" = 18) + if("confused") + // Huh? Scratching the head, head cocked the other way. + .[RIG_CHEST] = list("bend" = -4) + .[RIG_HEAD] = list("nod" = 6, "tilt" = -16) + .[free_arm] = list("swing" = 125, "raise" = 20, "hand_y" = 30) + .[RIG_TAIL] = list("lift" = -10) + if("kick") + // Punting something up off the foot. + .[RIG_CHEST] = list("bend" = -12) + .[RIG_HEAD] = list("nod" = 10) + .[RIG_L_LEG] = list("swing" = 80, "knee" = 10) + .[RIG_R_LEG] = list("swing" = -10, "knee" = 15) + .[free_arm] = list("swing" = -40, "raise" = 30, "elbow" = 20) + if("cock") + // Racking the gun, held up by the face. + .[free_arm] = list("swing" = 120, "raise" = 5, "elbow" = 100) + .[RIG_HEAD] = list("nod" = -8) + .[RIG_CHEST] = list("bend" = -4) + if("shoot") + // Gun levelled up at the sky's corner, kicking back. + .[free_arm] = list("swing" = 125, "raise" = 5, "elbow" = 0) + .[RIG_CHEST] = list("bend" = -10) + .[RIG_HEAD] = list("nod" = -18) + if("punch_high", "punch_low") + // A straight jab from the free fist, the body turning into it. + var/low = kind == "punch_low" + .[free_arm] = list("swing" = low ? 70 : 95, "raise" = 5, "elbow" = 0) + .[mic_arm] = list("swing" = 60, "raise" = 10, "elbow" = 110) + .[RIG_CHEST] = list("bend" = low ? 26 : 14, "lean" = 4) + .[RIG_HEAD] = list("nod" = low ? 8 : -4) + .[RIG_L_LEG] = list("swing" = 30, "knee" = low ? 35 : 15) + .[RIG_R_LEG] = list("swing" = -20, "knee" = low ? 20 : 0) + if("block") + // Guard up: both forearms across the face. + .[free_arm] = list("swing" = 70, "raise" = 10, "elbow" = 120) + .[mic_arm] = list("swing" = 75, "raise" = 10, "elbow" = 115) + .[RIG_CHEST] = list("bend" = 12) + .[RIG_HEAD] = list("nod" = 18) + .[RIG_L_LEG] = list("swing" = 10, "knee" = 25) + .[RIG_R_LEG] = list("swing" = 10, "knee" = 25) + if("dodge_high", "dodge_low") + // Ducking under a punch, or swaying back out of one. + var/low = kind == "dodge_low" + .[RIG_CHEST] = list("bend" = low ? 40 : -28, "breath" = -0.04) + .[RIG_HEAD] = list("nod" = low ? 10 : -20) + .[RIG_L_LEG] = list("swing" = low ? 45 : -10, "knee" = low ? 70 : 10) + .[RIG_R_LEG] = list("swing" = low ? 45 : 20, "knee" = low ? 70 : 10) + .[free_arm] = list("swing" = 40, "raise" = 20, "elbow" = 90) + if("hit_high", "hit_low", "uppercut_hit") + // Rocked by a punch: head snapped back (or folded over one to the gut), arms flung. + var/low = kind == "hit_low" + var/big = kind == "uppercut_hit" + .[RIG_CHEST] = list("bend" = low ? 30 : (big ? -35 : -22), "breath" = -0.05, "air" = big ? 6 : 0) + .[RIG_HEAD] = list("nod" = low ? 25 : (big ? -40 : -30), "tilt" = 12) + .[free_arm] = list("swing" = low ? 30 : 110, "raise" = 40, "elbow" = 30) + .[mic_arm] = list("swing" = low ? 40 : 100, "raise" = 35, "elbow" = 40) + .[RIG_L_LEG] = list("swing" = -15, "knee" = 20) + .[RIG_R_LEG] = list("swing" = 20, "knee" = 10) + .[RIG_TAIL] = list("lift" = -30) + if("prep") + // Winding up an uppercut: crouched low, fist drawn right back. + .[RIG_CHEST] = list("bend" = 30) + .[RIG_HEAD] = list("nod" = -10) + .[free_arm] = list("swing" = -40, "raise" = 10, "elbow" = 100) + .[RIG_L_LEG] = list("swing" = 40, "knee" = 70) + .[RIG_R_LEG] = list("swing" = 30, "knee" = 60) + if("uppercut") + // And up: the fist driven skyward, off the ground with it. + .[free_arm] = list("swing" = 165, "raise" = 5, "elbow" = 20) + .[RIG_CHEST] = list("bend" = -12, "air" = 5) + .[RIG_HEAD] = list("nod" = -20) + .[RIG_L_LEG] = list("swing" = 10, "knee" = 30) + .[RIG_R_LEG] = list("swing" = -25) + if("aim", "aim_both") + // Gun levelled straight out at something, or both of them. + .[free_arm] = list("swing" = 90, "raise" = 5, "elbow" = 0) + if(kind == "aim_both") + .[mic_arm] = list("swing" = 82, "raise" = 12, "elbow" = 0) + .[RIG_CHEST] = list("bend" = 4) + if("dance_left", "dance_right") + // Hands up, hips swung out to one side and the head the other way. + var/way = kind == "dance_left" ? 1 : -1 + .[RIG_L_ARM] = list("swing" = 150, "raise" = 25 + 15 * way, "elbow" = 40) + .[RIG_R_ARM] = list("swing" = 150, "raise" = 25 - 15 * way, "elbow" = 40) + .[RIG_CHEST] = list("lean" = 8 * way, "bend" = 4) + .[RIG_HEAD] = list("tilt" = -12 * way, "nod" = 6) + .[way > 0 ? RIG_L_LEG : RIG_R_LEG] = list("swing" = 20, "knee" = 25) + if("stare") + // Corrupted Marble behind the speaker: glaring at the enemy, head cocked, the near arm (the + // free one) reached up and out at them, clear of her face, claws spread, the other hand + // back on the speaker behind her. + .[free_arm] = list("swing" = 110, "raise" = 20, "elbow" = 40) + .[mic_arm] = list("swing" = -35, "raise" = 10, "elbow" = 20) + .[RIG_CHEST] = list("bend" = 4) + .[RIG_HEAD] = list("nod" = 8, "tilt" = 10) + .[RIG_TAIL] = list("lift" = -10, "wag" = 10) + if("taunt") + // Come on then: beckoning, chin up, weight back. + .[free_arm] = list("swing" = 80, "raise" = 10, "elbow" = 80) + .[RIG_CHEST] = list("bend" = -10) + .[RIG_HEAD] = list("nod" = -18, "tilt" = -10) + if(style) + fnf_apply_style(., kind, style, mic_arm, free_arm) + +/// Which arm a singer has the mic in: the hand holding a battle microphone, or else the arm +/// nearer the crowd when facing that way. +/mob/living/proc/fnf_mic_arm(facing) + for(var/obj/item/fnf_microphone/microphone in held_items) + return IS_RIGHT_INDEX(get_held_index_of_item(microphone)) ? RIG_R_ARM : RIG_L_ARM + return facing == WEST ? RIG_L_ARM : RIG_R_ARM + +/// Snaps into an arrow's pose, holds it for hold deciseconds (vibrating if it's long), then goes back to the mic. +/mob/living/proc/fnf_sing(lane, hold, mic_arm, facing, style) + setDir(facing) + var/static/list/nudges = list(list(-3, 0), list(0, -3), list(0, 3), list(3, 0)) + var/list/nudge = nudges[lane + 1] + fnf_nudge(nudge[1], nudge[2]) + +/mob/living/carbon/fnf_sing(lane, hold, mic_arm, facing, style) + if(!limb_rig) + return ..() + fnf_hide_mic(style) + setDir(facing) + var/static/list/faces = list("left", "down", "up", "right") + // The character's own move, traced off their sprites, held for the note. + var/list/traced = fnf_traced_keyframes(style, "sing[uppertext(faces[lane + 1])]", facing) + if(traced) + limb_rig.play(fnf_traced_hold(traced, hold, fnf_traced_rest(style, mic_arm, facing)), settle_after = FALSE) + limb_rig.set_fnf_face(faces[lane + 1], max(hold, 1.5)) + fnf_idle_until = 0 + if((style == "pico" || style == "cpico") && (lane == 0 || lane == 3) && ((lane == 0) == (facing == WEST))) + fnf_spin_guns(mic_arm, style, 3) + return + var/list/pose = fnf_pose(lane, mic_arm, facing, style) + var/list/keyframes = list( + list(fnf_scale_pose(pose, 1.2), 0.5, CUBIC_EASING|EASE_OUT), + list(pose, 1, SINE_EASING), + ) + // Held notes: a vibrato, mic and head working with the voice. + var/held = hold - 1.5 + var/beat = 0 + while(held > 0.6) + beat++ + var/amount = beat % 2 ? 4 : -4 + var/list/wobble = fnf_nudge_pose(pose, mic_arm, "swing", amount) + wobble = fnf_nudge_pose(wobble, RIG_HEAD, "nod", amount * 0.6) + keyframes += list(list(wobble, 0.6, SINE_EASING)) + held -= 0.6 + keyframes += list(list(fnf_pose("rest", mic_arm, facing, style), 2, SINE_EASING)) + limb_rig.play(keyframes, settle_after = FALSE) + // The face for the note, held as long as the note is. + limb_rig.set_fnf_face(faces[lane + 1], 1.5 + beat * 0.6) + // Pico spins his gun round into aiming it at the rival. + if((style == "pico" || style == "cpico") && (lane == 0 || lane == 3) && ((lane == 0) == (facing == WEST))) + fnf_spin_guns(mic_arm, style, 3) + +/// The between-notes bop, one beat long. +/mob/living/proc/fnf_bop(beat_time, mic_arm, facing, style) + fnf_nudge(0, -1) + +/mob/living/carbon/fnf_bop(beat_time, mic_arm, facing, style) + if(!limb_rig) + return ..() + fnf_hide_mic(style) + if(!limb_rig.is_pulling_fnf_face()) + limb_rig.set_fnf_face("idle") + // A traced idle plays right through, then starts again on the next beat after. + var/list/idle = fnf_traced_keyframes(style, "idle", facing) || fnf_traced_sway(style, facing) + if(idle) + if(world.time < fnf_idle_until) + return + fnf_idle_until = world.time + fnf_keyframes_time(idle) - 0.5 + limb_rig.play(idle, settle_after = FALSE) + return + limb_rig.play(list( + list(fnf_pose("bop", mic_arm, facing, style), beat_time * 0.25, CUBIC_EASING|EASE_OUT), + list(fnf_pose("rest", mic_arm, facing, style), beat_time * 0.75, SINE_EASING), + ), settle_after = FALSE) + +/mob/living/proc/fnf_nudge(x_offset, z_offset) + // Back to where the mob rests (up on the speakers, say), not to the floor. + var/home_w = base_pixel_w + has_offset(null, PIXEL_W_OFFSET) + var/home_z = base_pixel_z + has_offset(null, PIXEL_Z_OFFSET) + animate(src, pixel_w = home_w + x_offset, pixel_z = home_z + z_offset, time = 0.5, flags = ANIMATION_PARALLEL) + animate(pixel_w = home_w, pixel_z = home_z, time = 2, easing = SINE_EASING) + +/** + * Hanging there instead of bopping, still but for a twitch now and then: Purification's Girlfriend, + * once Boyfriend's swooped in to sing for her. + */ +/mob/living/proc/fnf_hang(beat_time, mic_arm, facing, style) + if(prob(25)) + fnf_nudge(pick(-1, 1), 0) + +/mob/living/carbon/fnf_hang(beat_time, mic_arm, facing, style) + if(!limb_rig) + return ..() + if(!limb_rig.is_pulling_fnf_face()) + limb_rig.set_fnf_face("idle") + // Purification's Girlfriend has her own, traced. + var/list/traced = fnf_traced_keyframes(style, "idle-alt", facing) + if(traced) + if(world.time >= fnf_idle_until) + fnf_idle_until = world.time + fnf_keyframes_time(traced) - 0.5 + limb_rig.play(traced, settle_after = FALSE) + return + var/list/pose = fnf_pose("rest", mic_arm, facing, style) + if(!prob(30)) + limb_rig.play(list(list(pose, beat_time, SINE_EASING)), settle_after = FALSE) + return + var/list/twitch = fnf_nudge_pose(pose, RIG_HEAD, "tilt", pick(-14, 12)) + twitch = fnf_nudge_pose(twitch, RIG_HEAD, "nod", pick(-6, 8)) + twitch = fnf_nudge_pose(twitch, mic_arm == RIG_L_ARM ? RIG_R_ARM : RIG_L_ARM, "elbow", 25) + limb_rig.play(list( + list(twitch, 0.4), + list(twitch, 0.5), + list(pose, max(beat_time - 0.9, 1), SINE_EASING), + ), settle_after = FALSE) + +/mob/living/proc/fnf_flash(colour, duration) + add_atom_colour(colour, TEMPORARY_COLOUR_PRIORITY) + addtimer(CALLBACK(src, TYPE_PROC_REF(/atom, remove_atom_colour), TEMPORARY_COLOUR_PRIORITY, colour), duration, TIMER_UNIQUE|TIMER_OVERRIDE) + +/mob/living/proc/fnf_miss(mic_arm, facing, style) + fnf_flash("#8a8aff", 3) + fnf_nudge(0, -2) + +/mob/living/carbon/fnf_miss(mic_arm, facing, style) + if(!limb_rig) + return ..() + fnf_flash("#8a8aff", 3) + // Recoil, shake it off, back to the mic. + var/list/flinch = fnf_pose("miss", mic_arm, facing, style) + limb_rig.play(list( + list(fnf_scale_pose(flinch, 1.15), 0.5, CUBIC_EASING|EASE_OUT), + list(fnf_nudge_pose(flinch, RIG_HEAD, "nod", 12), 0.7, SINE_EASING), + list(flinch, 0.7, SINE_EASING), + list(fnf_pose("rest", mic_arm, facing, style), 1.5, SINE_EASING), + ), settle_after = FALSE) + limb_rig.set_fnf_face("miss", 1.9) + +/mob/living/proc/fnf_hey(mic_arm, facing, style) + fnf_nudge(0, 4) + if(is_species(src, /datum/species/experiment)) + playsound(src, get_expie_exert_sound(), 40, TRUE) + +/mob/living/carbon/fnf_hey(mic_arm, facing, style) + . = ..() + if(!limb_rig) + return + var/list/hey = fnf_pose("hey", mic_arm, facing, style) + limb_rig.play(list( + list(fnf_scale_pose(hey, 1.15), 0.6, BACK_EASING|EASE_OUT), + list(hey, 5), + list(fnf_pose("rest", mic_arm, facing, style), 2, SINE_EASING), + ), settle_after = FALSE) + limb_rig.set_fnf_face("hey", 5.6) + +/// Screaming in agony (Corruption+'s corrupted Pico, fighting it): snaps into it and shakes. +/mob/living/proc/fnf_scream(mic_arm, facing, style) + fnf_nudge(0, 2) + +/mob/living/carbon/fnf_scream(mic_arm, facing, style) + . = ..() + if(!limb_rig) + return + setDir(facing) + // Corrupted Pico's own, traced: into it, then shaking in its last two frames. + var/list/traced = fnf_traced_keyframes(style, "scream", facing) + if(length(traced) >= 2) + var/list/shaking = traced.Copy() + for(var/i in 1 to 4) + shaking += traced.Copy(length(traced) - 1) + shaking += list(list(fnf_traced_rest(style, mic_arm, facing), 3, SINE_EASING)) + limb_rig.play(shaking, settle_after = FALSE) + limb_rig.set_fnf_face("miss", 4) + fnf_idle_until = 0 + return + var/list/scream = fnf_pose("scream", mic_arm, facing, style) + var/list/frames = list(list(fnf_scale_pose(scream, 1.15), 0.5, CUBIC_EASING|EASE_OUT)) + // Shaking with it. + for(var/i in 1 to 4) + frames += list(list(fnf_nudge_pose(scream, RIG_HEAD, "tilt", i % 2 ? -8 : 8), 0.8)) + frames += list(list(fnf_pose("rest", mic_arm, facing, style), 3, SINE_EASING)) + limb_rig.play(frames, settle_after = FALSE) + limb_rig.set_fnf_face("miss", 4) + +/** + * Standing still and staring (Corruption+'s Marble, behind the speaker): side-on, so the face shows, + * held in her pose but for a twitch now and then, the head jerking and the raised hand clenching. + */ +/mob/living/proc/fnf_stare(beat_time, facing) + setDir(facing) + if(prob(30)) + fnf_nudge(facing == EAST ? 1 : -1, 0) + +/mob/living/carbon/fnf_stare(beat_time, facing) + if(!limb_rig) + return ..() + setDir(facing) + if(!limb_rig.is_pulling_fnf_face()) + limb_rig.set_fnf_face("idle") + // Her own idle, traced, with a twitch thrown in now and then. + var/list/traced = fnf_traced_keyframes("marble", "idle", facing) + if(traced) + if(world.time < fnf_idle_until) + return + var/list/frames = traced.Copy() + if(prob(35)) + var/list/last = frames[length(frames)] + var/list/twitch = fnf_nudge_pose(fnf_nudge_pose(last[1], RIG_HEAD, "tilt", pick(-16, 14)), RIG_HEAD, "nod", pick(-8, 6)) + frames += list(list(twitch, 0.4), list(last[1], 0.6)) + fnf_idle_until = world.time + fnf_keyframes_time(frames) - 0.5 + limb_rig.play(frames, settle_after = FALSE) + return + // The near arm's the one reaching out: the far one goes back to the speaker. + var/near_arm = facing == WEST ? RIG_L_ARM : RIG_R_ARM + var/list/pose = fnf_pose("stare", near_arm == RIG_L_ARM ? RIG_R_ARM : RIG_L_ARM, facing, null) + if(!prob(30)) + limb_rig.play(list(list(pose, beat_time, SINE_EASING)), settle_after = FALSE) + return + var/list/twitch = fnf_nudge_pose(pose, RIG_HEAD, "tilt", pick(-16, 14)) + twitch = fnf_nudge_pose(twitch, RIG_HEAD, "nod", pick(-8, 6)) + twitch = fnf_nudge_pose(twitch, near_arm, "elbow", 30) + limb_rig.play(list( + list(twitch, 0.4), + list(twitch, 0.6), + list(pose, max(beat_time - 1, 1), SINE_EASING), + ), settle_after = FALSE) + +/// A one-off move (see fnf_pose()): snaps into it, holds it for hold deciseconds, and goes back. +/mob/living/proc/fnf_act(kind, mic_arm, facing, style, hold = 3) + var/static/list/nudges = list("hit_high" = -4, "hit_low" = -3, "uppercut_hit" = -6, "punch_high" = 4, "punch_low" = 4, "uppercut" = 3, "dodge_high" = -3, "shoot" = -2) + var/push = nudges[kind] || 0 + fnf_nudge(facing == EAST ? push : -push, kind == "uppercut" || kind == "uppercut_hit" ? 4 : 0) + +/mob/living/carbon/fnf_act(kind, mic_arm, facing, style, hold = 3) + . = ..() + if(!limb_rig) + return + setDir(facing) + var/list/traced = fnf_traced_keyframes(style, kind, facing) + if(traced) + limb_rig.play(fnf_traced_hold(traced, hold, fnf_traced_rest(style, mic_arm, facing)), settle_after = FALSE) + fnf_idle_until = 0 + else + var/list/pose = fnf_pose(kind, mic_arm, facing, style) + limb_rig.play(list( + list(fnf_scale_pose(pose, 1.15), 0.5, CUBIC_EASING|EASE_OUT), + list(pose, hold, SINE_EASING), + list(fnf_pose("rest", mic_arm, facing, style), 2, SINE_EASING), + ), settle_after = FALSE) + // Pico twirls his gun for a taunt, or getting cocky. + if((style == "pico" || style == "cpico") && (kind == "taunt" || kind == "cock")) + fnf_spin_guns(mic_arm, style, max(hold, 4), 2) + // Taking a hit winces; throwing one, or anything else, is effort. A meow's a grin, and confusion's + // a look to the side. + var/static/list/faces = list("meow" = "hey", "confused" = "left") + limb_rig.set_fnf_face(faces[kind] || (findtext(kind, "hit") ? "miss" : "down"), 0.5 + hold) + +/// Spins the gun in the free hand round about the hand. +/mob/living/carbon/proc/fnf_spin_guns(mic_arm, style, time, turns = 1) + limb_rig?.spin_held_item(mic_arm == RIG_L_ARM ? "r" : "l", time, turns) + +// Traced animations (traced_anims.dm). + +/mob/living + /// Until when a traced idle is still playing through (see fnf_bop()). + var/fnf_idle_until = 0 + +/** + * A move traced off the sprites of a style's character, as rig keyframes (list(pose, deciseconds, + * easing)), or null if nobody's traced it. Their left and right swap for a singer facing the other way + * from the sprite. + */ +/proc/fnf_traced_keyframes(style, anim, facing) + var/static/list/sheets = list( + "cpico" = list("corruptedpico", "corruptedpico2"), + "kapi" = list("corruptedkapi"), + "gf" = list("corruptedgirlfriendflying"), + "gf_flying" = list("corruptedgirlfriendflying"), + "marble" = list("corruptedmarble2", "corruptedmarble"), + ) + for(var/sheet in sheets[style]) + var/name = anim + var/sprite_facing = GLOB.fnf_traced_facing[sheet] + if((sprite_facing == "right" && facing == WEST) || (sprite_facing == "left" && facing == EAST)) + if(anim == "singLEFT") + name = "singRIGHT" + else if(anim == "singRIGHT") + name = "singLEFT" + var/list/frames = GLOB.fnf_traced_anims[sheet]?[name] + if(!length(frames)) + continue + . = list() + for(var/list/frame as anything in frames) + . += list(list(frame[1], frame[2], LINEAR_EASING)) + return + +/** + * Corruption+'s cast mostly don't sing into a mic (Kapi dances, Girlfriend floats, corrupted Pico has + * his gun): whoever's moving as one of them keeps the battle mic, but it isn't drawn. + */ +/mob/living/carbon/proc/fnf_hide_mic(style) + var/static/list/micless = list("cpico", "kapi", "gf", "gf_flying", "marble") + limb_rig?.set_hidden_held((style in micless) ? list(/obj/item/fnf_microphone) : null) + +/** + * For someone traced without an idle of their own (flying corrupted Girlfriend), a slow, slight sway + * about the first pose of their alt idle: leaning in and back, the head and arms drifting with it. + */ +/proc/fnf_traced_sway(style, facing) + var/list/alt = fnf_traced_keyframes(style, "idle-alt", facing) + if(!alt) + return null + var/list/first = alt[1] + var/list/base = first[1] + . = list() + for(var/way in list(1, -1)) + var/list/swayed = fnf_nudge_pose(base, RIG_CHEST, "bend", 3 * way) + swayed = fnf_nudge_pose(swayed, RIG_HEAD, "nod", -2 * way) + for(var/arm in list(RIG_L_ARM, RIG_R_ARM)) + if(swayed[arm]) + swayed = fnf_nudge_pose(swayed, arm, "upper", 4 * way) + swayed = fnf_nudge_pose(swayed, arm, "lower", 4 * way) + . += list(list(swayed, 8, SINE_EASING)) + +/// How long some keyframes take altogether, in deciseconds. +/proc/fnf_keyframes_time(list/keyframes) + . = 0 + for(var/list/keyframe as anything in keyframes) + . += keyframe[2] + +/** + * A traced move for a note held hold deciseconds, then back to rest. A long note stutters on the + * move's first two frames until it's let go, the way Funkin' starts the move over on every bit of a + * held note, then plays it through. + */ +/proc/fnf_traced_hold(list/keyframes, hold, list/rest) + . = list() + var/left = hold - fnf_keyframes_time(keyframes) + if(length(keyframes) >= 2) + var/list/first = keyframes[1] + var/list/second = keyframes[2] + var/first_time = min(first[2], 0.5) + var/second_time = min(second[2], 0.5) + while(left > first_time + second_time) + . += list(list(first[1], first_time), list(second[1], second_time)) + left -= first_time + second_time + . += keyframes + if(left > 0) + var/list/last = keyframes[length(keyframes)] + . += list(list(last[1], left)) + . += list(list(rest, 2, SINE_EASING)) + +/// Where a traced character rests: the first frame of their traced idle, or the usual rest. +/proc/fnf_traced_rest(style, mic_arm, facing) + var/list/idle = fnf_traced_keyframes(style, "idle", facing) + if(idle) + var/list/first = idle[1] + return first[1] + return fnf_pose("rest", mic_arm, facing, style) + +/// A backup dancer's move for one beat: into one side of the dance, then easing off it. +/mob/living/proc/fnf_dance(beat_time, left) + fnf_nudge(left ? -2 : 2, -1) + +/mob/living/carbon/fnf_dance(beat_time, left) + if(!limb_rig) + return ..() + limb_rig.play(list( + list(fnf_pose(left ? "dance_left" : "dance_right", RIG_R_ARM, SOUTH, null), beat_time * 0.3, CUBIC_EASING|EASE_OUT), + list(fnf_scale_pose(fnf_pose(left ? "dance_left" : "dance_right", RIG_R_ARM, SOUTH, null), 0.6), beat_time * 0.7, SINE_EASING), + ), settle_after = FALSE) + +/// Lost. Run off the health bar means going down in a heap. +/mob/living/proc/fnf_lose(knocked_out) + fnf_flash("#6f6fff", 3 SECONDS) + if(is_species(src, /datum/species/experiment)) + playsound(src, get_expie_pain_sound(), 50, TRUE) + if(!knocked_out) + return + visible_message(span_danger("[src] got blue-balled!")) + if(iscarbon(src)) + var/mob/living/carbon/loser = src + loser.limb_rig?.set_fnf_face("dead") + // A player's game over has them on the floor already; anyone else just drops. + if(!client) + Knockdown(3 SECONDS) + +/// Back to standing about normally once the battle's over. +/mob/living/proc/fnf_rest() + return + +/mob/living/carbon/fnf_rest() + limb_rig?.set_hidden_held(null) + limb_rig?.clear_fnf_face() + limb_rig?.settle() + +/// Adds to one angle of a pose in place, making the entry if it isn't there. +/proc/fnf_pose_add(list/pose, part_id, key, amount) + var/list/entry = pose[part_id] + if(!entry) + entry = list() + pose[part_id] = entry + entry[key] = (entry[key] || 0) + amount + +/** + * A summoned character's own way of moving, on top of the shared poses. + * + * * pose - the pose so far, changed in place + * * kind - as for fnf_pose(), with the side arrows already turned into "toward" and "away" + */ +/proc/fnf_apply_style(list/pose, kind, style, mic_arm, free_arm) + switch(style) + if("cpico") + // Corrupted Pico (Corruption+): hunched, the gun held out one-handed, as cool as ever: + // up at the chest, levelled at the rival, straight out low, up at the sky, or slung back + // over the shoulder, spun round into aiming it. + switch(kind) + if("rest", "bop", "miss") + pose[free_arm] = list("swing" = 50, "raise" = 10, "elbow" = 55) + if("toward") + pose[free_arm] = list("swing" = 85, "raise" = 5, "elbow" = 5) + if("away") + pose[free_arm] = list("swing" = 140, "raise" = 10, "elbow" = 120) + if(1) + pose[free_arm] = list("swing" = 90, "raise" = 5, "elbow" = 0) + fnf_pose_add(pose, RIG_L_LEG, "knee", 20) + fnf_pose_add(pose, RIG_R_LEG, "knee", 20) + if(2, "hey") + pose[free_arm] = list("swing" = 165, "raise" = 5, "elbow" = 10) + fnf_pose_add(pose, RIG_CHEST, "bend", 8) + fnf_pose_add(pose, RIG_HEAD, "nod", 6) + fnf_pose_add(pose, RIG_L_LEG, "knee", 12) + fnf_pose_add(pose, RIG_R_LEG, "knee", 12) + if("pico", "tankman") + // The gun's in the free hand, and goes where the note does: levelled at the rival, up at + // the sky, down at the floor. Pico sings the rival note into the mic and lets the gun talk. + switch(kind) + if("rest", "bop", "miss") + pose[free_arm] = list("swing" = 40, "raise" = 10, "elbow" = 45) + if("toward") + pose[free_arm] = list("swing" = 90, "raise" = 5, "elbow" = 0) + pose[mic_arm] = FNF_MIC_UP + if("away") + pose[free_arm] = list("swing" = -35, "raise" = 25, "elbow" = 70) + if(1) + pose[free_arm] = list("swing" = 35, "raise" = 5, "elbow" = 0) + if(2, "hey") + pose[free_arm] = list("swing" = 175, "raise" = 5, "elbow" = 0) + if("ugh") + pose[free_arm] = list("swing" = 20, "raise" = 15, "elbow" = 60) + if(style == "tankman") + // Parade-ground stiff: stands up straight whatever he's singing. + var/list/chest = pose[RIG_CHEST] + if(chest) + chest["bend"] = (chest["bend"] || 0) * 0.4 + fnf_pose_add(pose, RIG_HEAD, "nod", -4) + if("dad", "parents") + // Rockstar: legs planted wide, headbanging, the mic thrown to the sky on the high notes. + fnf_pose_add(pose, RIG_L_LEG, "raise", 10) + fnf_pose_add(pose, RIG_R_LEG, "raise", 10) + if(kind == "bop" || kind == 1) + fnf_pose_add(pose, RIG_HEAD, "nod", 14) + fnf_pose_add(pose, RIG_CHEST, "bend", 8) + if(kind == 2) + pose[mic_arm] = list("swing" = 160, "raise" = 10, "elbow" = 5) + if("mom") + // Swaying her hips into every note. + fnf_pose_add(pose, RIG_CHEST, "lean", kind == "away" ? -8 : 8) + fnf_pose_add(pose, RIG_HEAD, "tilt", kind == "away" ? 8 : -8) + if(kind == "bop") + fnf_pose_add(pose, RIG_CHEST, "bend", -4) + if("spooky") + // Hopping into everything, arms everywhere. + fnf_pose_add(pose, RIG_CHEST, "air", 5) + fnf_pose_add(pose, mic_arm, "raise", 30) + fnf_pose_add(pose, free_arm, "raise", 45) + fnf_pose_add(pose, RIG_L_LEG, "knee", 25) + if("senpai") + // Far too cool to move much. + for(var/part_id in pose) + var/list/entry = pose[part_id] + for(var/key in entry) + if(key != "hand_y") + entry[key] *= 0.55 + fnf_pose_add(pose, RIG_HEAD, "tilt", 10) + if("monster") + // Hunched right over, head jerking about. + fnf_pose_add(pose, RIG_CHEST, "bend", 16) + fnf_pose_add(pose, RIG_HEAD, "nod", kind == 2 ? -20 : 12) + if("darnell") + // Laid back, leaning away, all swagger. + fnf_pose_add(pose, RIG_CHEST, "bend", -8) + fnf_pose_add(pose, RIG_HEAD, "nod", -6) + fnf_pose_add(pose, RIG_L_LEG, "swing", 15) + // Corruption+'s cast, as the mod animates them. + if("kapi") + // Dancing on his arcade's dance pad more than singing: an arm flung up, a stamp, a tucked + // jump, a knee and fist driven forward, slouching between them, tail going. + switch(kind) + if("rest", "bop") + pose[free_arm] = list("swing" = 5, "raise" = 8, "elbow" = 15) + fnf_pose_add(pose, RIG_CHEST, "bend", 6) + fnf_pose_add(pose, RIG_HEAD, "nod", 6) + if("away") + pose[free_arm] = list("swing" = 160, "raise" = 40, "elbow" = 30) + pose[RIG_R_LEG] = list("swing" = 35, "knee" = 60) + fnf_pose_add(pose, RIG_CHEST, "lean", -6) + if("toward") + pose[free_arm] = list("swing" = 90, "raise" = 10, "elbow" = 10) + pose[RIG_L_LEG] = list("swing" = 60, "knee" = 70) + if(1) + pose[RIG_L_LEG] = list("swing" = 75, "knee" = 15) + pose[RIG_R_LEG] = list("swing" = -5, "knee" = 25) + if(2) + pose[RIG_L_LEG] = list("swing" = 55, "knee" = 90) + pose[RIG_R_LEG] = list("swing" = 55, "knee" = 90) + fnf_pose_add(pose, RIG_CHEST, "air", 6) + fnf_pose_add(pose, RIG_TAIL, "wag", kind == "away" ? -18 : 18) + if("skarlet") + // All attitude: a hand on her cocked hip, pointing at the rival, a shrug thrown away from them. + switch(kind) + if("rest", "bop") + pose[free_arm] = list("swing" = 10, "raise" = 35, "elbow" = 110) + fnf_pose_add(pose, RIG_CHEST, "lean", 6) + fnf_pose_add(pose, RIG_HEAD, "tilt", -6) + if("toward") + pose[free_arm] = list("swing" = 95, "raise" = 5, "elbow" = 0) + pose[mic_arm] = list("swing" = 50, "raise" = 10, "hand_y" = 27) + if("away") + pose[free_arm] = list("swing" = 40, "raise" = 40, "elbow" = 60) + fnf_pose_add(pose, RIG_HEAD, "tilt", 10) + if(1) + fnf_pose_add(pose, RIG_HEAD, "nod", 10) + fnf_pose_add(pose, RIG_CHEST, "bend", -3) + if("carol", "carol_flying") + // Shy: the mic held up in both hands, feet together, hardly moving. + pose[free_arm] = list("swing" = 50, "raise" = 5, "elbow" = 100) + for(var/part_id in pose) + var/list/entry = pose[part_id] + if(part_id == free_arm || part_id == mic_arm) + continue + for(var/key in entry) + if(key != "hand_y") + entry[key] *= 0.6 + fnf_pose_add(pose, RIG_HEAD, "tilt", 8) + if(style == "carol_flying") + // Up on her wings, curled up in the air. + pose[RIG_L_LEG] = list("swing" = 45, "knee" = 80) + pose[RIG_R_LEG] = list("swing" = 30, "knee" = 70) + if("gf", "gf_flying") + // Corrupted, she sings like she dances: the mic swung out wide, a leg kicked up on the high + // notes, a hand on her hip in between. Flying, her legs dangle. + switch(kind) + if("rest", "bop") + pose[free_arm] = list("swing" = 10, "raise" = 35, "elbow" = 110) + if("toward", "away") + pose[mic_arm] = list("swing" = 85, "raise" = 35, "elbow" = 40) + pose[free_arm] = list("swing" = 20, "raise" = 30, "elbow" = 100) + if(2) + pose[RIG_L_LEG] = list("swing" = 70, "knee" = 40) + pose[free_arm] = list("swing" = 165, "raise" = 10, "elbow" = 10) + if(style == "gf_flying") + pose[RIG_L_LEG] = list("swing" = 15, "knee" = 30) + pose[RIG_R_LEG] = list("swing" = -10, "knee" = 20) + +#undef FNF_MIC_UP +#undef FNF_FIST diff --git a/voidcrew/modules/fnf/props.dm b/voidcrew/modules/fnf/props.dm new file mode 100644 index 00000000000..602e09b7672 --- /dev/null +++ b/voidcrew/modules/fnf/props.dm @@ -0,0 +1,202 @@ +/** + * Things on a rhythm battle's stage, from Funkin' and Corruption+'s own art: the boombox Girlfriend + * sits on between the singers (and that Pico and Otis stand on to shoot from), its corrupted one and + * Nene's A-Bot, three tiles wide (icons/fnf_speakers.dmi); Kapi's arcade dance pad, lighting up where + * he steps, and the little speaker Marble leans on (icons/fnf_props.dmi). + * + * Whoever's up on one is raised to stand on it, squat on top (Pico and Otis, shooting from it), or + * sit on its edge with their legs hanging over. + */ +/obj/effect/abstract/fnf_prop + name = "" + icon = 'voidcrew/modules/fnf/icons/fnf_props.dmi' + // 64 pixels wide, centred on its tile. + pixel_w = -16 + plane = GAME_PLANE + layer = BELOW_MOB_LAYER + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + /// Who's up on it, and how far it raised them. + var/mob/living/rider + var/raised = 0 + + /// A dance pad's panels, lit when stepped on, by lane. + var/list/obj/effect/abstract/panels + /// Whether it's drawn mirrored (for someone facing west), left and right swapped. + var/mirrored = FALSE + +/obj/effect/abstract/fnf_prop/Destroy() + set_rider(null) + vis_contents.Cut() + QDEL_LIST(panels) + return ..() + +/// Shows one of the props. +/obj/effect/abstract/fnf_prop/proc/set_look(state) + icon_state = state + if(state in list("speakers", "speakers_evil", "abot")) + icon = 'voidcrew/modules/fnf/icons/fnf_speakers.dmi' + pixel_w = -32 + else + icon = initial(icon) + pixel_w = initial(pixel_w) + +/// How high each prop's top is, in pixels, for standing on. +/proc/fnf_prop_height(state) + var/static/list/heights = list("speakers" = 61, "speakers_evil" = 61, "abot" = 38, "dance_pad" = 8, "dance_pad_corrupt" = 8) + return heights[state] || 0 + +/// A dance pad's panel lighting up, stepped on: lane 0 to 3 for left, down, up, right. +/obj/effect/abstract/fnf_prop/proc/step_on(lane) + if(!findtext(icon_state, "dance_pad")) + return + var/static/list/lanes = list("left", "down", "up", "right") + if(!panels) + panels = list() + for(var/name in lanes) + var/obj/effect/abstract/panel = new + panel.icon = icon + panel.icon_state = "[icon_state]_lit_[name]" + panel.alpha = 0 + panel.vis_flags = VIS_INHERIT_ID|VIS_INHERIT_PLANE|VIS_INHERIT_LAYER + panel.mouse_opacity = MOUSE_OPACITY_TRANSPARENT + panels += panel + vis_contents += panel + // Mirrored, the left panel's on the right. + if(mirrored && (lane == 0 || lane == 3)) + lane = 3 - lane + var/obj/effect/abstract/panel = panels[clamp(lane + 1, 1, 4)] + animate(panel, alpha = 255, time = 0) + animate(alpha = 0, time = 4, easing = QUAD_EASING|EASE_IN) + +/** + * Puts someone up on it, or (with null) gets them down: "sit" on its edge, "crouch" on top, or + * (with no way given) stand on top. + */ +/obj/effect/abstract/fnf_prop/proc/set_rider(mob/living/new_rider, way) + if(rider && !QDELETED(rider)) + rider.remove_offsets(FNF_BATTLE_TRAIT, animate = FALSE) + if(iscarbon(rider)) + var/mob/living/carbon/carbon_rider = rider + carbon_rider.limb_rig?.set_seated(null) + rider = new_rider + raised = 0 + if(!rider) + return + // Sitting, the hips are on the top, the shins hanging down in front. + raised = fnf_prop_height(icon_state) - (way == "sit" ? 8 : 0) + // As an offset of its own, so nothing else putting the mob's offsets right takes it away. + rider.add_offsets(FNF_BATTLE_TRAIT, z_add = raised, animate = FALSE) + if(way && iscarbon(rider)) + var/mob/living/carbon/carbon_rider = rider + carbon_rider.limb_rig?.set_seated(way) + +/// The speakers thump with the beat. +/obj/effect/abstract/fnf_prop/proc/thump() + animate(src, transform = matrix(1.04, 0, 0, 0, 0.94, -0.7), time = 0.5) + animate(transform = matrix(), time = 2.5, easing = SINE_EASING) + +/** + * Sat on something ("sit"), facing the front: the thighs swung straight out at the viewer (so they + * foreshorten to nothing) and the shins hanging down. Or squatting on it ("crouch"): thighs out, + * shins folded right back under, hunched over. On top of whatever the body does anyway; null stands. + */ +/datum/limb_rig/proc/set_seated(way) + var/mob/living/carbon/human/human_owner = owner + var/list/own_posture = istype(human_owner) ? human_owner.dna.species.limb_rig_shape?["posture"] : null + switch(way) + if("sit") + posture = rig_merge_pose(own_posture, list( + RIG_L_LEG = list("swing" = 88, "knee" = 88), + RIG_R_LEG = list("swing" = 88, "knee" = 88), + )) + if("crouch") + posture = rig_merge_pose(own_posture, list( + RIG_L_LEG = list("swing" = 80, "knee" = 140), + RIG_R_LEG = list("swing" = 70, "knee" = 135), + RIG_CHEST = list("bend" = 14), + )) + else + posture = own_posture + +// The stage's. + +/datum/fnf_stage + /// What the girlfriend's place is up on, if anything. + var/obj/effect/abstract/fnf_prop/seat + /// Props by the singers (Kapi's dance pad, Marble's speaker), by singer. + var/list/singer_props = list() + +/// Where the girlfriend stands: a step behind, halfway between the singers. Null if there's no room. +/datum/fnf_stage/proc/get_girlfriend_spot() + if(!left_spot || !right_spot) + return null + var/turf/middle = locate(round((left_spot.x + right_spot.x) / 2), left_spot.y + 1, left_spot.z) + if(!middle || middle.is_blocked_turf(exclude_mobs = TRUE)) + return null + return middle + +/** + * Puts something in the girlfriend's place for her to sit on ("speakers", "speakers_evil", "abot"), + * or to stand on, or takes it away (null). Whoever's there now gets up on it, if they're seen. + */ +/datum/fnf_stage/proc/set_seat(state, way = "sit") + if(!state) + QDEL_NULL(seat) + return + if(seat?.icon_state != state) + QDEL_NULL(seat) + if(!state) + return + var/turf/spot = girlfriend ? get_turf(girlfriend) : get_girlfriend_spot() + if(!spot) + return + seat = new(spot) + seat.set_look(state) + seat.set_rider(girlfriend && girlfriend.alpha ? girlfriend : null, way) + +/// What the girlfriend's place has to be up on for this character, as Funkin' has it: list(state, way). +/datum/fnf_stage/proc/get_seat_for(character) + if(length(battle.chart["speaker"])) + // Stress: squatting up on the speakers to shoot from, Otis on Nene's A-Bot in the Pico mix. + return list(character == "otis" ? "abot" : "speakers", "crouch") + switch(character) + if("gf") + return list("speakers", "sit") + if("nene") + return list("abot", "sit") + +/// Gives a singer one of their props ("dance_pad", "speaker_small"), or takes it away (null). +/datum/fnf_stage/proc/set_singer_prop(mob/living/singer, state) + var/obj/effect/abstract/fnf_prop/old = singer_props[singer] + if(old?.icon_state == state) + return + if(old) + singer_props -= singer + qdel(old) + if(!state || !singer) + return + var/obj/effect/abstract/fnf_prop/prop = new(get_turf(singer)) + prop.set_look(state) + singer_props[singer] = prop + if(state == "speaker_small") + // Beside her, behind, for a hand to rest on, facing her enemy. + prop.pixel_w += get_enemy_side(singer) == WEST ? 12 : -12 + return + // Facing west (playing as Kapi), the pad's turned round to match: the rail behind them. + for(var/datum/fnf_side/side as anything in battle.sides) + if(side.singer == singer && side.facing == WEST) + prop.mirrored = TRUE + prop.transform = matrix(-1, 0, 0, 0, 1, 0) + prop.set_rider(singer) + +/// A singer's dance pad lighting up where they step (lane 0 to 3), if they have one. +/datum/fnf_stage/proc/step_on_pad(mob/living/singer, lane) + var/obj/effect/abstract/fnf_prop/prop = singer_props[singer] + prop?.step_on(lane) + +/// Which way the girlfriend's place faces to glare at the enemy, the opponent on the left. +/datum/fnf_stage/proc/get_enemy_side(mob/living/watcher) + var/mob/living/enemy = battle.left?.singer + if(!enemy || enemy == watcher) + return WEST + return enemy.x > watcher.x ? EAST : WEST diff --git a/voidcrew/modules/fnf/side.dm b/voidcrew/modules/fnf/side.dm new file mode 100644 index 00000000000..608e6215f7b --- /dev/null +++ b/voidcrew/modules/fnf/side.dm @@ -0,0 +1,643 @@ +/// One note on its way up, and the sprites drawing it. +/datum/fnf_note + /// When it should be hit, in song milliseconds. + var/time + /// 0 to 3: left, down, up, right. + var/lane + /// How long it's held, in milliseconds. 0 for a tap. + var/length = 0 + /// What the chart says happens on it besides singing ("ugh", "weekend-1-firegun"...), or null. + var/kind + /// Hit or missed already. + var/judged = FALSE + /// Being held down right now. + var/holding = FALSE + var/obj/effect/abstract/fnf_hud/head + var/obj/effect/abstract/fnf_hud/body + var/obj/effect/abstract/fnf_hud/tail + +/datum/fnf_note/Destroy() + QDEL_NULL(head) + QDEL_NULL(body) + QDEL_NULL(tail) + return ..() + +/** + * One singer in a rhythm battle: their chart, their strums, their score and their input. + * + * Presses are judged against the song as the singer heard it. The song, the notes on screen + * and the singer's press all take a trip over the network, so a press lands a whole round trip + * after the moment the singer heard. The side takes the singer's ping off every press, plus + * whatever offset they've set for their speakers. + */ +/datum/fnf_side + var/datum/fnf_battle/battle + var/mob/living/singer + /// The singer's name, kept for the results if they go away. + var/singer_name + /// Played by the game rather than a person: an NPC, or someone who logged out mid-song. + var/is_cpu = FALSE + /// The challenger's side, on the right. + var/is_right = FALSE + /// list(time, lane, hold, kind) for every note, in order. + var/list/chart + /// The next note in the chart to put on screen. + var/next_note = 1 + /// Notes on screen, not yet over with. + var/list/datum/fnf_note/live = list() + /// Sound channel this singer's vocals play on. + var/voice_channel + /// The vocals go quiet after a miss, until the next hit. + var/voice_muted = FALSE + /// Which arm the singer holds the mic in, RIG_L_ARM or RIG_R_ARM. + var/mic_arm + /// Which way the singer faces: toward the rival. + var/facing + /// A summoned character's own way of moving (see fnf_apply_style), or null. + var/style + /// Whether the singer just hangs there between notes, twitching, instead of bopping (see fnf_hang()). + var/hanging = FALSE + /// Held items the singer can't let go of while singing, as weakrefs. + var/list/datum/weakref/locked_items = list() + /// Things handed to the singer for the song (Pico's gun), taken back after. + var/list/obj/item/props = list() + /// The HUD style to put back afterwards, if it was hidden. + var/old_hud_version + /// Whether the singer's screen was zoomed in on the stage. + var/zoomed = FALSE + /// How far to zoom the singer's screen in once their last note's played, if at all. + var/zoom_when_done = 0 + /// Until when (world.time) the singer is busy with a pose, and shouldn't bop to the beat. + var/busy_until = 0 + + var/obj/effect/abstract/fnf_hud/strumline + var/list/obj/effect/abstract/fnf_hud/strums = list() + var/obj/effect/abstract/fnf_hud/text/score_text + var/obj/effect/abstract/fnf_hud/text/rating_text + + var/score = 0 + var/combo = 0 + var/max_combo = 0 + var/misses = 0 + /// Notes judged so far, and their accuracy summed (1 for a sick, 0 for a miss). + var/judged_count = 0 + var/accuracy_total = 0 + var/list/ratings = list("sick" = 0, "good" = 0, "bad" = 0, "shit" = 0) + +/datum/fnf_side/New(datum/fnf_battle/battle, mob/living/singer, is_right, list/chart, voice_channel) + src.battle = battle + src.singer = singer + singer_name = singer.name + src.is_right = is_right + src.chart = chart + src.voice_channel = voice_channel + is_cpu = !singer.client + + strumline = new(get_turf(singer)) + for(var/lane in 0 to 3) + var/obj/effect/abstract/fnf_hud/strum = new + strum.icon = battle.get_note_icon() + strum.icon_state = "strum_[lane]" + strum.pixel_w = lane_x(lane) + strum.pixel_z = FNF_STRUM_Y + strum.layer = ABOVE_ALL_MOB_LAYER + 0.01 + strumline.vis_contents += strum + strums += strum + score_text = new + score_text.pixel_z = FNF_STRUM_Y + 24 + score_text.layer = ABOVE_ALL_MOB_LAYER + 0.05 + strumline.vis_contents += score_text + rating_text = new + rating_text.layer = ABOVE_ALL_MOB_LAYER + 0.06 + rating_text.alpha = 0 + strumline.vis_contents += rating_text + update_score_text() + + facing = is_right ? WEST : EAST + singer.setDir(facing) + mic_arm = singer.fnf_mic_arm(facing) + RegisterSignal(singer, COMSIG_MOB_KEYDOWN, PROC_REF(on_key)) + RegisterSignal(singer, COMSIG_MOB_CLIENT_PRE_MOVE, PROC_REF(on_try_move)) + RegisterSignal(singer, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(singer, COMSIG_MOB_STATCHANGE, PROC_REF(on_stat_change)) + RegisterSignal(singer, COMSIG_MOB_LOGOUT, PROC_REF(on_logout)) + RegisterSignal(singer, COMSIG_MOB_LOGIN, PROC_REF(on_login)) + RegisterSignal(singer, COMSIG_QDELETING, PROC_REF(on_singer_deleted)) + lock_in() + +/datum/fnf_side/Destroy() + release_singer() + QDEL_LIST(live) + strumline?.vis_contents.Cut() + QDEL_LIST(strums) + QDEL_NULL(score_text) + QDEL_NULL(rating_text) + QDEL_NULL(strumline) + battle = null + return ..() + +/datum/fnf_side/proc/release_singer() + if(!singer) + return + unlock() + singer.fnf_rest() + UnregisterSignal(singer, list( + COMSIG_MOB_KEYDOWN, + COMSIG_MOB_CLIENT_PRE_MOVE, + COMSIG_MOVABLE_MOVED, + COMSIG_MOB_STATCHANGE, + COMSIG_MOB_LOGOUT, + COMSIG_MOB_LOGIN, + COMSIG_QDELETING, + )) + singer = null + +/** + * While singing, a player is all in: hands busy (nothing picked up, dropped or used), facing their + * rival whatever they click on, the HUD hidden and the screen zoomed in on the stage. + */ +/datum/fnf_side/proc/lock_in() + if(is_cpu) + return + // Glue their hands shut first: blocking hands drops whatever they're holding. + for(var/obj/item/held in singer.held_items) + ADD_TRAIT(held, TRAIT_NODROP, FNF_BATTLE_TRAIT) + locked_items += WEAKREF(held) + ADD_TRAIT(singer, TRAIT_HANDS_BLOCKED, FNF_BATTLE_TRAIT) + RegisterSignal(singer, COMSIG_ATOM_POST_DIR_CHANGE, PROC_REF(on_turned)) + var/client/viewer = singer.client + if(!viewer) + return + if(singer.hud_used) + old_hud_version = singer.hud_used.hud_version + singer.hud_used.show_hud(HUD_STYLE_NOHUD) + if(!GLOB.fnf_no_zoom[viewer.ckey]) + zoom_screen(FNF_ZOOM) + zoomed = TRUE + +/** + * Puts a prop for the song (Pico's gun) in the singer's hand nearer the crowd, where it can be + * seen, moving whatever's there (the mic) to the other hand. The far hand is behind the body. + */ +/datum/fnf_side/proc/give_prop(prop_type) + if(!singer) + return + // Side-on, the left hand is the near one facing west, the right facing east. + var/near_index = facing == WEST ? 1 : 2 + var/far_index = facing == WEST ? 2 : 1 + var/obj/item/in_near_hand = singer.get_item_for_held_index(near_index) + if(in_near_hand) + if(singer.get_item_for_held_index(far_index)) + return + singer.temporarilyRemoveItemFromInventory(in_near_hand, force = TRUE) + singer.put_in_hand(in_near_hand, far_index, forced = TRUE) + var/obj/item/prop = new prop_type(singer.drop_location()) + if(!singer.put_in_hand(prop, near_index, forced = TRUE)) + qdel(prop) + return + ADD_TRAIT(prop, TRAIT_NODROP, FNF_BATTLE_TRAIT) + props += prop + mic_arm = singer.fnf_mic_arm(facing) + +/// Gives back everything lock_in() took. Safe to call more than once. +/datum/fnf_side/proc/unlock() + QDEL_LIST(props) + if(!singer) + return + REMOVE_TRAIT(singer, TRAIT_HANDS_BLOCKED, FNF_BATTLE_TRAIT) + for(var/datum/weakref/item_ref as anything in locked_items) + var/obj/item/held = item_ref.resolve() + if(held) + REMOVE_TRAIT(held, TRAIT_NODROP, FNF_BATTLE_TRAIT) + locked_items.Cut() + UnregisterSignal(singer, COMSIG_ATOM_POST_DIR_CHANGE) + if(zoomed) + zoom_screen(1) + zoomed = FALSE + if(old_hud_version) + singer.hud_used?.show_hud(old_hud_version) + old_hud_version = null + +/** + * Zooms the singer's screen in by scaling what's already drawn, like a camera, rather than + * changing how much they can see: the view size is left alone, so lighting, fullscreen effects and + * the rest of the screen's layout don't change underneath. The camera is already centred on the + * stage, so this zooms straight into the battle. + */ +/datum/fnf_side/proc/zoom_screen(scale) + var/atom/movable/plane_master_controller/screen = singer?.hud_used?.plane_master_controllers[PLANE_MASTERS_NON_MASTER] + if(!screen) + return + for(var/atom/movable/screen/plane_master/plate as anything in screen.get_planes()) + // Whole-number zoom, pixels scaled as blocks: anything else smears them. + if(scale == 1) + plate.appearance_flags = (plate.appearance_flags & ~PIXEL_SCALE) | (initial(plate.appearance_flags) & PIXEL_SCALE) + else + plate.appearance_flags |= PIXEL_SCALE + animate(plate, transform = matrix() * scale, time = 10, easing = SINE_EASING) + +/datum/fnf_side/proc/on_turned(mob/source, old_dir, new_dir) + SIGNAL_HANDLER + if(new_dir != facing && battle.state != FNF_STATE_OVER) + source.setDir(facing) + +/// Where a lane sits across the strumline, in pixels from the middle of the singer's tile. +/proc/fnf_lane_x(lane) + return round((lane - 1.5) * FNF_LANE_GAP) + +/datum/fnf_side/proc/lane_x(lane) + return fnf_lane_x(lane) + +/// Whether this side's notes hit themselves: the CPU's, or a player's with botplay on. +/datum/fnf_side/proc/is_bot() + return is_cpu || (singer?.ckey && GLOB.fnf_botplay[singer.ckey]) + +/// How late this singer's presses arrive after they heard the note, in milliseconds. +/datum/fnf_side/proc/get_latency() + var/client/user = singer?.client + if(!user || is_bot()) + return 0 + return clamp(user.avgping, 0, 400) + (GLOB.fnf_offsets[user.ckey] || 0) + +/// Which lane a key plays, from the player's own movement keys: left, down, up or right. +/proc/fnf_key_lane(client/user, key) + switch(user?.movement_keys[key]) + if(WEST) + return 0 + if(SOUTH) + return 1 + if(NORTH) + return 2 + if(EAST) + return 3 + return null + +/datum/fnf_side/proc/is_lane_held(lane) + var/client/user = singer?.client + if(!user) + return TRUE + for(var/key in user.keys_held) + if(fnf_key_lane(user, key) == lane) + return TRUE + return FALSE + +/// Called every tick with where the song is. +/datum/fnf_side/proc/process_notes(now) + var/pixels_per_ms = FNF_TRAVEL_PX / battle.travel_ms + while(next_note <= length(chart)) + var/list/entry = chart[next_note] + if(entry[1] - now > battle.travel_ms) + break + next_note++ + spawn_note(entry, now, pixels_per_ms) + + var/heard = now - get_latency() + var/bot = is_bot() + for(var/datum/fnf_note/note as anything in live.Copy()) + if(!note.judged) + if(bot) + if(now >= note.time) + hit(note, 0, now) + else if(heard - note.time > FNF_WINDOW_SHIT) + miss(note) + continue + if(!note.holding) + continue + var/hold_end = note.time + note.length + if((bot ? now : heard) >= hold_end) + finish_hold(note) + else if(!bot && !is_lane_held(note.lane) && hold_end - heard > FNF_HOLD_GRACE) + drop_hold(note) + else + score += round(250 * world.tick_lag / 10) + if(!is_cpu) + battle.adjust_health(0.1, src) + if(zoom_when_done && next_note > length(chart) && !length(live)) + zoom_screen(zoom_when_done) + zoom_when_done = 0 + +/datum/fnf_side/proc/spawn_note(list/entry, now, pixels_per_ms) + var/datum/fnf_note/note = new + note.time = entry[1] + note.lane = entry[2] + note.length = entry[3] + if(length(entry) >= 4) + note.kind = entry[4] + live += note + // Notes rise from below at a steady speed and cross the strums right on their time. + var/until = max(note.time - now, 0) + var/start_y = FNF_STRUM_Y - until * pixels_per_ms + var/overshoot = 250 * pixels_per_ms + + var/note_icon = battle.get_note_icon() + note.head = new + note.head.icon = note_icon + note.head.icon_state = "note_[note.lane]" + note.head.pixel_w = lane_x(note.lane) + note.head.pixel_z = start_y + note.head.layer = ABOVE_ALL_MOB_LAYER + 0.03 + strumline.vis_contents += note.head + animate(note.head, pixel_z = FNF_STRUM_Y, time = until / 100) + animate(pixel_z = FNF_STRUM_Y + overshoot, alpha = 0, time = 2.5) + + if(note.length <= 0) + return + // A long note trails a stretched body below its head, capped with a rounded tail. + var/length_px = note.length * pixels_per_ms + note.body = new + note.body.icon = note_icon + note.body.icon_state = "hold_[note.lane]" + note.body.pixel_w = lane_x(note.lane) + note.body.pixel_z = start_y - length_px / 2 + note.body.transform = matrix(1, 0, 0, 0, length_px / 32, 0) + note.body.layer = ABOVE_ALL_MOB_LAYER + 0.02 + note.tail = new + note.tail.icon = note_icon + note.tail.icon_state = "holdend_[note.lane]" + note.tail.pixel_w = lane_x(note.lane) + note.tail.pixel_z = start_y - length_px + note.tail.layer = ABOVE_ALL_MOB_LAYER + 0.02 + strumline.vis_contents += note.body + strumline.vis_contents += note.tail + var/pass_time = (note.length + 250) / 100 + animate(note.body, pixel_z = FNF_STRUM_Y - length_px / 2, time = until / 100) + animate(pixel_z = FNF_STRUM_Y - length_px / 2 + length_px + overshoot, alpha = 0, time = pass_time) + animate(note.tail, pixel_z = FNF_STRUM_Y - length_px, time = until / 100) + animate(pixel_z = FNF_STRUM_Y + overshoot, alpha = 0, time = pass_time) + +/datum/fnf_side/proc/on_key(mob/source, key, client/user, full_key) + SIGNAL_HANDLER + if(is_bot() || battle.state == FNF_STATE_OVER || battle.state == FNF_STATE_READY) + return + var/lane = fnf_key_lane(user, key) + if(isnull(lane)) + return + press(lane, battle.get_song_time() - get_latency()) + +/// A press on a lane, at a song time already corrected for the singer's lag. +/datum/fnf_side/proc/press(lane, time) + var/datum/fnf_note/best + var/best_delta = INFINITY + for(var/datum/fnf_note/note as anything in live) + if(note.lane != lane || note.judged) + continue + var/delta = abs(note.time - time) + if(delta <= FNF_WINDOW_SHIT && delta < best_delta) + best = note + best_delta = delta + if(!best) + // Pressing with nothing there is free. The strum just flinches. + light_strum(lane, "press", 1.5) + return + hit(best, time - best.time, battle.get_song_time()) + +/datum/fnf_side/proc/hit(datum/fnf_note/note, delta, now) + note.judged = TRUE + var/off_by = abs(delta) + var/rating + var/weight + var/points + var/health + if(off_by <= FNF_WINDOW_SICK) + rating = "sick" + weight = 1 + points = 350 + health = 2.3 + else if(off_by <= FNF_WINDOW_GOOD) + rating = "good" + weight = 0.75 + points = 200 + health = 2 + else if(off_by <= FNF_WINDOW_BAD) + rating = "bad" + weight = 0.4 + points = 100 + health = 0.5 + else + rating = "shit" + weight = 0.1 + points = 50 + health = -0.5 + ratings[rating]++ + judged_count++ + accuracy_total += weight + score += points + combo++ + max_combo = max(max_combo, combo) + if(voice_muted) + set_voice(TRUE) + // The CPU doesn't push the health bar, or it would win just by existing. + if(!is_cpu) + battle.adjust_health(health, src) + show_rating(rating) + // Corruption+'s songs drain the player as the opponent sings, and some pay the player back. + if(src == battle.left) + battle.corruption?.opponent_hit(note.length) + else + battle.corruption?.player_hit(note.length) + + QDEL_NULL(note.head) + var/hold_left = note.length > 0 ? max(note.time + note.length - now, 0) : 0 + light_strum(note.lane, "confirm", hold_left ? hold_left / 100 + 1 : 1.5) + if(rating == "sick") + splash(note.lane) + var/hold_time = max(hold_left / 100, 2) + busy_until = world.time + hold_time + 1.5 + // Some notes are a move rather than a line ("ugh!", a punch, a gunshot): the stage acts those out. + if(!battle.stage?.note_hit(src, note)) + singer?.fnf_sing(note.lane, hold_time, mic_arm, facing, style) + // Kapi's pad lights up where he steps. + battle.stage?.step_on_pad(singer, note.lane) + if(!hold_left) + live -= note + qdel(note) + update_score_text() + return + // Held: the body now shrinks into the strum for as long as it lasts. + note.holding = TRUE + var/pixels_per_ms = FNF_TRAVEL_PX / battle.travel_ms + var/left_px = max(hold_left * pixels_per_ms, 0.5) + animate(note.body, pixel_z = FNF_STRUM_Y - left_px / 2, transform = matrix(1, 0, 0, 0, left_px / 32, 0), alpha = 255, time = 0) + animate(pixel_z = FNF_STRUM_Y, transform = matrix(1, 0, 0, 0, 0.01, 0), time = hold_left / 100) + animate(note.tail, pixel_z = FNF_STRUM_Y - left_px, alpha = 255, time = 0) + animate(pixel_z = FNF_STRUM_Y - 8, time = hold_left / 100) + update_score_text() + +/datum/fnf_side/proc/finish_hold(datum/fnf_note/note) + light_strum(note.lane, "strum", 0) + live -= note + qdel(note) + update_score_text() + +/// Let go of a long note too early. +/datum/fnf_side/proc/drop_hold(datum/fnf_note/note) + note.holding = FALSE + live -= note + combo = 0 + set_voice(FALSE) + light_strum(note.lane, "strum", 0) + battle.adjust_health(-2, src) + show_rating("miss") + fade_out(note) + update_score_text() + +/datum/fnf_side/proc/miss(datum/fnf_note/note) + note.judged = TRUE + live -= note + misses++ + judged_count++ + combo = 0 + score -= 10 + set_voice(FALSE) + // What the killing blow was decides how they die: a missed shot in 2hot blows up in their face. + battle.last_miss_kind = note.kind + battle.adjust_health(-4, src) + show_rating("miss") + fade_out(note) + if(singer) + SEND_SOUND(singer, sound("voidcrew/modules/fnf/sound/miss[rand(1, 3)].ogg", volume = 45)) + busy_until = world.time + 3 + if(!battle.stage?.note_missed(src, note)) + singer.fnf_miss(mic_arm, facing, style) + if(src == battle.right) + battle.corruption?.player_missed() + update_score_text() + +/// Lets a note that's no longer in play keep drifting up, greyed out, and then go. +/datum/fnf_side/proc/fade_out(datum/fnf_note/note) + for(var/obj/effect/abstract/fnf_hud/sprite as anything in list(note.head, note.body, note.tail)) + if(sprite) + animate(sprite, alpha = 70, time = 1, flags = ANIMATION_PARALLEL) + QDEL_IN(note, 5) + +/datum/fnf_side/proc/light_strum(lane, state, duration) + var/obj/effect/abstract/fnf_hud/strum = strums[lane + 1] + strum.icon_state = "[state]_[lane]" + if(duration) + addtimer(CALLBACK(src, PROC_REF(light_strum), lane, "strum", 0), duration, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_DELETE_ME) + +/datum/fnf_side/proc/splash(lane) + var/obj/effect/abstract/fnf_hud/splash = new + splash.icon = battle.get_note_icon() + splash.icon_state = "splash_[lane]" + splash.pixel_w = lane_x(lane) + splash.pixel_z = FNF_STRUM_Y + splash.layer = ABOVE_ALL_MOB_LAYER + 0.04 + strumline.vis_contents += splash + QDEL_IN(splash, 2) + +/datum/fnf_side/proc/show_rating(rating) + var/static/list/colours = list("sick" = "#7dfcff", "good" = "#8cff5a", "bad" = "#ffd24a", "shit" = "#c9864f", "miss" = "#ff4f4f") + var/static/list/words = list("sick" = "SICK!!", "good" = "Good!", "bad" = "Bad", "shit" = "Shit", "miss" = "Miss") + var/combo_text = combo >= 10 ? "
[combo]" : "" + rating_text.maptext = MAPTEXT("[words[rating]][combo_text]") + rating_text.pixel_z = FNF_STRUM_Y - 30 + animate(rating_text, alpha = 255, pixel_z = FNF_STRUM_Y - 30, time = 0) + animate(pixel_z = FNF_STRUM_Y - 24, time = 2, easing = CUBIC_EASING|EASE_OUT) + animate(alpha = 0, time = 3, delay = 2) + +/// On every beat: bop along, unless busy singing. +/datum/fnf_side/proc/bop(beat_time) + if(world.time < busy_until) + return + if(hanging) + singer?.fnf_hang(beat_time, mic_arm, facing, style) + else + singer?.fnf_bop(beat_time, mic_arm, facing, style) + +/datum/fnf_side/proc/hey() + busy_until = world.time + 8 + singer?.fnf_hey(mic_arm, facing, style) + +/datum/fnf_side/proc/scream() + busy_until = world.time + 8 + singer?.fnf_scream(mic_arm, facing, style) + +/// Acts out a one-off move (see fnf_pose()), and keeps the beat from interrupting it. +/datum/fnf_side/proc/act(kind, hold = 3) + busy_until = world.time + hold + 2 + singer?.fnf_act(kind, mic_arm, facing, style, hold) + +/datum/fnf_side/proc/get_accuracy() + return judged_count ? accuracy_total / judged_count * 100 : 100 + +/datum/fnf_side/proc/update_score_text() + var/cpu = is_cpu ? " (CPU)" : (is_bot() ? " (BOT)" : "") + score_text.maptext = MAPTEXT("[singer_name][cpu]
[score] · [misses] miss · [round(get_accuracy(), 0.1)]%
") + +/// A letter grade from accuracy, and how clean the run was. +/datum/fnf_side/proc/get_grade() + var/accuracy = get_accuracy() + var/letter = "F" + if(!misses && accuracy >= 99.9) + letter = "S+" + else if(accuracy >= 95) + letter = "S" + else if(accuracy >= 90) + letter = "A" + else if(accuracy >= 80) + letter = "B" + else if(accuracy >= 70) + letter = "C" + else if(accuracy >= 60) + letter = "D" + var/clear + if(!misses) + if(!ratings["good"] && !ratings["bad"] && !ratings["shit"]) + clear = "Sick Full Combo" + else if(!ratings["bad"] && !ratings["shit"]) + clear = "Good Full Combo" + else + clear = "Full Combo" + else if(misses < 10) + clear = "Single Digit Combo Breaks" + else + clear = "Clear" + return "[letter], [clear]" + +/datum/fnf_side/proc/get_results() + return "[singer_name]: [score] points, [round(get_accuracy(), 0.01)]% ([get_grade()]). \ + [ratings["sick"]] sick, [ratings["good"]] good, [ratings["bad"]] bad, [ratings["shit"]] shit, [misses] missed, best combo [max_combo]." + +/// The singer's vocals drop out on a miss and come back on the next hit. +/datum/fnf_side/proc/set_voice(on) + // One recording for both: cutting it out on a miss would silence the other singer too. + if(!on && battle.song.shared_voices) + return + voice_muted = !on + battle.set_channel_volume(voice_channel, on ? 1 : 0) + +/datum/fnf_side/proc/on_try_move(mob/source, list/move_args) + SIGNAL_HANDLER + if(battle.state == FNF_STATE_OVER) + return NONE + return COMSIG_MOB_CLIENT_BLOCK_PRE_MOVE + +/datum/fnf_side/proc/on_moved(mob/source, atom/old_loc, movement_dir, forced, list/old_locs, momentum_change) + SIGNAL_HANDLER + if(get_turf(source) == strumline.loc) + return + battle.forfeit(src, "was knocked off the stage") + +/datum/fnf_side/proc/on_stat_change(mob/source, new_stat, old_stat) + SIGNAL_HANDLER + if(new_stat >= UNCONSCIOUS) + battle.forfeit(src, new_stat == DEAD ? "died mid-song" : "passed out mid-song") + +/datum/fnf_side/proc/on_logout(mob/source) + SIGNAL_HANDLER + is_cpu = TRUE + update_score_text() + +/datum/fnf_side/proc/on_login(mob/source) + SIGNAL_HANDLER + if(battle.npc == singer) + return + is_cpu = FALSE + update_score_text() + +/datum/fnf_side/proc/on_singer_deleted(mob/source) + SIGNAL_HANDLER + battle.healthbar?.drop_portrait(singer) + release_singer() + battle.forfeit(src, "vanished") diff --git a/voidcrew/modules/fnf/songs.dm b/voidcrew/modules/fnf/songs.dm new file mode 100644 index 00000000000..33942ef105d --- /dev/null +++ b/voidcrew/modules/fnf/songs.dm @@ -0,0 +1,605 @@ +/** + * Songs for rhythm battles. + * + * A song is a folder holding Inst.ogg, the singers' Voices-.ogg, and the charts in + * Friday Night Funkin's own format: -chart.json and -metadata.json. Notes are + * {"t": time in ms, "d": lane, "l": hold length in ms, "k": kind}; lanes 0-3 (left, down, up, + * right) are the player's, 4-7 the opponent's. + * + * A song can come in variations (Erect remixes, Pico mixes), each its own entry in the song list: + * -chart-.json and -metadata-.json, with Inst-.ogg and + * Voices--.ogg. A Pico mix has you singing as Pico, gun and all. Lyrics, where a + * song has them, are in subtitles/song-lyrics[-].srt. + * + * Songs are picked by week, as Funkin' groups them (data/fnf/weeks/, fetched with the songs). + * + * Charts in the older format most mods use (Psych Engine and the like) work too: .json for + * normal and -.json for the rest, each {"song": {"notes": [sections]}}, with + * Inst.ogg and either Voices.ogg or separate Voices-Player.ogg and Voices-Opponent.ogg. + * + * No songs ship with the game: Funkin's can't, but tools/fnf/fetch_funkin.py downloads them into + * data/fnf/songs/, and any song folder in there (a mod's included) shows up in the song list. + */ + +/// Folders to look for songs in. +GLOBAL_LIST_INIT(fnf_song_dirs, list("data/fnf/songs/")) + +/// Every playable song, by name. Filled the first time a microphone asks. +GLOBAL_LIST(fnf_songs) + +/// The weeks songs are picked from, in order: list(list("name" = label, "songs" = list(song, ...)), ...). +GLOBAL_LIST(fnf_weeks) + +/// Where the week lists are. +#define FNF_WEEKS_DIR "data/fnf/weeks/" + +/// Funkin's own weeks, for when the week lists weren't fetched with the songs. +GLOBAL_LIST_INIT(fnf_default_weeks, list( + "sserafim" = list("name" = "LE SSERAFIM", "songs" = list("spaghetti")), + "tutorial" = list("name" = "TEACHING TIME", "songs" = list("tutorial")), + "week1" = list("name" = "DADDY DEAREST", "songs" = list("bopeebo", "fresh", "dadbattle")), + "week2" = list("name" = "SPOOKY MONTH", "songs" = list("spookeez", "south", "monster")), + "week3" = list("name" = "PICO", "songs" = list("pico", "philly-nice", "blammed")), + "week4" = list("name" = "MOMMY MUST MURDER", "songs" = list("satin-panties", "high", "milf")), + "week5" = list("name" = "RED SNOW", "songs" = list("cocoa", "eggnog", "winter-horrorland")), + "week6" = list("name" = "HATING SIMULATOR FT. MOAWLING", "songs" = list("senpai", "roses", "thorns")), + "week7" = list("name" = "TANKMAN FT. JOHNNYUTAH", "songs" = list("ugh", "guns", "stress")), + "weekend1" = list("name" = "DUE DEBTS", "songs" = list("darnell", "lit-up", "2hot", "blazin")), +)) + +/// Each player's own audio offset in milliseconds, by ckey: how late their sound comes out. +GLOBAL_LIST_EMPTY(fnf_offsets) + +/// Players who'd rather their screen didn't zoom in while they sing, by ckey. +GLOBAL_LIST_EMPTY(fnf_no_zoom) + +/// Players who've turned on botplay, by ckey: their notes hit themselves, every one dead on. +GLOBAL_LIST_EMPTY(fnf_botplay) + +/proc/get_fnf_songs(refresh = FALSE) + if(GLOB.fnf_songs && !refresh) + return GLOB.fnf_songs + var/list/songs = list() + for(var/base in GLOB.fnf_song_dirs) + for(var/entry in flist(base)) + if(copytext(entry, -1) != "/") + continue + var/datum/fnf_song/song = new("[base][entry]", copytext(entry, 1, -1)) + var/list/versions = list(song) + for(var/variation in song.variations) + versions += new /datum/fnf_song("[base][entry]", song.id, variation) + for(var/datum/fnf_song/version as anything in versions) + if(!version.valid) + qdel(version) + continue + var/name = version.name + if(songs[name]) + name = "[name] ([version.id])" + version.list_name = name + songs[name] = version + GLOB.fnf_songs = sort_list(songs) + GLOB.fnf_weeks = null + return GLOB.fnf_songs + +/** + * The songs grouped into weeks, as Funkin' has them: Week 1 is Bopeebo, Fresh and Dadbattle, with + * each one's Erect and Pico mixes right after it. Anything in no week (mods, the song that ships + * with the game) ends up in "Other songs". + */ +/proc/get_fnf_weeks() + var/list/songs = get_fnf_songs() + if(GLOB.fnf_weeks) + return GLOB.fnf_weeks + var/list/by_id = list() + for(var/name in songs) + var/datum/fnf_song/song = songs[name] + LAZYADD(by_id[song.id], song) + var/list/weeks = list() + var/list/week_files = list() + for(var/file_name in flist(FNF_WEEKS_DIR)) + if(copytext(file_name, -5) == ".json") + week_files += copytext(file_name, 1, -5) + // Fetched week lists, and Funkin's own for any that weren't (a mod's songs fetched on their own). + var/list/week_ids = week_files | assoc_to_keys(GLOB.fnf_default_weeks) + sortTim(week_ids, GLOBAL_PROC_REF(cmp_fnf_week)) + for(var/week_id in week_ids) + var/list/week = (week_id in week_files) ? fnf_read_json("[FNF_WEEKS_DIR][week_id].json") : GLOB.fnf_default_weeks[week_id] + var/list/week_songs = list() + for(var/song_id in week?["songs"]) + week_songs += by_id[song_id] + by_id -= song_id + if(length(week_songs)) + weeks += list(list("name" = "[fnf_week_label(week_id)]: [capitalize(lowertext(week["name"] || week_id))]", "songs" = week_songs)) + var/list/others = list() + for(var/song_id in by_id) + others += by_id[song_id] + if(length(others)) + weeks += list(list("name" = "Other songs", "songs" = others)) + GLOB.fnf_weeks = weeks + return weeks + +/// "Week 1" for week1, "Weekend 1" for weekend1, "Tutorial" for tutorial, "Corruption+ 1" for Corruption+'s. +/proc/fnf_week_label(week_id) + if(findtext(week_id, "corruption-") == 1) + return "Corruption+ [copytext(week_id, 12)]" + if(findtext(week_id, "weekend") == 1) + return "Weekend [copytext(week_id, 8)]" + if(findtext(week_id, "week") == 1) + return "Week [copytext(week_id, 5)]" + return capitalize(week_id) + +/// The tutorial first, then the weeks in order, then the weekends, then Corruption+, then anything else. +/proc/cmp_fnf_week(week_a, week_b) + var/rank_a = fnf_week_rank(week_a) + var/rank_b = fnf_week_rank(week_b) + if(rank_a != rank_b) + return rank_a - rank_b + return sorttext(week_b, week_a) + +/proc/fnf_week_rank(week_id) + if(week_id == "tutorial") + return 0 + if(findtext(week_id, "weekend") == 1) + return 2000 + (text2num(copytext(week_id, 8)) || 0) + if(findtext(week_id, "corruption-") == 1) + return 2500 + (text2num(copytext(week_id, 12)) || 0) + if(findtext(week_id, "week") == 1) + return 1000 + (text2num(copytext(week_id, 5)) || 0) + return 3000 + +/// Reads a JSON file, or returns null if it's missing or broken. +/proc/fnf_read_json(path) + if(!fexists(path)) + return null + try + return json_decode(file2text(path)) + catch + return null + +/// A character's name without its variant: "mom" for "mom-car", "senpai" for "senpai-angry". +/proc/fnf_base_character(character) + if(!istext(character)) + return null + var/hyphen = findtext(character, "-") + return hyphen ? copytext(character, 1, hyphen) : character + +/proc/cmp_fnf_note(list/a, list/b) + return a[1] - b[1] + +/datum/fnf_song + /// The folder name, and the start of the chart files' names. + var/id + /// Which mix this is ("erect", "pico"), or null for the original. + var/variation + /// The original's other mixes, to make songs of their own. + var/list/variations = list() + /// What it's called in the song list. + var/list_name + /// The folder, with a trailing slash. + var/path + var/name + var/artist = "Unknown" + var/bpm = 100 + var/list/difficulties = list() + var/inst_file + var/player_voice_file + var/opponent_voice_file + /// Who sings against you, as Funkin' names them without variants ("mom" for "mom-car"). Decides + /// who turns up when nobody's been challenged; see opponents.dm. + var/opponent_character + /// Who you sing as: "bf" usually, "pico" in a Pico mix. + var/player_character = "bf" + /// The same, with Funkin's variant on it: "bf-holding-gf" in Stress, carrying the girlfriend. + var/player_variant + /// Who cheers you on from the back, if anyone ("gf", "nene"). + var/girlfriend_character + /// Timed lyrics, from the song's subtitles: list(list(start ms, end ms, text), ...). + var/list/lyrics + /// Whether there's enough here to play. + var/valid = FALSE + /// For charts in the older format, each difficulty's chart file. + var/list/legacy_files + /// Who an older-format chart starts with, as it names them: list("player", "opponent", "gf"). + var/list/legacy_cast + /// Its "Change Character" events: list(list(ms, "player"/"opponent"/"gf", character), ...). + var/list/character_changes + /// Whether it's one of Corruption+'s (see corruption.dm): its player is one of the mod's characters. + var/corruption = FALSE + /// Which arrows its notes are drawn with, if not ours: "kapi" or "skarlet", Corruption+'s tainted skins. + var/note_skin + /// Its screen and stage events ("Overlay Event", "Image Flash", "Play Animation", "Screen Shake", + /// its flashes, glow and silhouettes): list(list(ms, kind, what, value), ...) (see /datum/fnf_corruption/proc/show()). + var/list/overlay_events + /// Its own game over, if it has one (gameover.json, from the Corruption+ fetcher): list of + /// list("from" = ms, "death" = file, "loop" = file, "end" = file), each from its time on. + var/list/game_over_sounds + /// Whether both singers' vocals are in the one track (the player's), so a miss can't cut them out. + var/shared_voices = FALSE + /// How long its music runs, in milliseconds, if known (info.json, from the Corruption+ fetcher), + /// so a song with no notes left (a cutscene to finish on) plays out. + var/length_ms = 0 + +/datum/fnf_song/New(path, id, variation) + src.path = path + src.id = id + src.variation = variation + name = id + var/suffix = variation ? "-[variation]" : "" + var/list/chart = fnf_read_json("[path][id]-chart[suffix].json") + if(!islist(chart?["notes"])) + if(!variation && fexists("[path]Inst.ogg")) + inst_file = "[path]Inst.ogg" + read_legacy() + return + var/list/meta = fnf_read_json("[path][id]-metadata[suffix].json") + var/list/characters = meta?["playData"]?["characters"] + var/instrumental = characters?["instrumental"] || variation + if(instrumental && fexists("[path]Inst-[instrumental].ogg")) + inst_file = "[path]Inst-[instrumental].ogg" + else if(fexists("[path]Inst.ogg")) + inst_file = "[path]Inst.ogg" + else + return + if(meta) + name = meta["songName"] || id + artist = meta["artist"] || artist + var/list/changes = meta["timeChanges"] + if(length(changes)) + var/list/first_change = changes[1] + bpm = first_change["bpm"] || bpm + player_voice_file = find_voice(characters?["playerVocals"], characters?["player"]) + opponent_voice_file = find_voice(characters?["opponentVocals"], characters?["opponent"]) + opponent_character = fnf_base_character(characters?["opponent"]) + player_character = fnf_base_character(characters?["player"]) || player_character + player_variant = characters?["player"] + girlfriend_character = fnf_base_character(characters?["girlfriend"]) + if(!variation) + for(var/other in meta["playData"]?["songVariations"]) + variations += "[other]" + if(!player_voice_file && fexists("[path]Voices[suffix].ogg")) + player_voice_file = "[path]Voices[suffix].ogg" + if(!player_voice_file && fexists("[path]Voices.ogg")) + player_voice_file = "[path]Voices.ogg" + // Both singers in one recording, as older mods ship them. + shared_voices = !opponent_voice_file + // The metadata says which are difficulties: charts also hold extra tracks under their own names + // (Stress's "picospeaker", the speaker's shots), which aren't songs to sing. + var/list/notes = chart["notes"] + var/list/listed = meta?["playData"]?["difficulties"] + for(var/difficulty in (length(listed) ? listed : notes)) + if(islist(notes[difficulty]) && difficulty != "picospeaker") + difficulties += difficulty + lyrics = read_lyrics("[path]subtitles/song-lyrics[suffix].srt") + valid = length(difficulties) > 0 + +/// Reads a song charted in the older format, one file per difficulty. +/datum/fnf_song/proc/read_legacy() + legacy_files = list() + var/list/first_song + for(var/file_name in flist(path)) + if(copytext(file_name, -5) != ".json") + continue + var/stem = copytext(file_name, 1, -5) + var/difficulty + if(stem == id) + difficulty = "normal" + else if(findtext(stem, "[id]-") == 1) + difficulty = copytext(stem, length(id) + 2) + if(!difficulty || difficulty == "metadata" || difficulty == "chart" || difficulty == "events") + continue + var/list/file_data = fnf_read_json("[path][file_name]") + var/list/song = file_data?["song"] + if(!islist(song?["notes"])) + continue + legacy_files[difficulty] = file_name + difficulties += difficulty + if(!first_song) + first_song = song + if(!first_song) + return + name = first_song["song"] || id + if(isnum(first_song["bpm"])) + bpm = first_song["bpm"] + legacy_cast = list("player" = first_song["player1"], "opponent" = first_song["player2"], "gf" = first_song["gfVersion"] || first_song["player3"]) + character_changes = read_character_changes(first_song) + if(fexists("[path]gameover.json")) + game_over_sounds = json_decode(file2text("[path]gameover.json")) + if(fexists("[path]info.json")) + var/list/info = json_decode(file2text("[path]info.json")) + if(isnum(info?["length_ms"])) + length_ms = info["length_ms"] + overlay_events = read_overlay_events(first_song) + var/static/list/skins = list("NOTE_assetsKapi" = "kapi", "NOTE_assetsSkarlet" = "skarlet") + note_skin = skins[first_song["arrowSkin"]] + if(fexists("[path]Voices-Player.ogg")) + player_voice_file = "[path]Voices-Player.ogg" + else + player_voice_file = find_voice(null, first_song["player1"]) + opponent_character = fnf_base_character(first_song["player2"]) + // Corruption+'s songs: its characters, as their looks here. + if(GLOB.fnf_corruption_cast[legacy_cast["player"]]) + corruption = TRUE + opponent_character = GLOB.fnf_corruption_cast[legacy_cast["opponent"]]?["look"] + // The player sings as the mod's character does: corrupted Pico all through Kapi's arcade. + player_character = GLOB.fnf_corruption_cast[legacy_cast["player"]]?["look"] || player_character + // Whoever's behind the speakers, wherever in the song they turn up first. + girlfriend_character = GLOB.fnf_corruption_cast[legacy_cast["gf"]]?["look"] + for(var/list/change as anything in character_changes) + if(!girlfriend_character && change[2] == "gf") + girlfriend_character = GLOB.fnf_corruption_cast[change[3]]?["look"] + if(fexists("[path]Voices-Opponent.ogg")) + opponent_voice_file = "[path]Voices-Opponent.ogg" + else + opponent_voice_file = find_voice(null, first_song["player2"]) + if(!player_voice_file && fexists("[path]Voices.ogg")) + player_voice_file = "[path]Voices.ogg" + // Both singers in one recording, as older mods ship them. + shared_voices = !opponent_voice_file + valid = TRUE + +/// The song's own game over sounds for dying at this point in it: list("death", "loop", "end" = file), or null. +/datum/fnf_song/proc/get_game_over_sounds(ms) + var/list/chosen + for(var/list/sounds in game_over_sounds) + if(!isnum(sounds["from"]) || sounds["from"] <= ms) + chosen = sounds + if(!chosen) + return null + . = list() + for(var/part in list("death", "loop", "end")) + if(istext(chosen[part]) && fexists("[path][chosen[part]]")) + .[part] = file("[path][chosen[part]]") + +/** + * An older-format song's "Change Character" events, from its chart and its events.json (Psych + * Engine keeps them in either, or both), in order: list(list(ms, role, character), ...). + */ +/datum/fnf_song/proc/read_character_changes(list/song_data) + var/list/raw = read_raw_events(song_data) + var/static/list/roles = list("0" = "player", "bf" = "player", "boyfriend" = "player", "1" = "opponent", "dad" = "opponent", "2" = "gf", "gf" = "gf", "girlfriend" = "gf") + var/list/seen = list() + . = list() + for(var/list/entry as anything in raw) + var/list/events = entry[2] + if(!islist(events)) + continue + for(var/list/event in events) + if(length(event) < 3 || event[1] != "Change Character") + continue + var/role = roles[lowertext(trim("[event[2]]"))] + var/key = "[entry[1]]-[role]-[event[3]]" + if(!role || seen[key]) + continue + seen[key] = TRUE + . += list(list(entry[1], role, "[event[3]]")) + sortTim(., GLOBAL_PROC_REF(cmp_fnf_note)) + +/// An older-format song's events, as Psych Engine keeps them: list(list(ms, list(list(name, value, value), ...)), ...). +/datum/fnf_song/proc/read_raw_events(list/song_data) + . = list() + for(var/list/entry in song_data["events"]) + if(length(entry) >= 2) + . += list(entry) + var/list/events_file = fnf_read_json("[path]events.json") + for(var/list/entry in events_file?["song"]?["events"]) + if(length(entry) >= 2) + . += list(entry) + +/** + * An older-format song's overlays (Corruption+'s "Overlay Event": 1 fades an image in, 0 fades it + * out) and flashes ("Image Flash": an image up for so many seconds), in order, once each. + */ +/datum/fnf_song/proc/read_overlay_events(list/song_data) + var/list/seen = list() + . = list() + for(var/list/entry as anything in read_raw_events(song_data)) + var/list/events = entry[2] + if(!islist(events)) + continue + for(var/list/event in events) + if(length(event) < 3) + continue + var/list/found + if(event[1] == "Overlay Event") + found = list(entry[1], "overlay", "[event[3]]", text2num("[event[2]]")) + else if(event[1] == "Image Flash") + found = list(entry[1], "flash", "[event[2]]", text2num("[event[3]]")) + // Someone acting something out (corrupted Pico's scream), and the screen shaking with it. + else if(event[1] == "Play Animation") + found = list(entry[1], "anim", "[event[2]]", lowertext("[event[3]]")) + else if(event[1] == "Screen Shake") + found = list(entry[1], "shake", "", text2num(splittext("[event[2]]", ",")[1])) + // Flashes: white, crimson, black, each fading over its value in seconds. + else if(event[1] == "Light" || event[1] == "Lightr" || event[1] == "Lightb") + var/static/list/lights = list("Light" = "#ffffff", "Lightr" = "#960030", "Lightb" = "#000000") + found = list(entry[1], "light", lights[event[1]], text2num("[event[2]]") || 0.5) + else if(event[1] == "BlackScreenEvent") + found = list(entry[1], "black", "[event[2]]", text2num("[event[3]]") || 0.5) + // Its "flashBom" bloom shader is left out: a flash of glow every bar or two, more glare + // than anything, and too much drawing. + // Silhouettes: black on white ("a" on, "b" off), white on black, or in colour on black (toggles). + else if(event[1] == "badapplelol") + found = list(entry[1], "apple", lowertext("[event[2]]") == "a" ? "white" : "off", text2num("[event[3]]") || 0.5) + else if(event[1] == "invertedapplebyflain") + found = list(entry[1], "apple", "toggle-black", text2num("[event[2]]") || 0.1) + else if(event[1] == "iconcoloredapplebyflain") + found = list(entry[1], "apple", "toggle-colour", text2num("[event[2]]") || 0.1) + else if(event[1] == "Blammed Lights") + found = list(entry[1], "blammed", "", text2num("[event[2]]") || 0) + // Health: taken (or given back) at once, or taken with each of the opponent's notes from now on. + else if(event[1] == "Drain") + found = list(entry[1], "drain", "", text2num("[event[2]]") || 0) + else if(event[1] == "HealthDrainCustom") + found = list(entry[1], "health_drain", "[text2num("[event[3]]") || 0]", text2num("[event[2]]") || 0) + // Words on screen (Purification's "That's the you I want to see..."), as far as they've got, in a colour. + else if(event[1] == "Set_Subtitle") + // "Words:speaker", or nothing, to clear them. + var/list/words = splittext("[event[2]]", ":") + found = list(entry[1], "subtitle", length(words) ? words[1] : "", "#[event[3] || "FFFFFF"]") + // Someone idling another way (Purification's Girlfriend, once Boyfriend's taken over): "-alt", or "" back. + else if(event[1] == "Alt Idle Animation") + found = list(entry[1], "alt_idle", lowertext("[event[2]]"), "[event[3]]") + if(!found) + continue + var/key = jointext(found, "-") + if(seen[key]) + continue + seen[key] = TRUE + . += list(found) + sortTim(., GLOBAL_PROC_REF(cmp_fnf_note)) + +/// The first Voices-.ogg that exists for these singers. Funkin' names variants like +/// "bf-car", while the file is just Voices-bf, so the part before any hyphen is tried too. A mix's +/// own recording (Voices-pico-pico.ogg for the Pico mix) comes before the original's. +/datum/fnf_song/proc/find_voice(list/vocals, character) + var/list/candidates = list() + for(var/vocal in vocals) + candidates += "[vocal]" + if(character) + candidates += "[character]" + for(var/candidate in candidates.Copy()) + var/hyphen = findtext(candidate, "-") + if(hyphen) + candidates |= copytext(candidate, 1, hyphen) + if(variation) + var/list/mixed = list() + for(var/candidate in candidates) + mixed += "[candidate]-[variation]" + candidates = mixed + candidates + for(var/candidate in candidates) + if(fexists("[path]Voices-[candidate].ogg")) + return "[path]Voices-[candidate].ogg" + +/// Reads timed lyrics from a SubRip (.srt) file: list(list(start ms, end ms, text), ...), or null. +/datum/fnf_song/proc/read_lyrics(file_path) + if(!fexists(file_path)) + return null + . = list() + var/list/lines = splittext(replacetext(file2text(file_path), ascii2text(13), ""), "\n") + var/index = 1 + while(index <= length(lines)) + var/line = lines[index] + index++ + var/arrow = findtext(line, "-->") + if(!arrow) + continue + var/start = fnf_srt_time(copytext(line, 1, arrow)) + var/finish = fnf_srt_time(copytext(line, arrow + 3)) + var/list/text_lines = list() + while(index <= length(lines) && length(trim(lines[index]))) + text_lines += trim(lines[index]) + index++ + if(!isnull(start) && !isnull(finish) && length(text_lines)) + . += list(list(start, finish, jointext(text_lines, " "))) + +/// "00:01:02,500" as milliseconds. +/proc/fnf_srt_time(text) + var/list/parts = splittext(trim(replacetext(text, ",", ".")), ":") + if(length(parts) != 3) + return null + var/hours = text2num(parts[1]) + var/minutes = text2num(parts[2]) + var/seconds = text2num(parts[3]) + if(isnull(hours) || isnull(minutes) || isnull(seconds)) + return null + return round(((hours * 60 + minutes) * 60 + seconds) * 1000) + +/** + * Loads one difficulty's chart. Returns list("player" = notes, "opponent" = notes, + * "events" = events, "speed" = scroll speed, "speaker" = list(list(ms, direction), ...)), with each note a list(time in ms, lane 0-3, + * hold length in ms, kind or null), in time order. + */ +/datum/fnf_song/proc/load_chart(difficulty) + if(legacy_files) + return load_legacy_chart(difficulty) + var/list/chart = fnf_read_json("[path][id]-chart[variation ? "-[variation]" : ""].json") + var/list/raw = chart?["notes"]?[difficulty] + var/list/player = list() + var/list/opponent = list() + for(var/list/note in raw) + var/lane = note["d"] + var/time = note["t"] + if(!isnum(lane) || !isnum(time) || lane < 0 || lane > 7) + continue + // Notes that hurt to hit are a mechanic of their own, not played here. + var/kind = note["k"] + if(istext(kind) && (findtext(kind, "hurt") || findtext(kind, "mine"))) + continue + var/hold = note["l"] + var/list/entry = list(time, lane % 4, isnum(hold) ? max(hold, 0) : 0, istext(kind) ? kind : null) + if(lane < 4) + player += list(entry) + else + opponent += list(entry) + sortTim(player, GLOBAL_PROC_REF(cmp_fnf_note)) + sortTim(opponent, GLOBAL_PROC_REF(cmp_fnf_note)) + var/speed = chart?["scrollSpeed"] + if(islist(speed)) + var/list/speeds = speed + speed = speeds[difficulty] || speeds["default"] || speeds["normal"] + if(!isnum(speed)) + speed = 1.3 + var/list/events = list() + for(var/list/event in chart?["events"]) + if(isnum(event["t"])) + events += list(event) + // Stress's speaker track: when (and which way) whoever's on the speaker shoots a tankman. + var/list/speaker = list() + for(var/list/shot in chart?["notes"]?["picospeaker"]) + if(isnum(shot["t"]) && isnum(shot["d"])) + speaker += list(list(shot["t"], shot["d"])) + sortTim(speaker, GLOBAL_PROC_REF(cmp_fnf_note)) + return list("player" = remove_stacked(player), "opponent" = remove_stacked(opponent), "events" = events, "speed" = speed, "speaker" = speaker) + +/// The older format: the notes come in sections, and each section says whose turn it is. Lanes +/// 0-3 are whoever's turn it is, 4-7 the other singer, except in Psych Engine 1.0 charts, +/// where 0-3 are always the player. +/datum/fnf_song/proc/load_legacy_chart(difficulty) + var/list/file_data = fnf_read_json("[path][legacy_files[difficulty]]") + var/list/song = file_data?["song"] + var/fixed_lanes = findtext("[song?["format"]]", "psych_v1") + var/list/player = list() + var/list/opponent = list() + for(var/list/section in song?["notes"]) + var/players_turn = section["mustHitSection"] + for(var/list/raw in section["sectionNotes"]) + if(length(raw) < 2) + continue + var/time = raw[1] + var/lane = raw[2] + if(!isnum(time) || !isnum(lane) || lane < 0 || lane > 7) + continue + var/kind = length(raw) >= 4 ? raw[4] : null + if(istext(kind) && (findtext(kind, "hurt") || findtext(kind, "mine"))) + continue + if(kind == "No Animation") + kind = "noanim" + var/hold = length(raw) >= 3 ? raw[3] : 0 + var/players_note = fixed_lanes ? lane < 4 : (players_turn ? lane < 4 : lane >= 4) + // A section the one in the girlfriend's place sings (Psych Engine's gfSection): the + // player plays it, but she's the one singing. + if(players_note && section["gfSection"] && !istext(kind)) + kind = "gf" + var/list/entry = list(time, lane % 4, isnum(hold) ? max(hold, 0) : 0, istext(kind) ? kind : null) + if(players_note) + player += list(entry) + else + opponent += list(entry) + sortTim(player, GLOBAL_PROC_REF(cmp_fnf_note)) + sortTim(opponent, GLOBAL_PROC_REF(cmp_fnf_note)) + var/speed = song?["speed"] + return list("player" = remove_stacked(player), "opponent" = remove_stacked(opponent), "events" = list(), "speed" = isnum(speed) ? speed : 1.3) + +/// Drops notes charted twice on the same lane at the same time. +/datum/fnf_song/proc/remove_stacked(list/notes) + . = list() + var/list/last_by_lane = list() + for(var/list/note as anything in notes) + var/lane_key = "[note[2]]" + var/last_time = last_by_lane[lane_key] + if(!isnull(last_time) && note[1] - last_time < 5) + continue + last_by_lane[lane_key] = note[1] + . += list(note) + +#undef FNF_WEEKS_DIR diff --git a/voidcrew/modules/fnf/sound/attribution.txt b/voidcrew/modules/fnf/sound/attribution.txt new file mode 100644 index 00000000000..e4d4911c026 --- /dev/null +++ b/voidcrew/modules/fnf/sound/attribution.txt @@ -0,0 +1,29 @@ +Friday Night Funkin' sounds, by The Funkin' Crew (https://github.com/FunkinCrew/funkin.assets): +count3, count2, count1, countgo - the countdown (introTHREE, introTWO, introONE, introGO) +miss1-3 - missnote1-3 +loss - fnf_loss_sfx +gameover_loop, gameover_retry - gameOver, gameOverEnd +shot1-4, gun_cock - Pico's gun, from Weekend 1 (shot1-4, Gun_Prep) +loss_pico, gameover_loop_pico, gameover_retry_pico - Pico's game over (fnf_loss_sfx-pico, gameOver-pico, gameOverEnd-pico) +thunder1-2, train_passes - Week 2's lightning and Week 3's train (thunder_1-2, train_passes) +dialogue_blip, dialogue_music, dialogue_music_scary - Week 6's dialogue (pixelText, Lunchbox, LunchboxScary) +loss_pico_explode, loss_pico_gutpunch - Pico's Weekend 1 deaths (fnf_loss_sfx-pico-explode, fnf_loss_sfx-pico-gutpunch) +The RETRY and GIVE UP buttons in icons/fnf_buttons.dmi are lettered in the game's font. +icons/fnf_speakers.dmi - Nene's A-Bot (abotSystem, stereoBG, aBotViz), made small + +Friday Night Funkin': Corruption+, by Carlito049 (https://gamejolt.com/games/fnfcorruptionplus/793089), +a fan extension of the Corruption mod by Phantom Fear and Pincer. Used with credit, as Carlito asks. +Its characters: Kapi by PaperKitty, Skarlet Bunny by Rechi, Carol by bb-panzu. Broken Wires by FellowDesert51. +icons/fnf_notes_kapi.dmi, icons/fnf_notes_skarlet.dmi - its tainted arrows (NOTE_assetsKapi, +NOTE_assetsSkarlet, noteSplashesKapi), made small +icons/fnf_healthbar_tainted.dmi - Skarlet's health bar frame (hpBar) +icons/fnf_speakers.dmi - the speakers and the corrupted ones (speakers, EVILspeakers), made small +icons/fnf_props.dmi - Marble's speaker (from speakers), made small, and Kapi's dance pads, drawn after +his sprites (kapi, corruptedkapi) +icons/fnf_corruption_overlays.dmi - its overlays and flashes (scary, prey, hunter, shadowarcade, +overlayskarlet and the rest), at a quarter size +Its songs and their game overs (Kapi's, bloodlust and null) aren't shipped: tools/fnf/fetch_corruption_plus.py fetches them from GameJolt. + +Casualties Unknown, by Orsoniks: +laststand_drone - the last stand drone (laststanddrone), cut to its first 8 seconds +The last stand screens in icons/laststand.dmi are the game's laststand1-3. diff --git a/voidcrew/modules/fnf/sound/count1.ogg b/voidcrew/modules/fnf/sound/count1.ogg new file mode 100644 index 00000000000..a42cb17333a Binary files /dev/null and b/voidcrew/modules/fnf/sound/count1.ogg differ diff --git a/voidcrew/modules/fnf/sound/count2.ogg b/voidcrew/modules/fnf/sound/count2.ogg new file mode 100644 index 00000000000..990701bba15 Binary files /dev/null and b/voidcrew/modules/fnf/sound/count2.ogg differ diff --git a/voidcrew/modules/fnf/sound/count3.ogg b/voidcrew/modules/fnf/sound/count3.ogg new file mode 100644 index 00000000000..dc660504c35 Binary files /dev/null and b/voidcrew/modules/fnf/sound/count3.ogg differ diff --git a/voidcrew/modules/fnf/sound/countgo.ogg b/voidcrew/modules/fnf/sound/countgo.ogg new file mode 100644 index 00000000000..b1f0086f85f Binary files /dev/null and b/voidcrew/modules/fnf/sound/countgo.ogg differ diff --git a/voidcrew/modules/fnf/sound/dialogue_blip.ogg b/voidcrew/modules/fnf/sound/dialogue_blip.ogg new file mode 100644 index 00000000000..6f3b8f24e9d Binary files /dev/null and b/voidcrew/modules/fnf/sound/dialogue_blip.ogg differ diff --git a/voidcrew/modules/fnf/sound/dialogue_music.ogg b/voidcrew/modules/fnf/sound/dialogue_music.ogg new file mode 100644 index 00000000000..5fb010536a1 Binary files /dev/null and b/voidcrew/modules/fnf/sound/dialogue_music.ogg differ diff --git a/voidcrew/modules/fnf/sound/dialogue_music_scary.ogg b/voidcrew/modules/fnf/sound/dialogue_music_scary.ogg new file mode 100644 index 00000000000..da8646a7376 Binary files /dev/null and b/voidcrew/modules/fnf/sound/dialogue_music_scary.ogg differ diff --git a/voidcrew/modules/fnf/sound/gameover_loop.ogg b/voidcrew/modules/fnf/sound/gameover_loop.ogg new file mode 100644 index 00000000000..7f42193facc Binary files /dev/null and b/voidcrew/modules/fnf/sound/gameover_loop.ogg differ diff --git a/voidcrew/modules/fnf/sound/gameover_loop_pico.ogg b/voidcrew/modules/fnf/sound/gameover_loop_pico.ogg new file mode 100644 index 00000000000..b19dfed189d Binary files /dev/null and b/voidcrew/modules/fnf/sound/gameover_loop_pico.ogg differ diff --git a/voidcrew/modules/fnf/sound/gameover_retry.ogg b/voidcrew/modules/fnf/sound/gameover_retry.ogg new file mode 100644 index 00000000000..f1010742075 Binary files /dev/null and b/voidcrew/modules/fnf/sound/gameover_retry.ogg differ diff --git a/voidcrew/modules/fnf/sound/gameover_retry_pico.ogg b/voidcrew/modules/fnf/sound/gameover_retry_pico.ogg new file mode 100644 index 00000000000..038ca609c68 Binary files /dev/null and b/voidcrew/modules/fnf/sound/gameover_retry_pico.ogg differ diff --git a/voidcrew/modules/fnf/sound/gun_cock.ogg b/voidcrew/modules/fnf/sound/gun_cock.ogg new file mode 100644 index 00000000000..efda55ff50a Binary files /dev/null and b/voidcrew/modules/fnf/sound/gun_cock.ogg differ diff --git a/voidcrew/modules/fnf/sound/laststand_drone.ogg b/voidcrew/modules/fnf/sound/laststand_drone.ogg new file mode 100644 index 00000000000..1eb9be12568 Binary files /dev/null and b/voidcrew/modules/fnf/sound/laststand_drone.ogg differ diff --git a/voidcrew/modules/fnf/sound/loss.ogg b/voidcrew/modules/fnf/sound/loss.ogg new file mode 100644 index 00000000000..1d70db2fc4b Binary files /dev/null and b/voidcrew/modules/fnf/sound/loss.ogg differ diff --git a/voidcrew/modules/fnf/sound/loss_pico.ogg b/voidcrew/modules/fnf/sound/loss_pico.ogg new file mode 100644 index 00000000000..467a45f53f7 Binary files /dev/null and b/voidcrew/modules/fnf/sound/loss_pico.ogg differ diff --git a/voidcrew/modules/fnf/sound/loss_pico_explode.ogg b/voidcrew/modules/fnf/sound/loss_pico_explode.ogg new file mode 100644 index 00000000000..fbedcbba2e2 Binary files /dev/null and b/voidcrew/modules/fnf/sound/loss_pico_explode.ogg differ diff --git a/voidcrew/modules/fnf/sound/loss_pico_gutpunch.ogg b/voidcrew/modules/fnf/sound/loss_pico_gutpunch.ogg new file mode 100644 index 00000000000..34beba7ad79 Binary files /dev/null and b/voidcrew/modules/fnf/sound/loss_pico_gutpunch.ogg differ diff --git a/voidcrew/modules/fnf/sound/miss1.ogg b/voidcrew/modules/fnf/sound/miss1.ogg new file mode 100644 index 00000000000..b8bfc8ea3f0 Binary files /dev/null and b/voidcrew/modules/fnf/sound/miss1.ogg differ diff --git a/voidcrew/modules/fnf/sound/miss2.ogg b/voidcrew/modules/fnf/sound/miss2.ogg new file mode 100644 index 00000000000..c38c4849890 Binary files /dev/null and b/voidcrew/modules/fnf/sound/miss2.ogg differ diff --git a/voidcrew/modules/fnf/sound/miss3.ogg b/voidcrew/modules/fnf/sound/miss3.ogg new file mode 100644 index 00000000000..59506db6a04 Binary files /dev/null and b/voidcrew/modules/fnf/sound/miss3.ogg differ diff --git a/voidcrew/modules/fnf/sound/shot1.ogg b/voidcrew/modules/fnf/sound/shot1.ogg new file mode 100644 index 00000000000..a8f7ee268b5 Binary files /dev/null and b/voidcrew/modules/fnf/sound/shot1.ogg differ diff --git a/voidcrew/modules/fnf/sound/shot2.ogg b/voidcrew/modules/fnf/sound/shot2.ogg new file mode 100644 index 00000000000..806ef8a4965 Binary files /dev/null and b/voidcrew/modules/fnf/sound/shot2.ogg differ diff --git a/voidcrew/modules/fnf/sound/shot3.ogg b/voidcrew/modules/fnf/sound/shot3.ogg new file mode 100644 index 00000000000..6f926a33a7a Binary files /dev/null and b/voidcrew/modules/fnf/sound/shot3.ogg differ diff --git a/voidcrew/modules/fnf/sound/shot4.ogg b/voidcrew/modules/fnf/sound/shot4.ogg new file mode 100644 index 00000000000..8142acd9371 Binary files /dev/null and b/voidcrew/modules/fnf/sound/shot4.ogg differ diff --git a/voidcrew/modules/fnf/sound/thunder1.ogg b/voidcrew/modules/fnf/sound/thunder1.ogg new file mode 100644 index 00000000000..bd65a822537 Binary files /dev/null and b/voidcrew/modules/fnf/sound/thunder1.ogg differ diff --git a/voidcrew/modules/fnf/sound/thunder2.ogg b/voidcrew/modules/fnf/sound/thunder2.ogg new file mode 100644 index 00000000000..1356515f47a Binary files /dev/null and b/voidcrew/modules/fnf/sound/thunder2.ogg differ diff --git a/voidcrew/modules/fnf/sound/train_passes.ogg b/voidcrew/modules/fnf/sound/train_passes.ogg new file mode 100644 index 00000000000..f695954f135 Binary files /dev/null and b/voidcrew/modules/fnf/sound/train_passes.ogg differ diff --git a/voidcrew/modules/fnf/stage.dm b/voidcrew/modules/fnf/stage.dm new file mode 100644 index 00000000000..c4c88f9ef72 --- /dev/null +++ b/voidcrew/modules/fnf/stage.dm @@ -0,0 +1,793 @@ +/** + * Everything on a rhythm battle's stage besides the two singers singing: + * - The girlfriend (or Nene), summoned to stand behind them, bopping to the beat and cheering. In + * Stress she's Pico (or Otis) up on the speaker, gunning down the tankmen who run at the stage + * from both sides, on the song's own speaker track. Week 4 brings Mommy's henchmen, dancing. + * - The song's lyrics, where it has them, under the stage. + * - Chart events that have someone do something: Boyfriend's "hey!", Tankman's "ugh", Pico's + * burp. + * - Notes that are a move rather than a line. Tankman's "ugh" notes, and all of Weekend 1: in + * 2hot Darnell lights cans and kicks them up for Pico to shoot, and Blazin' is a fistfight, every + * note a punch, block or dodge. + * - What the song's stage does. Week 2's mansion gets struck by lightning, scaring everyone; Week 3's + * train comes rumbling past. Week 6 opens with its dialogue, and Darnell with Darnell lighting a + * can for Pico to shoot. + */ +/datum/fnf_stage + var/datum/fnf_battle/battle + /// Whoever's cheering from the back, summoned for the song. + var/mob/living/carbon/human/girlfriend + /// Whoever the player carries through the song (Stress: Girlfriend, or Nene clinging to Pico). + var/mob/living/carbon/human/carried + /// Until when the girlfriend's busy (shooting), and shouldn't bop. + var/girlfriend_busy_until = 0 + /// Which way whoever's in the girlfriend's place faces: the front, or round at someone. + var/girlfriend_facing = SOUTH + /// Week 4's backup dancers. + var/list/mob/living/carbon/human/henchmen = list() + /// Stress's tankmen: every one made so far, and the ones free to run in again. + var/list/mob/living/carbon/human/soldiers = list() + var/list/mob/living/carbon/human/idle_soldiers = list() + /// The next shot on the speaker track to send a tankman in for. + var/next_shot = 1 + /// Where the singers stand, for the tankmen to run at. + var/turf/left_spot + var/turf/right_spot + /// The knife stuck in Tankman's head, and whose head it's in. + var/image/head_knife + var/atom/knifed + /// The next line of the lyrics to show, and when the one showing ends (song ms). + var/next_lyric = 1 + var/lyric_ends = -1 + var/obj/effect/abstract/fnf_hud/text/lyric_text + /// The can in the air in 2hot, if there is one. + var/obj/effect/abstract/fnf_hud/can + /// How far apart the singers stand, in pixels. + var/gap_px = 96 + /// Week 2's lightning: the beat it last struck on, and how many beats until it can again. + var/lightning_beat = 0 + var/lightning_wait = 8 + /// Week 3's train: beats since it last passed, and whether it's passing now. + var/train_cooldown = 0 + var/train_passing = FALSE + /// Week 6's dialogue box before the song, and its lines still to come. + var/obj/effect/abstract/fnf_hud/text/dialogue_text + var/list/dialogue_lines + var/dialogue_channel + +/datum/fnf_stage/New(datum/fnf_battle/battle, turf/left_turf, turf/right_turf) + src.battle = battle + gap_px = (right_turf.x - left_turf.x) * world.icon_size + left_spot = left_turf + right_spot = right_turf + summon_girlfriend(left_turf, right_turf) + summon_carried(right_turf) + if(battle.song.id in list("satin-panties", "high", "milf")) + summon_henchmen(left_turf, right_turf) + if(length(battle.song.lyrics) && battle.healthbar) + lyric_text = new + lyric_text.maptext_width = 256 + lyric_text.maptext_x = -112 + lyric_text.layer = ABOVE_ALL_MOB_LAYER + 0.05 + lyric_text.pixel_w = battle.healthbar.center_x - 16 + lyric_text.pixel_z = battle.healthbar.center_y - FNF_STRUM_Y - 44 - 30 + battle.healthbar.vis_contents += lyric_text + start_intro() + +/datum/fnf_stage/Destroy() + QDEL_NULL(seat) + QDEL_LIST_ASSOC_VAL(singer_props) + if(girlfriend && !QDELETED(girlfriend)) + do_sparks(2, FALSE, girlfriend) + qdel(girlfriend) + girlfriend = null + if(carried && !QDELETED(carried)) + qdel(carried) + carried = null + QDEL_LIST(henchmen) + for(var/mob/living/soldier as anything in soldiers) + GLOB.move_manager.stop_looping(soldier) + QDEL_LIST(soldiers) + idle_soldiers.Cut() + if(knifed && head_knife) + knifed.cut_overlay(head_knife) + knifed = null + head_knife = null + battle?.healthbar?.vis_contents -= lyric_text + QDEL_NULL(lyric_text) + battle?.healthbar?.vis_contents -= dialogue_text + QDEL_NULL(dialogue_text) + QDEL_NULL(can) + if(dialogue_channel) + for(var/mob/listener as anything in battle?.listeners) + SEND_SOUND(listener, sound(null, channel = dialogue_channel)) + SSsounds.free_datum_channels(src) + battle = null + return ..() + +/// She stands a step behind, halfway between the singers, if there's room. +/datum/fnf_stage/proc/summon_girlfriend(turf/left_turf, turf/right_turf) + summon_girlfriend_as(battle.song.girlfriend_character) + +/// Summons someone to stand behind the singers, as one of the looks in opponents.dm. +/datum/fnf_stage/proc/summon_girlfriend_as(character) + if(!GLOB.fnf_opponents[character]) + return + var/turf/middle = get_girlfriend_spot() + if(!middle || (locate(/mob/living) in middle)) + return + girlfriend = fnf_summon_opponent(character, middle) + var/speaker_shooter = length(battle.chart["speaker"]) + for(var/obj/item/held in girlfriend.held_items) + if(!speaker_shooter || !istype(held, /obj/item/toy/fnf_gun)) + qdel(held) + // Whoever's up on the speaker in Stress dual-wields: Pico two guns, Otis two rifles. + if(speaker_shooter) + var/gun_type = GLOB.fnf_opponents[character]["gun"] + if(!ispath(gun_type)) + gun_type = /obj/item/toy/fnf_gun + girlfriend.put_in_hands(new gun_type(girlfriend)) + girlfriend.setDir(SOUTH) + do_sparks(2, FALSE, girlfriend) + // Up on her speakers (Corruption+'s songs say what she's on themselves). + if(!battle.song.corruption) + var/list/seat_for = get_seat_for(character) + set_seat(seat_for?[1], seat_for?[2]) + +/// In Stress, the player sings holding someone: Boyfriend carries Girlfriend, Nene clings to Pico's +/// back. They ride along on the player's tile, just behind them. +/datum/fnf_stage/proc/summon_carried(turf/right_turf) + var/static/list/carried_by = list("bf-holding-gf" = "gf", "pico-holding-nene" = "nene") + var/character = carried_by[battle.song.player_variant] + if(!character || !battle.right?.singer) + return + carried = fnf_summon_opponent(character, right_turf) + for(var/obj/item/held in carried.held_items) + qdel(held) + carried.setDir(battle.right.facing) + // Behind the singer (who faces west), a little up off the floor, and drawn behind them. + carried.pixel_w = battle.right.facing == WEST ? 7 : -7 + carried.pixel_z = 5 + carried.layer = battle.right.singer.layer - 0.01 + +/// Week 4: three of Mommy's henchmen dancing in a row behind the stage, where there's room. +/datum/fnf_stage/proc/summon_henchmen(turf/left_turf, turf/right_turf) + for(var/spot_x in list(left_turf.x, round((left_turf.x + right_turf.x) / 2), right_turf.x)) + var/turf/spot = locate(spot_x, left_turf.y + 2, left_turf.z) + if(!spot || spot.is_blocked_turf(exclude_mobs = FALSE)) + continue + var/mob/living/carbon/human/henchman = fnf_summon_opponent("henchman", spot) + for(var/obj/item/held in henchman.held_items) + qdel(held) + henchman.setDir(SOUTH) + henchmen += henchman + +/datum/fnf_stage/proc/bop(beat_time) + if(girlfriend && !QDELETED(girlfriend) && world.time >= girlfriend_busy_until) + // Corrupted Marble doesn't dance: she glares at the enemy. + if(girlfriend.fnf_look == "marble") + girlfriend.fnf_stare(beat_time, get_enemy_side(girlfriend)) + else + girlfriend.setDir(girlfriend_facing) + girlfriend.fnf_bop(beat_time, girlfriend_facing == SOUTH ? RIG_R_ARM : girlfriend.fnf_mic_arm(girlfriend_facing), girlfriend_facing, null) + seat?.thump() + for(var/mob/living/carbon/human/henchman as anything in henchmen) + henchman.fnf_dance(beat_time, battle.last_beat % 2) + if(carried && !QDELETED(carried)) + carried.fnf_bop(beat_time, RIG_R_ARM, battle.right.facing, null) + var/beat = battle.last_beat + switch(battle.song.id) + if("spookeez", "south", "monster") + // Struck at random, never twice within 8 to 24 beats. Spookeez opens with one, silent. + if(beat == 4 && battle.song.id == "spookeez") + lightning(FALSE) + else if(beat > lightning_beat + lightning_wait && prob(10)) + lightning(TRUE) + if("pico", "philly-nice", "blammed") + // Now and then, on the middle of a bar, a train. + if(!train_passing) + train_cooldown++ + if(beat % 8 == 4 && train_cooldown > 8 && prob(30)) + train_cooldown = rand(-4, 0) + pass_train() + +/// Everyone listening: the singers, and anyone watching. +/datum/fnf_stage/proc/play_to_all(sound_file, volume = 60) + for(var/mob/listener as anything in battle.listeners) + SEND_SOUND(listener, sound(sound_file, volume = volume * (battle.listeners[listener] || 1))) + +/// The mansion lit up by lightning: a flash, thunder, and Boyfriend and the girlfriend jumping. +/datum/fnf_stage/proc/lightning(with_thunder) + lightning_beat = battle.last_beat + lightning_wait = rand(8, 24) + if(with_thunder) + play_to_all("voidcrew/modules/fnf/sound/thunder[rand(1, 2)].ogg", 70) + for(var/mob/listener as anything in battle.listeners) + if(listener.client) + flash_color(listener.client, "#dde8ff", 4) + battle.right?.act("hit_high", 4) + if(girlfriend && !QDELETED(girlfriend)) + girlfriend.fnf_act("hit_high", RIG_R_ARM, SOUTH, null, 4) + +/// A train thunders past behind the stage: the rumble builds, the girlfriend's hair whips about as +/// it goes by, and it's gone. +/datum/fnf_stage/proc/pass_train() + train_passing = TRUE + play_to_all("voidcrew/modules/fnf/sound/train_passes.ogg", 55) + addtimer(CALLBACK(src, PROC_REF(train_rushes)), 47, TIMER_DELETE_ME) + addtimer(VARSET_CALLBACK(src, train_passing, FALSE), 90, TIMER_DELETE_ME) + +/datum/fnf_stage/proc/train_rushes() + for(var/mob/listener as anything in battle.listeners) + shake_camera(listener, 12, 0.5) + if(girlfriend && !QDELETED(girlfriend)) + girlfriend.fnf_act("dodge_high", RIG_R_ARM, SOUTH, null, 10) + +/// What happens before the count in: Week 6's dialogue, or Darnell's can. Pushes the count in back +/// for as long as it takes. +/datum/fnf_stage/proc/start_intro() + if(read_dialogue()) + return + if(battle.song.id == "darnell" && !battle.song.variation) + battle.lead_in += 4 SECONDS + // He lights one, kicks it up, and Pico shoots it down. Darnell has a good laugh. + addtimer(CALLBACK(src, PROC_REF(weekend_cans), null, "lightcan", TRUE), 5, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(weekend_cans), null, "kickcan", TRUE), 13, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(weekend_cans), null, "cockgun", TRUE), 18, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(weekend_cans), null, "firegun", TRUE), 24, TIMER_DELETE_ME) + addtimer(CALLBACK(src, PROC_REF(darnell_laughs)), 29, TIMER_DELETE_ME) + +/datum/fnf_stage/proc/darnell_laughs() + battle.left?.act("taunt", 6) + +/** + * Week 6's dialogue, from data/fnf/dialogue/ (fetched with the songs): read the song's conversation + * and start playing it in a box under the stage, one line after another, typed out to its music. + * Returns TRUE if the song has one. + */ +/datum/fnf_stage/proc/read_dialogue() + var/list/conversation = fnf_read_json("data/fnf/dialogue/[battle.song.id][battle.song.variation ? "-[battle.song.variation]" : ""].json") + if(!length(conversation?["dialogue"]) || !battle.healthbar) + return FALSE + dialogue_lines = list() + var/total = 0 + for(var/list/entry in conversation["dialogue"]) + var/text = jointext(entry["text"], " ") + var/list/line = list(fnf_dialogue_speaker(entry["speaker"], battle), text) + dialogue_lines += list(line) + total += fnf_dialogue_line_time(text) + battle.lead_in += total + dialogue_text = new + dialogue_text.maptext_width = 288 + dialogue_text.maptext_height = 64 + dialogue_text.maptext_x = -128 + dialogue_text.layer = ABOVE_ALL_MOB_LAYER + 0.07 + dialogue_text.pixel_w = battle.healthbar.center_x - 16 + dialogue_text.pixel_z = battle.healthbar.center_y - FNF_STRUM_Y - 44 - 64 + battle.healthbar.vis_contents += dialogue_text + var/music = conversation["music"]?["asset"] + if(music) + dialogue_channel = SSsounds.reserve_sound_channel(src) + var/music_file = music == "LunchboxScary" ? "voidcrew/modules/fnf/sound/dialogue_music_scary.ogg" : "voidcrew/modules/fnf/sound/dialogue_music.ogg" + for(var/mob/listener as anything in battle.listeners) + SEND_SOUND(listener, sound(music_file, repeat = TRUE, channel = dialogue_channel, volume = 35 * (battle.listeners[listener] || 1))) + next_dialogue_line() + return TRUE + +/// How long a line of dialogue stays up, in deciseconds: typed out, then read. +/proc/fnf_dialogue_line_time(text) + return round(length(text) * 0.35) + 20 + +/// Who's talking, as the box names them. +/proc/fnf_dialogue_speaker(speaker, datum/fnf_battle/battle) + switch(fnf_base_character(speaker)) + if("senpai") + return list("Senpai", "#ffb3d9") + if("spirit") + return list("Spirit", "#ff4a4a") + if("nene") + return list("Nene", "#ff8ad8") + if("bf", "pico") + return list(battle.right?.singer_name || "You", "#8ad8ff") + return list(capitalize(speaker), "#ffffff") + +/datum/fnf_stage/proc/next_dialogue_line() + if(QDELETED(src) || !dialogue_text) + return + if(!length(dialogue_lines)) + battle.healthbar.vis_contents -= dialogue_text + QDEL_NULL(dialogue_text) + if(dialogue_channel) + for(var/mob/listener as anything in battle.listeners) + SEND_SOUND(listener, sound(null, channel = dialogue_channel)) + return + var/list/line = dialogue_lines[1] + dialogue_lines.Cut(1, 2) + type_dialogue(line[1], line[2], 0) + addtimer(CALLBACK(src, PROC_REF(next_dialogue_line)), fnf_dialogue_line_time(line[2]), TIMER_DELETE_ME) + +/// Types a line out a few letters at a time, blipping as it goes. +/datum/fnf_stage/proc/type_dialogue(list/speaker, text, shown) + if(QDELETED(src) || !dialogue_text) + return + shown = min(shown + 4, length(text)) + dialogue_text.maptext = MAPTEXT("[html_encode(speaker[1])]
[html_encode(copytext(text, 1, shown + 1))]
") + if(shown % 8 == 0) + play_to_all("voidcrew/modules/fnf/sound/dialogue_blip.ogg", 30) + if(shown < length(text)) + addtimer(CALLBACK(src, PROC_REF(type_dialogue), speaker, text, shown), 1.4, TIMER_DELETE_ME) + +/// How long a tankman takes to run in, in song milliseconds. +#define FNF_SOLDIER_RUN_MS 1600 +/// Most tankmen out at once, kept low: each is a whole rigged mob. Shots beyond that are just shots. +#define FNF_MAX_SOLDIERS 2 + +/// Sends in the tankmen the speaker is about to shoot. +/datum/fnf_stage/proc/send_soldiers(now) + var/list/shots = battle.chart["speaker"] + if(!length(shots) || !girlfriend || QDELETED(girlfriend)) + return + while(next_shot <= length(shots)) + var/list/shot = shots[next_shot] + if(shot[1] - FNF_SOLDIER_RUN_MS > now) + break + next_shot++ + if(shot[1] > now) + send_soldier(shot[2] <= 1, shot[1] - now) + +/// A tankman runs in from off one side, to be shot as he gets near the stage. +/datum/fnf_stage/proc/send_soldier(from_left, ms_left) + var/turf/near = locate(from_left ? left_spot.x - 1 : right_spot.x + 1, left_spot.y, left_spot.z) + var/turf/start = locate(from_left ? left_spot.x - 5 : right_spot.x + 5, left_spot.y, left_spot.z) + if(!near || !start) + return + var/mob/living/carbon/human/soldier = get_soldier(start) + if(!soldier) + return + soldier.forceMove(start) + soldier.alpha = 255 + soldier.setDir(from_left ? EAST : WEST) + // Running, strides timed to the pace he's moved at. + var/delay = max(round(ms_left / 100 / 4, world.tick_lag), world.tick_lag) + if(soldier.limb_rig) + soldier.limb_rig.step_delay_override = delay + GLOB.move_manager.move_to(soldier, near, 0, delay) + addtimer(CALLBACK(src, PROC_REF(shoot_soldier), soldier, from_left), ms_left / 100, TIMER_DELETE_ME) + +/// A tankman off the bench, or a new one if they're all out and there's room for another. +/datum/fnf_stage/proc/get_soldier(turf/where) + if(length(idle_soldiers)) + var/mob/living/carbon/human/soldier = idle_soldiers[length(idle_soldiers)] + idle_soldiers.len-- + return soldier + if(length(soldiers) >= FNF_MAX_SOLDIERS) + return null + var/mob/living/carbon/human/soldier = new(where) + soldier.fully_replace_character_name(soldier.real_name, "Tankman soldier") + soldier.equipOutfit(/datum/outfit/fnf_soldier) + if(soldier.move_intent != MOVE_INTENT_RUN) + soldier.toggle_move_intent() + // Their guns are part of the show: glued in, so nothing ever ends up on the floor. + for(var/obj/item/held in soldier.held_items) + ADD_TRAIT(held, TRAIT_NODROP, FNF_BATTLE_TRAIT) + soldiers += soldier + return soldier + +/// Bang: the one on the speaker turns and fires, and down he goes. +/datum/fnf_stage/proc/shoot_soldier(mob/living/carbon/human/soldier, from_left) + if(QDELETED(soldier) || !girlfriend || QDELETED(girlfriend)) + return + GLOB.move_manager.stop_looping(soldier) + var/facing = from_left ? WEST : EAST + girlfriend.setDir(facing) + girlfriend.fnf_act("aim_both", RIG_L_ARM, facing, null, 1) + girlfriend_busy_until = world.time + 3 + var/turf/shooter_turf = get_turf(girlfriend) + var/turf/target_turf = get_turf(soldier) + // From up on the speakers, squatting. + var/lift = seat?.rider == girlfriend ? seat.raised - 6 : 0 + if(shooter_turf && target_turf) + tracer(shooter_turf, 16 + (from_left ? -10 : 10), 26 + lift, (target_turf.x - shooter_turf.x) * world.icon_size + 16, (target_turf.y - shooter_turf.y) * world.icon_size + 22) + soldier.limb_rig?.step_delay_override = null + // Knocked flying: a ragdoll, kicked away from the shooter, falling however physics has it. + // Without the physics library, a flinch and a fall. + if(soldier.set_limb_physics(TRUE)) + var/datum/limb_physics/ragdoll = soldier.limb_rig.physics + var/away = from_left ? -1 : 1 + ragdoll.push(RIG_CHEST, away * 45, 12) + ragdoll.push(RIG_HEAD, away * 10, 4) + ragdoll.twist(RIG_CHEST, away * -2) + else + soldier.fnf_act("hit_high", RIG_R_ARM, soldier.dir, null, 2) + ADD_TRAIT(soldier, TRAIT_FLOORED, FNF_BATTLE_TRAIT) + var/obj/effect/abstract/fnf_hud/blood = new(target_turf) + blood.icon = 'icons/effects/blood.dmi' + blood.icon_state = "floor[rand(1, 7)]" + blood.color = "#a10808" + blood.plane = GAME_PLANE + blood.layer = LOW_OBJ_LAYER + blood.alpha = 0 + animate(blood, alpha = 230, time = 5) + animate(alpha = 0, time = 10, delay = 8) + QDEL_IN(blood, 25) + addtimer(CALLBACK(src, PROC_REF(bench_soldier), soldier), 8, TIMER_DELETE_ME) + +/// A shot tankman vanishes, quickly, and goes back on the bench for the next run. +/datum/fnf_stage/proc/bench_soldier(mob/living/carbon/human/soldier) + if(QDELETED(soldier)) + return + animate(soldier, alpha = 0, time = 2) + addtimer(CALLBACK(src, PROC_REF(soldier_benched), soldier), 2, TIMER_DELETE_ME) + +/datum/fnf_stage/proc/soldier_benched(mob/living/carbon/human/soldier) + if(QDELETED(soldier)) + return + soldier.set_limb_physics(FALSE) + REMOVE_TRAIT(soldier, TRAIT_FLOORED, FNF_BATTLE_TRAIT) + soldier.moveToNullspace() + idle_soldiers += soldier + +/// Puts up the lyrics as the song reaches them. +/datum/fnf_stage/proc/tick(now) + send_soldiers(now) + if(!lyric_text) + return + var/list/lyrics = battle.song.lyrics + if(lyric_ends >= 0 && now >= lyric_ends) + lyric_text.maptext = null + lyric_ends = -1 + while(next_lyric <= length(lyrics)) + var/list/line = lyrics[next_lyric] + if(line[1] > now) + break + next_lyric++ + if(line[2] <= now) + continue + lyric_ends = line[2] + lyric_text.maptext = MAPTEXT("[html_encode(line[3])]") + +/** + * A chart's PlayAnimation event: someone on stage does something. + * + * * target - who: "bf" or "boyfriend" (the right), "dad" (the left), "gf" or "girlfriend" + * * anim - what, as Funkin' names the animation + */ +/datum/fnf_stage/proc/play_animation(target, anim) + var/datum/fnf_side/side + switch(target) + if("bf", "boyfriend") + side = battle.right + if("dad", "opponent") + side = battle.left + if("gf", "girlfriend") + // Corruption+'s Purification has her dodging Carol's attacks. + if(anim == "dodge" && girlfriend && !QDELETED(girlfriend)) + girlfriend_busy_until = world.time + 5 + girlfriend.fnf_act("dodge_high", RIG_R_ARM, SOUTH, null, 3) + else + cheer() + return + if(!side) + return + switch(anim) + if("hey", "cheer") + side.hey() + // Boyfriend shouts "hey!"; Pico says "yeah!". + cheer() + if("ugh", "augh") + side.act("ugh", 4) + if("laugh") + side.act("taunt", 5) + if("beat it") + side.act("taunt", 4) + if("hehPrettyGood") + side.hey() + if("burpSmile") + side.act("taunt", 3) + if("redheadsAnim") + // "Ugh, redheads..." + side.act("taunt", 5) + if("knifeToss") + knife_toss() + // Corruption+: corrupted Pico screaming, fighting it; Kapi's meow; being lost in a flashback. + if("scream") + side.scream() + if("meow") + side.act("meow", 6) + if("confused1", "confused2") + side.act("confused", 6) + if("taunt", "cocky") + side.act("taunt", 5) + if("hey!") + side.hey() + // Carol's harp, which nobody here has. + if("strings") + return + else + side.hey() + +/// Stress (Pico Mix): Nene, clinging to Pico, has heard enough about redheads, and throws a knife +/// at Tankman. +/datum/fnf_stage/proc/knife_toss() + var/datum/fnf_side/tankman = battle.left + var/mob/living/thrower = carried || battle.right?.singer + if(!thrower || !tankman?.singer) + return + thrower.fnf_act("punch_high", RIG_R_ARM, battle.right.facing, null, 3) + var/obj/effect/abstract/fnf_hud/knife = new(get_turf(thrower)) + knife.icon = 'icons/obj/service/kitchen.dmi' + knife.icon_state = "knife" + knife.pixel_w = thrower.pixel_w + knife.pixel_z = 14 + animate(knife, pixel_w = -gap_px, pixel_z = 10, transform = turn(matrix(), -1080), time = 3, easing = LINEAR_EASING) + QDEL_IN(knife, 3) + addtimer(CALLBACK(src, PROC_REF(knife_lands)), 3, TIMER_DELETE_ME) + +/datum/fnf_stage/proc/knife_lands() + var/datum/fnf_side/tankman = battle?.left + if(!tankman?.singer) + return + playsound(tankman.singer, 'sound/items/weapons/bladeslice.ogg', 60, TRUE) + tankman.act("hit_high", 5) + stick_knife_in_head(tankman.singer) + +/** + * Leaves the knife stuck in someone's head for the rest of the song: on the head itself if they're + * rigged, so it nods with it. The in-hand knife sprite, handle out toward whoever threw it. + */ +/datum/fnf_stage/proc/stick_knife_in_head(mob/living/victim) + if(knifed) + return + var/mob/living/carbon/rigged = victim + var/datum/limb_rig/rig = istype(rigged) ? rigged.limb_rig : null + var/atom/holder = rig?.parts[RIG_HEAD] || victim + // Thrown from the right, so the blade points left into the head and the handle sticks out right. + head_knife = image('icons/mob/inhands/equipment/kitchen_righthand.dmi', "knife", dir = WEST) + // That sprite's blade is centred at (9.5, 13.5); the head's middle is about (19, 31), or (19, 25) + // on a human-sized head. + var/head_y = istype(rig, /datum/limb_rig/sprites) ? 31 : 25 + head_knife.pixel_w = 19 - 9.5 + head_knife.pixel_z = head_y - 13.5 + head_knife.appearance_flags = RESET_COLOR|PIXEL_SCALE + holder.add_overlay(head_knife) + knifed = holder + +/// The girlfriend throws her arms up: "Hey!" +/datum/fnf_stage/proc/cheer() + if(!girlfriend || QDELETED(girlfriend)) + return + girlfriend.fnf_hey(RIG_R_ARM, SOUTH, null) + +/// The singer on the other side from this one. +/datum/fnf_stage/proc/rival_of(datum/fnf_side/side) + return side == battle.left ? battle.right : battle.left + +/** + * A note was hit. Returns TRUE if it was a move the stage acted out, so the singer doesn't also + * sing it. + */ +/datum/fnf_stage/proc/note_hit(datum/fnf_side/side, datum/fnf_note/note) + var/kind = note.kind + if(!kind) + return FALSE + switch(kind) + if("noanim") + return TRUE + if("gf") + // Whoever's in her place sings it (Purification's corrupted Boyfriend, taking over), and + // whoever usually would just hangs there, twitching. + if(!girlfriend || QDELETED(girlfriend)) + return FALSE + girlfriend_busy_until = world.time + max(note.length / 100, 2) + 1.5 + girlfriend.setDir(girlfriend_facing) + girlfriend.fnf_sing(note.lane, max(note.length / 100, 2), girlfriend_facing == SOUTH ? RIG_R_ARM : girlfriend.fnf_mic_arm(girlfriend_facing), girlfriend_facing, girlfriend.fnf_look) + return TRUE + if("ugh") + side.act("ugh", 3) + return TRUE + if("hehPrettyGood") + side.hey() + return TRUE + if(findtext(kind, "weekend-1-") != 1) + return FALSE + var/move = copytext(kind, 11) + if(weekend_cans(side, move, TRUE)) + return TRUE + return blazin(side, move, TRUE) + +/// A note was missed. Returns TRUE if the stage showed what that cost, instead of the usual flinch. +/datum/fnf_stage/proc/note_missed(datum/fnf_side/side, datum/fnf_note/note) + if(findtext(note.kind, "weekend-1-") != 1) + return FALSE + var/move = copytext(note.kind, 11) + if(weekend_cans(side, move, FALSE)) + return TRUE + return blazin(side, move, FALSE) + +/** + * 2hot: Darnell lights a can, kicks it up (and knees it higher), Pico racks his gun and shoots it + * out of the air. Miss the shot and the can comes down on Pico's head. + */ +/datum/fnf_stage/proc/weekend_cans(datum/fnf_side/side, move, hit) + var/static/list/can_moves = list("lightcan", "kickcan", "kneecan", "cockgun", "firegun") + if(!(move in can_moves)) + return FALSE + var/datum/fnf_side/darnell = battle.left + var/datum/fnf_side/pico = battle.right + if(!darnell.singer || !pico.singer) + return TRUE + // Where the can hangs to be shot: in the air between them, right where Pico's gun points. + var/hover_x = gap_px * 0.55 + var/hover_y = 31 + max(gap_px * 0.45 - 9, 8) * 0.7 + switch(move) + if("lightcan") + darnell.act("cock", 3) + QDEL_NULL(can) + can = new(get_turf(darnell.singer)) + can.icon = 'icons/obj/art/crayons.dmi' + can.icon_state = "spraycan" + can.pixel_w = 8 + can.pixel_z = 6 + playsound(darnell.singer, 'sound/items/lighter/zippo_on.ogg', 50, TRUE) + if("kickcan") + darnell.act("kick", 2) + if(can) + playsound(darnell.singer, 'sound/items/weapons/genhit1.ogg', 40, TRUE) + // Up in an arc over toward Pico, and hanging there. + animate(can, pixel_w = hover_x - 16, pixel_z = hover_y + 10 - 16, transform = turn(matrix(), 360), time = 4, easing = SINE_EASING|EASE_OUT) + animate(pixel_z = hover_y - 16, time = 6, easing = SINE_EASING) + if("kneecan") + darnell.act("kick", 2) + if(can) + playsound(darnell.singer, 'sound/items/weapons/genhit2.ogg', 40, TRUE) + animate(can, pixel_w = hover_x - 16, pixel_z = hover_y + 14 - 16, transform = turn(matrix(), 180), time = 3, easing = SINE_EASING|EASE_OUT) + animate(pixel_z = hover_y - 16, time = 5, easing = SINE_EASING) + if("cockgun") + pico.act("cock", 3) + playsound(pico.singer, 'voidcrew/modules/fnf/sound/gun_cock.ogg', 50, TRUE) + if("firegun") + if(hit) + pico.act("shoot", 2) + playsound(pico.singer, "voidcrew/modules/fnf/sound/shot[rand(1, 4)].ogg", 60, TRUE) + if(can) + tracer(get_turf(darnell.singer), gap_px - 9, 31, can.pixel_w + 16, can.pixel_z + 16) + var/obj/effect/temp_visual/explosion/fast/pop = new(can.loc) + // It's already moved 32 px down and left to centre its 96 px sprite on a tile. + pop.pixel_w = can.pixel_w + pop.pixel_z = can.pixel_z + pop.transform = matrix() * 0.35 + playsound(can, 'sound/effects/pop_expl.ogg', 40, TRUE) + QDEL_NULL(can) + else + // Unshot, the can comes down and goes off in Pico's face. + if(can) + var/face_x = gap_px - 3 + animate(can, pixel_w = face_x - 16, pixel_z = 31 - 16, transform = turn(matrix(), 540), time = 2, easing = QUAD_EASING|EASE_IN) + addtimer(CALLBACK(src, PROC_REF(can_in_face), can, face_x), 2, TIMER_DELETE_ME) + can = null + return TRUE + +/// The can Pico didn't shoot blows up in his face: a big chunk of health, and charred for a moment. +/datum/fnf_stage/proc/can_in_face(obj/effect/abstract/fnf_hud/missed_can, face_x) + var/turf/where = missed_can.loc + qdel(missed_can) + var/datum/fnf_side/pico = battle?.right + if(!pico?.singer || !where) + return + var/obj/effect/temp_visual/explosion/fast/bang = new(where) + bang.pixel_w = face_x - 16 + bang.pixel_z = 31 - 16 + bang.transform = matrix() * 0.55 + playsound(pico.singer, 'sound/effects/pop_expl.ogg', 70, TRUE) + shake_camera(pico.singer, 3, 1) + pico.singer.fnf_flash("#2a3470", 8) + pico.act("hit_high", 4) + battle.adjust_health(-32, pico) + +/// A bullet's streak, from one point to another (pixels from the bottom left of a turf). +/datum/fnf_stage/proc/tracer(turf/from_turf, from_x, from_y, to_x, to_y) + var/delta_x = to_x - from_x + var/delta_y = to_y - from_y + var/length = sqrt(delta_x ** 2 + delta_y ** 2) + var/obj/effect/abstract/fnf_hud/streak = new(from_turf) + streak.icon_state = "bar" + streak.color = "#fff3a0" + streak.pixel_w = (from_x + to_x) / 2 - 16 + streak.pixel_z = (from_y + to_y) / 2 - 16 + var/matrix/line = matrix(length / 32, 0, 0, 0, 1 / 32, 0) + line.Turn(-arctan(delta_x, delta_y)) + streak.transform = line + animate(streak, alpha = 0, time = 2) + QDEL_IN(streak, 3) + +/** + * Blazin': every note is a move in the fight between Pico (right) and Darnell (left), named for + * what Pico does or has done to him. Hit it and it goes Pico's way; miss it and it doesn't: a + * whiffed punch, or a punch that should have been blocked landing. + */ +/datum/fnf_stage/proc/blazin(datum/fnf_side/side, move, hit) + var/datum/fnf_side/darnell = battle.left + var/datum/fnf_side/pico = battle.right + var/static/list/punches = list( + "punchhigh" = "high", "punchlow" = "low", "punchhighspin" = "high", "punchlowspin" = "low", + ) + var/static/list/blocked_punches = list("punchhighblocked" = "high", "punchlowblocked" = "low", "punchlowdodged" = "low") + var/static/list/guards = list("blockhigh" = "high", "blocklow" = "low", "blockspin" = "high", "dodgehigh" = "high", "dodgelow" = "low") + var/static/list/hits = list("hithigh" = "high", "hitlow" = "low") + if(!darnell.singer || !pico.singer) + return FALSE + if(punches[move]) + var/height = punches[move] + pico.act("punch_[height]", 2) + if(hit) + darnell.act("hit_[height]", 3) + punch_sound(darnell) + else + darnell.act("dodge_[height]", 3) + return TRUE + if(blocked_punches[move]) + var/height = blocked_punches[move] + pico.act("punch_[height]", 2) + if(findtext(move, "dodged")) + darnell.act("dodge_[height]", 3) + else + darnell.act("block", 3) + playsound(darnell.singer, 'sound/items/weapons/block_shield.ogg', 40, TRUE) + return TRUE + if(guards[move]) + var/height = guards[move] + darnell.act("punch_[height]", 2) + if(hit) + if(findtext(move, "dodge")) + pico.act("dodge_[height]", 3) + else + pico.act("block", 3) + playsound(pico.singer, 'sound/items/weapons/block_shield.ogg', 40, TRUE) + else + pico.act("hit_[height]", 3) + punch_sound(pico) + return TRUE + if(hits[move]) + // Scripted: Darnell gets one in whatever happens. + var/height = hits[move] + darnell.act("punch_[height]", 2) + pico.act("hit_[height]", 3) + punch_sound(pico) + return TRUE + switch(move) + if("picouppercutprep") + pico.act("prep", 4) + if("picouppercut") + pico.act("uppercut", 3) + if(hit) + darnell.act("uppercut_hit", 5) + punch_sound(darnell, TRUE) + else + darnell.act("dodge_high", 3) + if("darnelluppercutprep") + darnell.act("prep", 4) + if("darnelluppercut") + darnell.act("uppercut", 3) + if(hit) + pico.act("dodge_high", 3) + else + pico.act("uppercut_hit", 5) + punch_sound(pico, TRUE) + if("fakeout") + pico.act("punch_high", 1) + darnell.act("block", 3) + if("taunt") + pico.act("taunt", 5) + if("idle") + return TRUE + else + return FALSE + return TRUE + +/datum/fnf_stage/proc/punch_sound(datum/fnf_side/struck, big = FALSE) + if(struck.singer) + playsound(struck.singer, "sound/items/weapons/punch[rand(1, 4)].ogg", big ? 70 : 50, TRUE) + shake_camera(struck.singer, big ? 3 : 1, big ? 1 : 0.5) + +#undef FNF_SOLDIER_RUN_MS +#undef FNF_MAX_SOLDIERS diff --git a/voidcrew/modules/fnf/traced_anims.dm b/voidcrew/modules/fnf/traced_anims.dm new file mode 100644 index 00000000000..bb7bf714619 --- /dev/null +++ b/voidcrew/modules/fnf/traced_anims.dm @@ -0,0 +1,53 @@ +// Generated from FNF Skeleton Mapper tracings of Corruption+'s sprite sheets: don't edit by hand. + +/** + * The Corruption+ cast's own animations, traced joint by joint off their sprite sheets: by sheet, then + * animation, list(list(pose, deciseconds), ...), each pose in the limb rig's own angles (see + * /datum/limb_rig/sprites/get_pose_matrices()). Forward is the way the singer faces, whichever way the + * sprite did. + */ +GLOBAL_LIST_INIT(fnf_traced_anims, list( + "corruptedmarble2" = list( + "idle" = list(list(list(RIG_CHEST = list("bend" = 11.8), RIG_HEAD = list("nod" = -13.7), RIG_L_ARM = list("upper" = -151.1, "lower" = -168), RIG_R_ARM = list("upper" = -22.1, "lower" = 37.8), RIG_L_LEG = list("thigh" = 39.3, "shin" = -18.2, "foot" = -45.1), RIG_R_LEG = list("thigh" = -13.7, "shin" = -0, "foot" = -33)), 2.92), list(list(RIG_CHEST = list("bend" = 11.8), RIG_HEAD = list("nod" = -11.8), RIG_L_ARM = list("upper" = -152.2, "lower" = -161.2), RIG_R_ARM = list("upper" = -16.7, "lower" = 33.2), RIG_L_LEG = list("thigh" = 39.3, "shin" = -18.2, "foot" = -45.1), RIG_R_LEG = list("thigh" = -16.7, "shin" = -0, "foot" = -33)), 0.417), list(list(RIG_CHEST = list("bend" = 11.8), RIG_HEAD = list("nod" = -12.7), RIG_L_ARM = list("upper" = -148.7, "lower" = -163.7), RIG_R_ARM = list("upper" = -16.7, "lower" = 33.2), RIG_L_LEG = list("thigh" = 39.3, "shin" = -18.2, "foot" = -45.1), RIG_R_LEG = list("thigh" = -16.7, "shin" = -0, "foot" = -33)), 2.5)), + ), + "corruptedmarble" = list( + "idle" = list(list(list(RIG_CHEST = list("bend" = 10.2), RIG_HEAD = list("nod" = -11.7), RIG_L_ARM = list("upper" = -148.4, "lower" = -162.3), RIG_R_ARM = list("upper" = -25.1, "lower" = 45.3), RIG_L_LEG = list("thigh" = 28.9, "shin" = -21.7, "foot" = -15.1), RIG_R_LEG = list("thigh" = -8.4, "shin" = 12.1, "foot" = -20.9)), 0.417), list(list(RIG_CHEST = list("bend" = 10.2), RIG_HEAD = list("nod" = -10.7), RIG_L_ARM = list("upper" = -148.4, "lower" = -159.2), RIG_R_ARM = list("upper" = -18.9, "lower" = 42), RIG_L_LEG = list("thigh" = 28.9, "shin" = -21.7, "foot" = -15.1), RIG_R_LEG = list("thigh" = -8.4, "shin" = 12.1, "foot" = -20.9)), 0.417), list(list(RIG_CHEST = list("bend" = 10.6), RIG_HEAD = list("nod" = -12), RIG_L_ARM = list("upper" = -151.4, "lower" = -165.3), RIG_R_ARM = list("upper" = -19.4, "lower" = 60.4), RIG_L_LEG = list("thigh" = 27, "shin" = -18.4, "foot" = -15.1), RIG_R_LEG = list("thigh" = -5.2, "shin" = 5.8, "foot" = -27.2)), 0.417), list(list(RIG_CHEST = list("bend" = 9.5), RIG_HEAD = list("nod" = -11.2), RIG_L_ARM = list("upper" = -150.3, "lower" = -166.4), RIG_R_ARM = list("upper" = -18.3, "lower" = 61.5), RIG_L_LEG = list("thigh" = 27, "shin" = -18.4, "foot" = -15.1), RIG_R_LEG = list("thigh" = -5.2, "shin" = 5.8, "foot" = -27.2)), 0.417), list(list(RIG_CHEST = list("bend" = 10.3), RIG_HEAD = list("nod" = -12.3), RIG_L_ARM = list("upper" = -151.1, "lower" = -169.6), RIG_R_ARM = list("upper" = -17, "lower" = 63.1), RIG_L_LEG = list("thigh" = 27, "shin" = -18.4, "foot" = -15.1), RIG_R_LEG = list("thigh" = -5.2, "shin" = 5.8, "foot" = -27.2)), 0.417), list(list(RIG_CHEST = list("bend" = 10.6), RIG_HEAD = list("nod" = -11.6), RIG_L_ARM = list("upper" = -148, "lower" = -166.5), RIG_R_ARM = list("upper" = -20.7, "lower" = 60.4), RIG_L_LEG = list("thigh" = 27, "shin" = -18.4, "foot" = -15.1), RIG_R_LEG = list("thigh" = -5.2, "shin" = 5.8, "foot" = -27.2)), 3.75)), + ), + "corruptedgirlfriendflying" = list( + "singLEFT" = list(list(list(RIG_HEAD = list("tilt" = -13), RIG_L_ARM = list("upper" = 46.3, "lower" = 125.3), RIG_R_ARM = list("upper" = 55.3, "lower" = -30), RIG_L_LEG = list("thigh" = 9.3, "shin" = -16.2, "foot" = -14.2), RIG_R_LEG = list("thigh" = -5.1, "shin" = -3.7, "foot" = -1.7)), 0.833), list(list(RIG_HEAD = list("tilt" = -10.9), RIG_L_ARM = list("upper" = 55.5, "lower" = 135), RIG_R_ARM = list("upper" = 66.5, "lower" = -30), RIG_L_LEG = list("thigh" = 9.3, "shin" = -16.2, "foot" = -14.2), RIG_R_LEG = list("thigh" = -3.6, "shin" = -8.3, "foot" = -6.3)), 4.17)), + "singDOWN" = list(list(list(RIG_HEAD = list("tilt" = -0.3), RIG_L_ARM = list("upper" = 18.6, "lower" = 132.6), RIG_R_ARM = list("upper" = 28.2, "lower" = 159.9), RIG_L_LEG = list("thigh" = 9, "shin" = -16.5, "foot" = -14.5), RIG_R_LEG = list("thigh" = -15, "shin" = 25.1, "foot" = 27.1)), 0.833), list(list(RIG_HEAD = list("tilt" = -0.6), RIG_L_ARM = list("upper" = 18.6, "lower" = 140.1), RIG_R_ARM = list("upper" = 26.8, "lower" = 163.4), RIG_L_LEG = list("thigh" = 9, "shin" = -16.5, "foot" = -14.5), RIG_R_LEG = list("thigh" = -15, "shin" = 25.1, "foot" = 27.1)), 4.17)), + "singUP" = list(list(list(RIG_HEAD = list("tilt" = -1.5), RIG_L_ARM = list("upper" = 25.4, "lower" = -30), RIG_R_ARM = list("upper" = 143.5, "lower" = -30), RIG_L_LEG = list("thigh" = 0.8, "shin" = -8.4, "foot" = -6.4), RIG_R_LEG = list("thigh" = -17.2, "shin" = 5.5, "foot" = 7.5)), 0.417), list(list(RIG_HEAD = list("tilt" = -1.5), RIG_L_ARM = list("upper" = 27.2, "lower" = -30), RIG_R_ARM = list("upper" = 143.5, "lower" = -30), RIG_L_LEG = list("thigh" = 5.8, "shin" = -15.7, "foot" = -13.7), RIG_R_LEG = list("thigh" = -17.2, "shin" = 5.5, "foot" = 7.5)), 0.417), list(list(RIG_HEAD = list("tilt" = -0.8), RIG_L_ARM = list("upper" = 27.6, "lower" = -30), RIG_R_ARM = list("upper" = 142.4, "lower" = -30), RIG_L_LEG = list("thigh" = 5.5, "shin" = -16.6, "foot" = -14.6), RIG_R_LEG = list("thigh" = -18.4, "shin" = 14.7, "foot" = 16.7)), 4.17)), + "singRIGHT" = list(list(list(RIG_HEAD = list("tilt" = 8.3), RIG_L_ARM = list("upper" = 54.3, "lower" = -30), RIG_R_ARM = list("upper" = 32.8, "lower" = 165.4), RIG_L_LEG = list("thigh" = 3.8, "shin" = -16.9, "foot" = -14.9), RIG_R_LEG = list("thigh" = -10.6, "shin" = 18.9, "foot" = 20.9)), 0.833), list(list(RIG_HEAD = list("tilt" = 8.3), RIG_L_ARM = list("upper" = 51.2, "lower" = -30), RIG_R_ARM = list("upper" = 29.5, "lower" = 158.8), RIG_L_LEG = list("thigh" = 3.8, "shin" = -12.7, "foot" = -10.7), RIG_R_LEG = list("thigh" = -2.8, "shin" = 16.9, "foot" = 18.9)), 4.17)), + "idle-alt" = list(list(list(RIG_HEAD = list("tilt" = 3), RIG_L_ARM = list("upper" = 7.5, "lower" = 26.6), RIG_R_ARM = list("upper" = 30.5, "lower" = 20.2), RIG_L_LEG = list("thigh" = 4.9, "shin" = -5.5, "foot" = -3.5), RIG_R_LEG = list("thigh" = 1.2, "shin" = 4.9, "foot" = 6.9)), 3.75), list(list(RIG_HEAD = list("tilt" = 3), RIG_L_ARM = list("upper" = 13.1, "lower" = 26.6), RIG_R_ARM = list("upper" = 30.5, "lower" = 20.2), RIG_L_LEG = list("thigh" = 4.9, "shin" = -5.5, "foot" = -3.5), RIG_R_LEG = list("thigh" = 1.2, "shin" = 4.9, "foot" = 6.9)), 0.417), list(list(RIG_HEAD = list("tilt" = 1.8), RIG_L_ARM = list("upper" = 7.8, "lower" = 24.6), RIG_R_ARM = list("upper" = 30.5, "lower" = 15.3), RIG_L_LEG = list("thigh" = 1, "shin" = -6.4, "foot" = -4.4), RIG_R_LEG = list("thigh" = -3.9, "shin" = 9.2, "foot" = 11.2)), 0.417), list(list(RIG_HEAD = list("tilt" = 1.8), RIG_L_ARM = list("upper" = 7.8, "lower" = 14.7), RIG_R_ARM = list("upper" = 30.5, "lower" = 16), RIG_L_LEG = list("thigh" = 1, "shin" = 1.4, "foot" = 3.4), RIG_R_LEG = list("thigh" = -0.6, "shin" = 6.9, "foot" = 8.9)), 0.417)), + ), + "corruptedpico" = list( + "idle" = list(list(list(RIG_CHEST = list("bend" = -16.2), RIG_HEAD = list("nod" = 15.8), RIG_L_ARM = list("upper" = -5.4, "lower" = -64.9), RIG_R_ARM = list("upper" = 40.7, "lower" = 79.7), RIG_L_LEG = list("thigh" = 24.1, "shin" = -20.9, "foot" = -53.9), RIG_R_LEG = list("thigh" = 22.7, "shin" = 48.8, "foot" = 15.8)), 0.417), list(list(RIG_CHEST = list("bend" = -16.2), RIG_HEAD = list("nod" = 15.8), RIG_L_ARM = list("upper" = -5.4, "lower" = -64.9), RIG_R_ARM = list("upper" = 40.7, "lower" = 79.7), RIG_L_LEG = list("thigh" = 16.4, "shin" = -20.9, "foot" = -53.9), RIG_R_LEG = list("thigh" = 21.2, "shin" = 48.8, "foot" = 15.8)), 0.417), list(list(RIG_CHEST = list("bend" = -16.2), RIG_HEAD = list("nod" = 17.2), RIG_L_ARM = list("upper" = -21.5, "lower" = -62.9), RIG_R_ARM = list("upper" = 42.1, "lower" = 68.1), RIG_L_LEG = list("thigh" = 21.2, "shin" = -20.9, "foot" = -53.9), RIG_R_LEG = list("thigh" = 19.8, "shin" = 49.5, "foot" = 16.5)), 1.25), list(list(RIG_CHEST = list("bend" = -14.7), RIG_HEAD = list("nod" = 15.5), RIG_L_ARM = list("upper" = -22.9, "lower" = -43.2), RIG_R_ARM = list("upper" = 40.6, "lower" = 66.7), RIG_L_LEG = list("thigh" = 21.2, "shin" = -20.9, "foot" = -53.9), RIG_R_LEG = list("thigh" = 19.8, "shin" = 49.5, "foot" = 16.5)), 0.417), list(list(RIG_CHEST = list("bend" = -13.6), RIG_HEAD = list("nod" = 14.4), RIG_L_ARM = list("upper" = -24, "lower" = -65.5), RIG_R_ARM = list("upper" = 39.5, "lower" = 72.2), RIG_L_LEG = list("thigh" = 21.2, "shin" = -20.9, "foot" = -53.9), RIG_R_LEG = list("thigh" = 19.8, "shin" = 49.5, "foot" = 16.5)), 0.417), list(list(RIG_CHEST = list("bend" = -13.6), RIG_HEAD = list("nod" = 14.4), RIG_L_ARM = list("upper" = -24, "lower" = -52.4), RIG_R_ARM = list("upper" = 39.5, "lower" = 72.2), RIG_L_LEG = list("thigh" = 21.2, "shin" = -20.9, "foot" = -53.9), RIG_R_LEG = list("thigh" = 19.8, "shin" = 49.5, "foot" = 16.5)), 0.417), list(list(RIG_CHEST = list("bend" = -13.9), RIG_HEAD = list("nod" = 13.1), RIG_L_ARM = list("upper" = -23.8, "lower" = -52.2), RIG_R_ARM = list("upper" = 39.7, "lower" = 72.4), RIG_L_LEG = list("thigh" = 21.2, "shin" = -20.9, "foot" = -53.9), RIG_R_LEG = list("thigh" = 19.8, "shin" = 49.5, "foot" = 16.5)), 0.417), list(list(RIG_CHEST = list("bend" = -11.7), RIG_HEAD = list("nod" = 9.1), RIG_L_ARM = list("upper" = -25.9, "lower" = -54.3), RIG_R_ARM = list("upper" = 37.6, "lower" = 70.3), RIG_L_LEG = list("thigh" = 21.2, "shin" = -20.9, "foot" = -53.9), RIG_R_LEG = list("thigh" = 19.8, "shin" = 49.5, "foot" = 16.5)), 0.417), list(list(RIG_CHEST = list("bend" = -10.9), RIG_HEAD = list("nod" = 7.9), RIG_L_ARM = list("upper" = -26.7, "lower" = -55.1), RIG_R_ARM = list("upper" = 36.8, "lower" = 69.5), RIG_L_LEG = list("thigh" = 21.2, "shin" = -20.9, "foot" = -53.9), RIG_R_LEG = list("thigh" = 19.8, "shin" = 49.5, "foot" = 16.5)), 0.417), list(list(RIG_CHEST = list("bend" = -10.2), RIG_HEAD = list("nod" = 6.7), RIG_L_ARM = list("upper" = -27.4, "lower" = -55.8), RIG_R_ARM = list("upper" = 36.1, "lower" = 68.8), RIG_L_LEG = list("thigh" = 21.2, "shin" = -20.9, "foot" = -53.9), RIG_R_LEG = list("thigh" = 19.8, "shin" = 49.5, "foot" = 16.5)), 0.417), list(list(RIG_CHEST = list("bend" = -12.1), RIG_HEAD = list("nod" = 9.8), RIG_L_ARM = list("upper" = -25.6, "lower" = -53.9), RIG_R_ARM = list("upper" = 38, "lower" = 70.6), RIG_L_LEG = list("thigh" = 21.2, "shin" = -20.9, "foot" = -53.9), RIG_R_LEG = list("thigh" = 19.8, "shin" = 49.5, "foot" = 16.5)), 0.417), list(list(RIG_CHEST = list("bend" = -12.7), RIG_HEAD = list("nod" = 10.8), RIG_L_ARM = list("upper" = -24.9, "lower" = -53.3), RIG_R_ARM = list("upper" = 38.6, "lower" = 71.3), RIG_L_LEG = list("thigh" = 21.2, "shin" = -20.9, "foot" = -53.9), RIG_R_LEG = list("thigh" = 19.8, "shin" = 49.5, "foot" = 16.5)), 0.417)), + "singUP" = list(list(list(RIG_CHEST = list("bend" = -20.8), RIG_HEAD = list("nod" = 3), RIG_L_ARM = list("upper" = -24.2, "lower" = 50.9), RIG_R_ARM = list("upper" = 47, "lower" = 134.4), RIG_L_LEG = list("thigh" = 22.6, "shin" = -11.4, "foot" = -44.4), RIG_R_LEG = list("thigh" = 6.5, "shin" = 40.4, "foot" = 7.4)), 0.833), list(list(RIG_CHEST = list("bend" = -15), RIG_HEAD = list("nod" = -1.9), RIG_L_ARM = list("upper" = -15.6, "lower" = 47.1), RIG_R_ARM = list("upper" = 51.7, "lower" = 90.1), RIG_L_LEG = list("thigh" = 16.4, "shin" = -6.9, "foot" = -39.9), RIG_R_LEG = list("thigh" = 9.5, "shin" = 35.5, "foot" = 2.5)), 5)), + "singDOWN" = list(list(list(RIG_CHEST = list("bend" = 15.9), RIG_HEAD = list("nod" = -22.5), RIG_L_ARM = list("upper" = -43.5, "lower" = -116.8), RIG_R_ARM = list("upper" = 96.1, "lower" = 58.1), RIG_L_LEG = list("thigh" = 2.1, "shin" = -50.6, "foot" = -83.6), RIG_R_LEG = list("thigh" = 21.5, "shin" = 44.2, "foot" = 11.2)), 0.417), list(list(RIG_CHEST = list("bend" = 1.7), RIG_HEAD = list("nod" = -6), RIG_L_ARM = list("upper" = -29.3, "lower" = -102.6), RIG_R_ARM = list("upper" = 110.3, "lower" = 72.3), RIG_L_LEG = list("thigh" = 2.1, "shin" = -50.6, "foot" = -83.6), RIG_R_LEG = list("thigh" = 21.5, "shin" = 44.2, "foot" = 11.2)), 0.417), list(list(RIG_CHEST = list("bend" = 6.3), RIG_HEAD = list("nod" = -8.7), RIG_L_ARM = list("upper" = -43.1, "lower" = -111.8), RIG_R_ARM = list("upper" = 105.8, "lower" = 67.8), RIG_L_LEG = list("thigh" = 2.1, "shin" = -55.6, "foot" = -88.6), RIG_R_LEG = list("thigh" = 6.4, "shin" = 44.2, "foot" = 11.2)), 3.75)), + "singLEFT" = list(list(list(RIG_CHEST = list("bend" = -0.6), RIG_HEAD = list("nod" = -1.9), RIG_L_ARM = list("upper" = -34.1, "lower" = 5.4), RIG_R_ARM = list("upper" = 10.4, "lower" = 62), RIG_L_LEG = list("thigh" = -9.8, "shin" = -15.5, "foot" = -48.5), RIG_R_LEG = list("thigh" = -7, "shin" = 44.3, "foot" = 11.3)), 0.833), list(list(RIG_CHEST = list("bend" = -5.5), RIG_HEAD = list("nod" = 5.5), RIG_L_ARM = list("upper" = -30.4, "lower" = -6.4), RIG_R_ARM = list("upper" = 24.3, "lower" = 46.5), RIG_L_LEG = list("thigh" = 15.9, "shin" = -15.5, "foot" = -48.5), RIG_R_LEG = list("thigh" = -7, "shin" = 44.3, "foot" = 11.3)), 6.25)), + "singRIGHT" = list(list(list(RIG_CHEST = list("bend" = -30.1), RIG_HEAD = list("nod" = 35.9), RIG_L_ARM = list("upper" = -30.3, "lower" = -29.9), RIG_R_ARM = list("upper" = 74.4, "lower" = 120.1), RIG_L_LEG = list("thigh" = -3.9, "shin" = -4.7, "foot" = -37.7), RIG_R_LEG = list("thigh" = 45, "shin" = 36.1, "foot" = 3.1)), 0.833), list(list(RIG_CHEST = list("bend" = -25.4), RIG_HEAD = list("nod" = 30.3), RIG_L_ARM = list("upper" = -35.3, "lower" = -10.5), RIG_R_ARM = list("upper" = 65.7, "lower" = 110.2), RIG_L_LEG = list("thigh" = -10.5, "shin" = -4.7, "foot" = -37.7), RIG_R_LEG = list("thigh" = 46.2, "shin" = 36.1, "foot" = 3.1)), 6.25)), + ), + "corruptedpico2" = list( + "idle" = list(list(list(RIG_CHEST = list("bend" = -15.1), RIG_HEAD = list("nod" = 16.2), RIG_L_ARM = list("upper" = -13.9, "lower" = -49), RIG_R_ARM = list("upper" = 41.2, "lower" = 75.5), RIG_L_LEG = list("thigh" = -44.8, "shin" = -3.1, "foot" = -36.1), RIG_R_LEG = list("thigh" = -2.6, "shin" = 42.6, "foot" = 9.6)), 0.833), list(list(RIG_CHEST = list("bend" = -10.7), RIG_HEAD = list("nod" = 11.9), RIG_L_ARM = list("upper" = -25.1, "lower" = -57.4), RIG_R_ARM = list("upper" = 45.1, "lower" = 64.2), RIG_L_LEG = list("thigh" = -44.8, "shin" = -3.1, "foot" = -36.1), RIG_R_LEG = list("thigh" = -2.6, "shin" = 42.6, "foot" = 9.6)), 0.833), list(list(RIG_CHEST = list("bend" = -15.3), RIG_HEAD = list("nod" = 20.2), RIG_L_ARM = list("upper" = -19.7, "lower" = -60), RIG_R_ARM = list("upper" = 56.2, "lower" = 60.3), RIG_L_LEG = list("thigh" = -44.8, "shin" = -3.1, "foot" = -36.1), RIG_R_LEG = list("thigh" = -2.6, "shin" = 42.6, "foot" = 9.6)), 1.67), list(list(RIG_CHEST = list("bend" = -13.1), RIG_HEAD = list("nod" = 16.6), RIG_L_ARM = list("upper" = -36.2, "lower" = -42.4), RIG_R_ARM = list("upper" = 45.5, "lower" = 58.1), RIG_L_LEG = list("thigh" = -44.8, "shin" = -3.1, "foot" = -36.1), RIG_R_LEG = list("thigh" = -2.6, "shin" = 42.6, "foot" = 9.6)), 0.833), list(list(RIG_CHEST = list("bend" = -13.1), RIG_HEAD = list("nod" = 14.9), RIG_L_ARM = list("upper" = -19.5, "lower" = -27), RIG_R_ARM = list("upper" = 45.5, "lower" = 75.3), RIG_L_LEG = list("thigh" = -44.8, "shin" = -3.1, "foot" = -36.1), RIG_R_LEG = list("thigh" = -2.6, "shin" = 42.6, "foot" = 9.6)), 0.833), list(list(RIG_CHEST = list("bend" = -13.1), RIG_HEAD = list("nod" = 13.1), RIG_L_ARM = list("upper" = -20.2, "lower" = -23.5), RIG_R_ARM = list("upper" = 45.5, "lower" = 75.3), RIG_L_LEG = list("thigh" = -44.8, "shin" = -3.1, "foot" = -36.1), RIG_R_LEG = list("thigh" = -2.6, "shin" = 42.6, "foot" = 9.6)), 2.5)), + "scream" = list(list(list(RIG_CHEST = list("bend" = -20.2), RIG_HEAD = list("nod" = 14.2), RIG_L_ARM = list("upper" = -38.6, "lower" = -140), RIG_R_ARM = list("upper" = 53.4, "lower" = 67.4), RIG_L_LEG = list("thigh" = -1.8, "shin" = -17.2, "foot" = -50.2), RIG_R_LEG = list("thigh" = 7.4, "shin" = 35.1, "foot" = 2.1)), 0.417), list(list(RIG_CHEST = list("bend" = -20.6), RIG_HEAD = list("nod" = 3.8), RIG_L_ARM = list("upper" = -16.3, "lower" = -122), RIG_R_ARM = list("upper" = 59, "lower" = 112.1), RIG_L_LEG = list("thigh" = -1.8, "shin" = -17.2, "foot" = -50.2), RIG_R_LEG = list("thigh" = 21.8, "shin" = 35.1, "foot" = 2.1)), 0.417), list(list(RIG_CHEST = list("bend" = -20.6), RIG_HEAD = list("nod" = 1.8), RIG_L_ARM = list("upper" = -16.3, "lower" = -122), RIG_R_ARM = list("upper" = 59, "lower" = 112.1), RIG_L_LEG = list("thigh" = -1.8, "shin" = -17.2, "foot" = -50.2), RIG_R_LEG = list("thigh" = 21.8, "shin" = 35.1, "foot" = 2.1)), 0.417), list(list(RIG_CHEST = list("bend" = -16.8), RIG_HEAD = list("nod" = -4.1), RIG_L_ARM = list("upper" = -20.1, "lower" = -125.8), RIG_R_ARM = list("upper" = 55.2, "lower" = 108.3), RIG_L_LEG = list("thigh" = -1.8, "shin" = -17.2, "foot" = -50.2), RIG_R_LEG = list("thigh" = 21.8, "shin" = 35.1, "foot" = 2.1)), 0.417), list(list(RIG_CHEST = list("bend" = -15.7), RIG_HEAD = list("nod" = -5.8), RIG_L_ARM = list("upper" = -21.2, "lower" = -126.9), RIG_R_ARM = list("upper" = 54.1, "lower" = 107.2), RIG_L_LEG = list("thigh" = -1.8, "shin" = -17.2, "foot" = -50.2), RIG_R_LEG = list("thigh" = 21.8, "shin" = 35.1, "foot" = 2.1)), 2.5)), + ), + "corruptedkapi" = list( + "idle" = list(list(list(RIG_CHEST = list("bend" = 0.7), RIG_HEAD = list("nod" = -1.3), RIG_L_ARM = list("upper" = 29.7, "lower" = -14.2), RIG_R_ARM = list("upper" = -26.5, "lower" = -168.4), RIG_L_LEG = list("thigh" = -4.5, "shin" = -5, "foot" = -15.5), RIG_R_LEG = list("thigh" = 17.8, "shin" = -39.7, "foot" = -23.8)), 0.417), list(list(RIG_CHEST = list("bend" = -2.8), RIG_HEAD = list("nod" = 5), RIG_L_ARM = list("upper" = 33.2, "lower" = -10.7), RIG_R_ARM = list("upper" = -22.9, "lower" = -164.9), RIG_L_LEG = list("thigh" = -4.5, "shin" = -5, "foot" = -15.5), RIG_R_LEG = list("thigh" = 17.8, "shin" = -39.7, "foot" = -18.5)), 0.417), list(list(RIG_CHEST = list("bend" = -6.3), RIG_HEAD = list("nod" = 11.3), RIG_L_ARM = list("upper" = 36.7, "lower" = -7.2), RIG_R_ARM = list("upper" = -19.5, "lower" = -161.4), RIG_L_LEG = list("thigh" = -4.5, "shin" = -5, "foot" = -15.5), RIG_R_LEG = list("thigh" = 17.8, "shin" = -39.7, "foot" = -28.4)), 0.417), list(list(RIG_CHEST = list("bend" = -6.3), RIG_HEAD = list("nod" = 11.3), RIG_L_ARM = list("upper" = 36.7, "lower" = -7.2), RIG_R_ARM = list("upper" = -19.5, "lower" = -161.4), RIG_L_LEG = list("thigh" = -4.5, "shin" = -5, "foot" = -15.5), RIG_R_LEG = list("thigh" = 17.8, "shin" = -39.7, "foot" = -17.7)), 0.417), list(list(RIG_CHEST = list("bend" = -6.3), RIG_HEAD = list("nod" = 9.1), RIG_L_ARM = list("upper" = 36.7, "lower" = -7.2), RIG_R_ARM = list("upper" = -19.5, "lower" = -164.5), RIG_L_LEG = list("thigh" = 0, "shin" = -9.2, "foot" = -15.5), RIG_R_LEG = list("thigh" = 19, "shin" = -34.1, "foot" = -17.7)), 0.417), list(list(RIG_CHEST = list("bend" = -6.3), RIG_HEAD = list("nod" = 9.1), RIG_L_ARM = list("upper" = 36.7, "lower" = -7.2), RIG_R_ARM = list("upper" = -19.5, "lower" = -167.6), RIG_L_LEG = list("thigh" = 0, "shin" = -9.2, "foot" = -15.5), RIG_R_LEG = list("thigh" = 19, "shin" = -34.1, "foot" = -17.7)), 0.417), list(list(RIG_CHEST = list("bend" = -6.3), RIG_HEAD = list("nod" = 9.1), RIG_L_ARM = list("upper" = 36.7, "lower" = -7.2), RIG_R_ARM = list("upper" = -19.5, "lower" = -170.3), RIG_L_LEG = list("thigh" = 0, "shin" = -9.2, "foot" = -15.5), RIG_R_LEG = list("thigh" = 19, "shin" = -34.1, "foot" = -17.7)), 0.417), list(list(RIG_CHEST = list("bend" = -6.3), RIG_HEAD = list("nod" = 8.6), RIG_L_ARM = list("upper" = 36.7, "lower" = -7.2), RIG_R_ARM = list("upper" = -19.5, "lower" = -170.3), RIG_L_LEG = list("thigh" = 0, "shin" = -9.2, "foot" = -15.5), RIG_R_LEG = list("thigh" = 19, "shin" = -34.1, "foot" = -17.7)), 0.417), list(list(RIG_CHEST = list("bend" = -6.3), RIG_HEAD = list("nod" = 8.2), RIG_L_ARM = list("upper" = 36.7, "lower" = -7.2), RIG_R_ARM = list("upper" = -19.5, "lower" = -170.3), RIG_L_LEG = list("thigh" = 0, "shin" = -9.2, "foot" = -15.5), RIG_R_LEG = list("thigh" = 19, "shin" = -34.1, "foot" = -17.7)), 0.417), list(list(RIG_CHEST = list("bend" = -6.3), RIG_HEAD = list("nod" = 7.8), RIG_L_ARM = list("upper" = 36.7, "lower" = -7.2), RIG_R_ARM = list("upper" = -19.5, "lower" = -170.3), RIG_L_LEG = list("thigh" = 0, "shin" = -9.2, "foot" = -15.5), RIG_R_LEG = list("thigh" = 19, "shin" = -34.1, "foot" = -17.7)), 0.417), list(list(RIG_CHEST = list("bend" = -6.3), RIG_HEAD = list("nod" = 7.3), RIG_L_ARM = list("upper" = 36.7, "lower" = -7.2), RIG_R_ARM = list("upper" = -19.5, "lower" = -170.3), RIG_L_LEG = list("thigh" = 0, "shin" = -9.2, "foot" = -15.5), RIG_R_LEG = list("thigh" = 19, "shin" = -34.1, "foot" = -17.7)), 0.833)), + "singDOWN" = list(list(list(RIG_CHEST = list("bend" = -25.7), RIG_HEAD = list("nod" = -20.3), RIG_L_ARM = list("upper" = 162, "lower" = 155.9), RIG_R_ARM = list("upper" = 1.6, "lower" = -29.9), RIG_L_LEG = list("thigh" = 136.8, "shin" = 46.2, "foot" = 32.7), RIG_R_LEG = list("thigh" = 8.5, "shin" = -37.2, "foot" = -55.8)), 0.417), list(list(RIG_CHEST = list("bend" = -25.7), RIG_HEAD = list("nod" = -30.1), RIG_L_ARM = list("upper" = 175.8, "lower" = 173.4), RIG_R_ARM = list("upper" = 8.1, "lower" = -3), RIG_L_LEG = list("thigh" = 164.3, "shin" = 64.6, "foot" = 44.2), RIG_R_LEG = list("thigh" = 43.4, "shin" = -35.8, "foot" = -20.3)), 0.417), list(list(RIG_CHEST = list("bend" = -25.7), RIG_HEAD = list("nod" = -20.2), RIG_L_ARM = list("upper" = 191, "lower" = 173.7), RIG_R_ARM = list("upper" = 8.1, "lower" = -3), RIG_L_LEG = list("thigh" = 180, "shin" = 68.7, "foot" = 51.4), RIG_R_LEG = list("thigh" = 47.2, "shin" = -17.3, "foot" = -12.1)), 0.417), list(list(RIG_CHEST = list("bend" = -25.7), RIG_HEAD = list("nod" = -26.1), RIG_L_ARM = list("upper" = 191, "lower" = 179.1), RIG_R_ARM = list("upper" = 8.1, "lower" = -5.5), RIG_L_LEG = list("thigh" = 165.5, "shin" = 67.4, "foot" = 70.9), RIG_R_LEG = list("thigh" = 50.3, "shin" = -9.5, "foot" = -21.5)), 0.417), list(list(RIG_CHEST = list("bend" = -25.7), RIG_HEAD = list("nod" = -30.6), RIG_L_ARM = list("upper" = 191, "lower" = 179.1), RIG_R_ARM = list("upper" = 8.1, "lower" = -5.5), RIG_L_LEG = list("thigh" = 165.5, "shin" = 67.4, "foot" = 70.9), RIG_R_LEG = list("thigh" = 50.3, "shin" = -9.5, "foot" = -21.5)), 0.833)), + "singUP" = list(list(list(RIG_CHEST = list("bend" = -24), RIG_HEAD = list("nod" = 13.1), RIG_L_ARM = list("upper" = 50.5, "lower" = 42.5), RIG_R_ARM = list("upper" = 43.3, "lower" = 5.1), RIG_L_LEG = list("thigh" = 83.7, "shin" = -54.1, "foot" = -52.7), RIG_R_LEG = list("thigh" = 122.8, "shin" = -2.8, "foot" = 9.4)), 0.417), list(list(RIG_CHEST = list("bend" = -26.8), RIG_HEAD = list("nod" = 18.4), RIG_L_ARM = list("upper" = 49.3, "lower" = 51.4), RIG_R_ARM = list("upper" = 55.3, "lower" = 1.5), RIG_L_LEG = list("thigh" = 58.3, "shin" = -48.1, "foot" = -57.2), RIG_R_LEG = list("thigh" = 99.7, "shin" = -7.6, "foot" = -1)), 0.417), list(list(RIG_CHEST = list("bend" = -26.8), RIG_HEAD = list("nod" = 18.4), RIG_L_ARM = list("upper" = 47.6, "lower" = 55.6), RIG_R_ARM = list("upper" = 49, "lower" = -2.3), RIG_L_LEG = list("thigh" = 58.3, "shin" = -48.1, "foot" = -57.2), RIG_R_LEG = list("thigh" = 98.8, "shin" = -2.2, "foot" = -1)), 0.417), list(list(RIG_CHEST = list("bend" = -26.8), RIG_HEAD = list("nod" = 17.7), RIG_L_ARM = list("upper" = 47.6, "lower" = 55.6), RIG_R_ARM = list("upper" = 49, "lower" = -2.3), RIG_L_LEG = list("thigh" = 58.3, "shin" = -48.1, "foot" = -57.2), RIG_R_LEG = list("thigh" = 98.8, "shin" = -2.2, "foot" = -1)), 0.417), list(list(RIG_CHEST = list("bend" = -26.8), RIG_HEAD = list("nod" = 21), RIG_L_ARM = list("upper" = 47.6, "lower" = 55.6), RIG_R_ARM = list("upper" = 49, "lower" = -2.3), RIG_L_LEG = list("thigh" = 58.3, "shin" = -48.1, "foot" = -57.2), RIG_R_LEG = list("thigh" = 98.8, "shin" = -6.5, "foot" = 3)), 0.833)), + "meow" = list(list(list(RIG_HEAD = list("tilt" = -5.1), RIG_L_ARM = list("upper" = 23.3, "lower" = 156.3), RIG_R_ARM = list("upper" = 24.9, "lower" = 149.9), RIG_L_LEG = list("thigh" = 2.6, "shin" = -0.7, "foot" = 1.3), RIG_R_LEG = list("thigh" = -8.3, "shin" = -4.5, "foot" = -2.5)), 0.417), list(list(RIG_HEAD = list("tilt" = -5.1), RIG_L_ARM = list("upper" = 23.3, "lower" = 156.3), RIG_R_ARM = list("upper" = 24.9, "lower" = 149.9), RIG_L_LEG = list("thigh" = 0.8, "shin" = -0.7, "foot" = 1.3), RIG_R_LEG = list("thigh" = -8.3, "shin" = -4.5, "foot" = -2.5)), 0.417), list(list(RIG_HEAD = list("tilt" = -1.8), RIG_L_ARM = list("upper" = 29.4, "lower" = 152), RIG_R_ARM = list("upper" = 53.5, "lower" = 166.7), RIG_L_LEG = list("thigh" = 2.6, "shin" = -2.8, "foot" = -0.8), RIG_R_LEG = list("thigh" = -13.2, "shin" = -2.1, "foot" = -0.1)), 0.417), list(list(RIG_HEAD = list("tilt" = -1.8), RIG_L_ARM = list("upper" = 29.4, "lower" = 152), RIG_R_ARM = list("upper" = 53.5, "lower" = 171.4), RIG_L_LEG = list("thigh" = 2.6, "shin" = -2.8, "foot" = -0.8), RIG_R_LEG = list("thigh" = -13.2, "shin" = -2.1, "foot" = -0.1)), 0.417), list(list(RIG_HEAD = list("tilt" = -1.2), RIG_L_ARM = list("upper" = 29.4, "lower" = 159), RIG_R_ARM = list("upper" = 53.5, "lower" = 177.5), RIG_L_LEG = list("thigh" = 2.6, "shin" = -2.8, "foot" = -0.8), RIG_R_LEG = list("thigh" = -13.2, "shin" = -2.1, "foot" = -0.1)), 0.833), list(list(RIG_HEAD = list("tilt" = -2.4), RIG_L_ARM = list("upper" = 29.4, "lower" = 157.1), RIG_R_ARM = list("upper" = 53.5, "lower" = 173.4), RIG_L_LEG = list("thigh" = 2.6, "shin" = -2.8, "foot" = -0.8), RIG_R_LEG = list("thigh" = -13.2, "shin" = -2.1, "foot" = -0.1)), 3.75)), + "singLEFT" = list(list(list(RIG_CHEST = list("bend" = 33.1), RIG_HEAD = list("nod" = -7.2), RIG_L_ARM = list("upper" = 6.9, "lower" = -159.4), RIG_R_ARM = list("upper" = -105.5, "lower" = -22.3), RIG_L_LEG = list("thigh" = 50.5, "shin" = -43.5, "foot" = -35.5), RIG_R_LEG = list("thigh" = 91.8, "shin" = -42.7, "foot" = -18)), 0.417), list(list(RIG_CHEST = list("bend" = 43.8), RIG_HEAD = list("nod" = -18.3), RIG_L_ARM = list("upper" = 5.1, "lower" = -150.9), RIG_R_ARM = list("upper" = -110.6, "lower" = -10.1), RIG_L_LEG = list("thigh" = 52.8, "shin" = -56.1, "foot" = -19.1), RIG_R_LEG = list("thigh" = 74.4, "shin" = -58.4, "foot" = -21.4)), 0.417), list(list(RIG_CHEST = list("bend" = 43.8), RIG_HEAD = list("nod" = -20.8), RIG_L_ARM = list("upper" = 12, "lower" = -118.3), RIG_R_ARM = list("upper" = -101, "lower" = -22.9), RIG_L_LEG = list("thigh" = 52.8, "shin" = -56, "foot" = -19), RIG_R_LEG = list("thigh" = 74.4, "shin" = -55.1, "foot" = -18.1)), 0.417), list(list(RIG_CHEST = list("bend" = 43.8), RIG_HEAD = list("nod" = -22.5), RIG_L_ARM = list("upper" = 25.3, "lower" = -102.9), RIG_R_ARM = list("upper" = -101, "lower" = -22.9), RIG_L_LEG = list("thigh" = 47.4, "shin" = -51.1, "foot" = -14.1), RIG_R_LEG = list("thigh" = 74.4, "shin" = -55.5, "foot" = -18.5)), 0.417), list(list(RIG_CHEST = list("bend" = 43.8), RIG_HEAD = list("nod" = -21.8), RIG_L_ARM = list("upper" = 25.3, "lower" = -102.9), RIG_R_ARM = list("upper" = -101, "lower" = -22.9), RIG_L_LEG = list("thigh" = 47.4, "shin" = -51.1, "foot" = -14.1), RIG_R_LEG = list("thigh" = 74.4, "shin" = -55.5, "foot" = -18.5)), 0.417), list(list(RIG_CHEST = list("bend" = 43.8), RIG_HEAD = list("nod" = -20.8), RIG_L_ARM = list("upper" = 25.3, "lower" = -102.9), RIG_R_ARM = list("upper" = -101, "lower" = -22.9), RIG_L_LEG = list("thigh" = 47.4, "shin" = -51.1, "foot" = -14.1), RIG_R_LEG = list("thigh" = 74.4, "shin" = -55.5, "foot" = -18.5)), 0.417)), + "singRIGHT" = list(list(list(RIG_CHEST = list("bend" = 16), RIG_HEAD = list("nod" = -13.8), RIG_L_ARM = list("upper" = 117.4, "lower" = 82.7), RIG_R_ARM = list("upper" = -147.2, "lower" = -140.9), RIG_L_LEG = list("thigh" = -20.8, "shin" = 32.2, "foot" = -0.8), RIG_R_LEG = list("thigh" = -142, "shin" = 7.2, "foot" = -25.8)), 0.417), list(list(RIG_CHEST = list("bend" = 11.8), RIG_HEAD = list("nod" = 1.1), RIG_L_ARM = list("upper" = 108.1, "lower" = 85.3), RIG_R_ARM = list("upper" = -136.4, "lower" = -144.3), RIG_L_LEG = list("thigh" = -9.9, "shin" = 27.7, "foot" = -5.3), RIG_R_LEG = list("thigh" = -124.8, "shin" = 17.5, "foot" = -15.5)), 0.417), list(list(RIG_CHEST = list("bend" = 13.8), RIG_HEAD = list("nod" = -1.4), RIG_L_ARM = list("upper" = 101.5, "lower" = 104.9), RIG_R_ARM = list("upper" = -136, "lower" = -149.3), RIG_L_LEG = list("thigh" = -9.9, "shin" = 27.7, "foot" = -5.3), RIG_R_LEG = list("thigh" = -124.8, "shin" = 17.5, "foot" = -15.5)), 0.417), list(list(RIG_CHEST = list("bend" = 13.8), RIG_HEAD = list("nod" = -1.4), RIG_L_ARM = list("upper" = 101.5, "lower" = 99.4), RIG_R_ARM = list("upper" = -136, "lower" = -143.9), RIG_L_LEG = list("thigh" = -9.9, "shin" = 27.7, "foot" = -5.3), RIG_R_LEG = list("thigh" = -124.8, "shin" = 17.5, "foot" = -15.5)), 0.417), list(list(RIG_CHEST = list("bend" = 13.8), RIG_HEAD = list("nod" = -1.4), RIG_L_ARM = list("upper" = 101.5, "lower" = 94.1), RIG_R_ARM = list("upper" = -136, "lower" = -143.9), RIG_L_LEG = list("thigh" = -9.9, "shin" = 27.7, "foot" = -5.3), RIG_R_LEG = list("thigh" = -124.8, "shin" = 17.5, "foot" = -15.5)), 0.417), list(list(RIG_CHEST = list("bend" = 13.8), RIG_HEAD = list("nod" = -1.1), RIG_L_ARM = list("upper" = 101.5, "lower" = 94.1), RIG_R_ARM = list("upper" = -136, "lower" = -143.9), RIG_L_LEG = list("thigh" = -9.9, "shin" = 27.7, "foot" = -5.3), RIG_R_LEG = list("thigh" = -124.8, "shin" = 17.5, "foot" = -15.5)), 0.417)), + ), +)) + +/// Which way each traced sheet's sprites face: "left" or "right" (their left and right notes swap +/// for a singer facing the other way), or "front". +GLOBAL_LIST_INIT(fnf_traced_facing, list( + "corruptedmarble2" = "left", + "corruptedmarble" = "left", + "corruptedgirlfriendflying" = "front", + "corruptedpico" = "left", + "corruptedpico2" = "left", + "corruptedkapi" = "right", +)) diff --git a/voidcrew/modules/gags/gags.dm b/voidcrew/modules/gags/gags.dm new file mode 100644 index 00000000000..4cd019a109a --- /dev/null +++ b/voidcrew/modules/gags/gags.dm @@ -0,0 +1,153 @@ +/** + * Gags: things done to someone for a laugh, each acted out by the limb rig (voidcrew/modules/limb_rig) + * to a clip of its own. Bodies without a rig still go through them, just without the poses. + * + * * Fall Down The Stairs (smite, stairs.dm): Peter Griffin's fall, down a long flight, into his pose. + * * I Like Trains (smite, trains.dm): asdfmovie's three kids, and the tram. + * * *skibidi (emote, skibidi.dm): a toilet with your head on a long neck, for a chorus. + * * *springlock (emote, springlock.dm): the suit's springlocks give. Fatal. + * + * The smites take their victim to a room of their own: a map template in + * _maps/voidcrew/templates/gags/, loaded on reserved ground and given back after. + */ + +#define GAG_TRAIT "gag" +#define GAG_MAPS "_maps/voidcrew/templates/gags/" + +/// Sleeps until this long (deciseconds) after the gag's start, and gives up if it ended meanwhile. +#define GAG_AT(at) sleep(started + (at) - world.time); if(QDELETED(src)) { return } + +/mob/living/carbon/human + /// The gag being played on them, if any. + var/datum/gag/current_gag + +/area/gag_room + name = "somewhere else" + icon = 'icons/area/areas_misc.dmi' + icon_state = "unknown" + area_flags = HIDDEN_AREA | EVENT_PROTECTED | NOTELEPORT + default_gravity = STANDARD_GRAVITY + requires_power = FALSE + static_lighting = FALSE + base_lighting_alpha = 255 + base_lighting_color = COLOR_WHITE + ambience_index = null + ambient_buzz = null + +/// Just looks, set up in the gags' maps: the stairs, and the parts of the tram that run it. +/obj/effect/gag_scenery + name = "" + anchored = TRUE + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + layer = TURF_DECAL_LAYER + plane = FLOOR_PLANE + +/// Scenery that stands up off the floor: doors, displays, consoles. +/obj/effect/gag_scenery/standing + layer = OBJ_LAYER + plane = GAME_PLANE + +/// The tram's subfloor, without the tram it'd make move: under its floor and walls, as the real one is. +/obj/effect/gag_scenery/tram_subfloor + icon = 'icons/obj/tram/tram_structure.dmi' + icon_state = "subfloor" + layer = TRAM_STRUCTURE_LAYER + plane = GAME_PLANE + +/// Loads one of the gags' maps (see GAG_MAPS) on reserved ground. Returns the reservation, or null. +/proc/gag_load_room(map_name) + var/static/list/templates = list() + var/datum/map_template/template = templates[map_name] + if(!template) + template = new(GAG_MAPS + map_name, cache = TRUE) + templates[map_name] = template + var/datum/turf_reservation/room = SSmapping.request_turf_block_reservation(template.width, template.height, requester = "a gag") + if(!room) + return null + template.load(room.bottom_left_turfs[1]) + return room + +/// A turf of a room, by its x and y in the map (from 1, at the bottom left, as map editors count). +/proc/gag_room_turf(datum/turf_reservation/room, x, y) + var/turf/origin = room.bottom_left_turfs[1] + return locate(origin.x + x - 1, origin.y + y - 1, origin.z) + +/** + * One gag being played on someone. Holds them still for it, and gives back afterwards whatever it + * took: their transform, their place, the room it made. + */ +/datum/gag + var/mob/living/carbon/human/victim + /// Their transform before, given back when they're knocked down or it ends (see give_back_transform()). + var/matrix/old_transform + var/transform_taken = TRUE + /// Where they were, for the gags that take them somewhere. + var/turf/came_from + var/datum/turf_reservation/room + /// When it started, for its timings (see GAG_AT). + var/started + +/datum/gag/New(mob/living/carbon/human/victim) + . = ..() + src.victim = victim + victim.current_gag = src + old_transform = matrix(victim.transform) + victim.add_traits(list(TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED), GAG_TRAIT) + RegisterSignal(victim, COMSIG_QDELETING, PROC_REF(on_victim_deleted)) + RegisterSignal(victim, COMSIG_LIVING_SET_BODY_POSITION, PROC_REF(on_body_position)) + started = world.time + INVOKE_ASYNC(src, PROC_REF(act)) + +/// The gag itself. +/datum/gag/proc/act() + return + +/datum/gag/Destroy() + if(victim) + UnregisterSignal(victim, list(COMSIG_QDELETING, COMSIG_LIVING_SET_BODY_POSITION)) + give_back_transform() + victim.remove_traits(list(TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED, TRAIT_GODMODE), GAG_TRAIT) + victim.pixel_w = victim.base_pixel_w + victim.pixel_z = victim.base_pixel_z + victim.current_gag = null + victim = null + // Nobody's left behind when the room goes, living or not. + if(room) + for(var/turf/spot as anything in room.reserved_turfs) + for(var/mob/left in spot) + left.forceMove(came_from || get_safe_random_station_turf()) + QDEL_NULL(room) + return ..() + +/datum/gag/proc/give_back_transform() + if(!transform_taken) + return + transform_taken = FALSE + victim.transform = old_transform + +/datum/gag/proc/on_victim_deleted(datum/source) + SIGNAL_HANDLER + victim = null + qdel(src) + +/// Knocked down: the game turns them from their own transform, so they get it back first; and it's over. +/datum/gag/proc/on_body_position(datum/source, new_value, old_value) + SIGNAL_HANDLER + give_back_transform() + if(new_value == LYING_DOWN) + qdel(src) + +/// Poses someone, held until they're told otherwise (see /datum/limb_rig/proc/settle()). +/proc/gag_pose(mob/living/carbon/who, list/pose, time = 1) + who.limb_rig?.play(list(list(pose, max(time, 0.5))), settle_after = FALSE) + +/// Every limb flung somewhere, as when tumbling. +/proc/gag_flail_pose() + return list( + RIG_L_ARM = list("swing" = rand(-150, 150), "raise" = rand(0, 60), "elbow" = rand(0, 120)), + RIG_R_ARM = list("swing" = rand(-150, 150), "raise" = rand(0, 60), "elbow" = rand(0, 120)), + RIG_L_LEG = list("swing" = rand(-70, 100), "knee" = rand(0, 120)), + RIG_R_LEG = list("swing" = rand(-70, 100), "knee" = rand(0, 120)), + RIG_HEAD = list("nod" = rand(-30, 30)), + RIG_CHEST = list("bend" = rand(-20, 30)), + ) diff --git a/voidcrew/modules/gags/icons/gags_face.dmi b/voidcrew/modules/gags/icons/gags_face.dmi new file mode 100644 index 00000000000..7bd9ad366e0 Binary files /dev/null and b/voidcrew/modules/gags/icons/gags_face.dmi differ diff --git a/voidcrew/modules/gags/icons/gags_mask.dmi b/voidcrew/modules/gags/icons/gags_mask.dmi new file mode 100644 index 00000000000..4f0e3aed375 Binary files /dev/null and b/voidcrew/modules/gags/icons/gags_mask.dmi differ diff --git a/voidcrew/modules/gags/invincible.dm b/voidcrew/modules/gags/invincible.dm new file mode 100644 index 00000000000..ce48a67ff77 --- /dev/null +++ b/voidcrew/modules/gags/invincible.dm @@ -0,0 +1,208 @@ +/** + * *invincible: the Invincible wobble edit. The whole spessman is a cut-out picture on a spring: + * every move flicks it, and it overshoots and wobbles back and forth about its feet, leaning, + * squashing and swinging, each swing smaller, until it settles or the next flick comes. A rigged + * body strikes the edit's poses as well. They're held where they are while it lasts, and invincible + * if they were fit to be (see run_emote()). + * + * The edit, as it goes: a fighting crouch, lunging twice; flying, fists up, swaying; zipping + * across and back, one fist up; and a punch at whoever's watching, drawn half again as big. + */ +/// How long it lasts (invincible.ogg's length), how long until they can again, and how long until +/// they can be invincible in it again (until then they just wobble). +#define GAG_INVINCIBLE_LENGTH (11.3 SECONDS) +#define GAG_INVINCIBLE_COOLDOWN (10 SECONDS) +#define GAG_INVINCIBLE_GODMODE_COOLDOWN (3 MINUTES) +/// How many times it swings back and forth after a flick, and how much of each swing the next keeps. +#define GAG_INVINCIBLE_RINGS 7 +#define GAG_INVINCIBLE_DECAY 0.62 + +/datum/emote/living/carbon/human/invincible + key = "invincible" + message = "becomes invincible." + emote_type = EMOTE_VISIBLE | EMOTE_AUDIBLE + cooldown = GAG_INVINCIBLE_COOLDOWN + +/datum/emote/living/carbon/human/invincible/can_run_emote(mob/user, status_check = TRUE, intentional, params) + var/mob/living/carbon/human/human_user = user + if(!intentional || !istype(human_user) || human_user.current_gag || human_user.stat != CONSCIOUS || human_user.body_position == LYING_DOWN || human_user.buckled || !isturf(human_user.loc) || HAS_TRAIT(human_user, TRAIT_RESTRAINED)) + return FALSE + return ..() + +/datum/emote/living/carbon/human/invincible/run_emote(mob/user, params, type_override, intentional = FALSE) + . = ..() + var/mob/living/carbon/human/human_user = user + // Invincible only when nothing's wrong, and only every so often: it's never a way out of a fight, + // nor chained into a godmode. Otherwise they just wobble, held still and as hittable as ever. + var/invincible = world.time >= human_user.next_invincible && human_user.health >= human_user.maxHealth * 0.9 && !human_user.pulledby && !human_user.on_fire + if(invincible) + human_user.next_invincible = world.time + GAG_INVINCIBLE_GODMODE_COOLDOWN + human_user.log_message("became invincible for [DisplayTimeText(GAG_INVINCIBLE_LENGTH)] (*invincible).", LOG_EMOTE) + new /datum/gag/invincible(user, invincible) + +/mob/living/carbon/human + /// When they can next be invincible (see *invincible). + var/next_invincible = 0 + +/** + * The wobble, flick by flick, in time with invincible.ogg: list(when, in seconds into the clip; how + * long the move takes, in deciseconds (0 snaps); where it settles: shear, squash, turn (degrees), + * size, pixel_w, pixel_z; how hard it's flicked: shear, squash, turn; and how long one swing back + * and forth takes, in deciseconds). It moves to where it settles plus the flick, then swings to the + * other side and back (see GAG_INVINCIBLE_RINGS and GAG_INVINCIBLE_DECAY) and settles. A flick of + * nothing just moves. Shear leans the top over (1 being a pixel across for every pixel up), squash + * below 1 squashes and above stretches, and all of it is about their feet. + */ +GLOBAL_LIST_INIT(gag_invincible_beats, list( + // A fighting crouch, lunging twice. + list(0, 1.5, 0.1, 0.95, 0, 1, 0, 0, 0.5, 0.25, 15, 2.6), + list(0.2, 1.2, 0.35, 0.8, 5, 1, 6, 0, 0.6, 0.3, 20, 2.2), + list(0.55, 1.2, 0.05, 1, 0, 1, 0, 0, -0.6, -0.25, -18, 2.6), + list(1, 1.2, 0.35, 0.8, 5, 1, 7, 0, 0.7, 0.3, 22, 2.2), + list(1.35, 1.2, 0.05, 1, 0, 1, 0, 0, -0.6, -0.25, -18, 2.6), + list(2, 1, 0.1, 0.95, 0, 1, 0, 0, 0.4, 0.15, 12, 2.4), + list(2.45, 1, 0.1, 0.95, 0, 1, 0, 0, -0.45, -0.2, -14, 2.4), + list(2.85, 1, 0, 0.8, 0, 1, 0, 0, 0, 0.3, 0, 1.6), + // Flying, fists up, flung from side to side. + list(3, 0.8, 0, 1, 0, 1.1, 0, 10, 0.8, 0.3, 35, 3), + list(3.6, 1, 0, 1, 0, 1.1, 0, 10, -0.6, 0.2, -25, 2.8), + list(4.2, 1, 0, 1, 0, 1.1, 0, 10, 0.65, -0.2, 25, 2.8), + list(4.8, 1, 0, 1, 0, 1.1, 0, 10, -0.7, 0.25, -28, 2.6), + list(5.4, 1, 0, 1, 0, 1.1, 0, 10, 0.6, -0.2, 24, 2.8), + list(6, 1, 0, 1, 0, 1.1, 0, 10, -0.5, 0.2, -20, 2.6), + // Zipping across, landing with one fist up, and back. + list(6.5, 0, 0, 1, 90, 1, -20, 4, 0, 0, 0, 0), + list(6.52, 3, 0, 1, 85, 1, 20, 4, 0, 0, 0, 0), + list(6.85, 0.5, 0, 1, 0, 1, 0, 10, 0.8, 0.3, 30, 2.6), + list(7.6, 0, 0, 1, -95, 1, 20, 4, 0, 0, 0, 0), + list(7.62, 3, 0, 1, -85, 1, -20, 4, 0, 0, 0, 0), + list(7.95, 0.5, 0, 1, 0, 1, 0, 10, -0.8, 0.3, -30, 2.6), + // The punch at the camera, shaken about. + list(8.75, 0.5, 0, 1, 0, 1.6, 0, 0, 0.3, 0.4, 10, 3), + list(9.4, 1, 0, 1, 0, 1.6, 0, 0, -0.6, 0.2, -20, 2.8), + list(10.1, 1, 0, 1, 0, 1.6, 0, 0, 0.6, -0.2, 20, 2.8), + list(10.7, 1, 0, 1, 0, 1.6, 0, 0, -0.4, 0.15, -12, 2.6), + list(11.1, 1.5, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0), +)) + +/// When a rigged body strikes each pose: list(when, in seconds; facing; pose; how long it takes, in deciseconds). +GLOBAL_LIST_INIT(gag_invincible_posing, list( + list(0, EAST, "stance", 1), + list(0.2, EAST, "lunge", 1.5), + list(0.45, EAST, "stance", 3), + list(1, EAST, "lunge", 1.5), + list(1.25, EAST, "stance", 3), + list(3, SOUTH, "fists_up", 0.5), + list(6.5, EAST, "dive", 0.5), + list(6.85, SOUTH, "one_fist", 0.5), + list(7.6, WEST, "dive", 0.5), + list(7.95, SOUTH, "one_fist", 0.5), + list(8.75, SOUTH, "punch", 0.5), +)) + +/// The poses, by name. +GLOBAL_LIST_INIT(gag_invincible_poses, list( + "stance" = list(RIG_CHEST = list("bend" = 12), RIG_HEAD = list("nod" = -8), RIG_L_ARM = list("swing" = 60, "elbow" = 100), RIG_R_ARM = list("swing" = 35, "elbow" = 120), RIG_L_LEG = list("swing" = 35, "knee" = 55), RIG_R_LEG = list("swing" = -20, "knee" = 25)), + "lunge" = list(RIG_CHEST = list("bend" = 35), RIG_HEAD = list("nod" = -15), RIG_L_ARM = list("swing" = 95, "elbow" = 5), RIG_R_ARM = list("swing" = 20, "elbow" = 130), RIG_L_LEG = list("swing" = 55, "knee" = 45), RIG_R_LEG = list("swing" = -45, "knee" = 5)), + "fists_up" = list(RIG_HEAD = list("nod" = -10), RIG_L_ARM = list("raise" = 160, "elbow" = 30), RIG_R_ARM = list("raise" = 160, "elbow" = 30), RIG_L_LEG = list("swing" = -15, "knee" = 35), RIG_R_LEG = list("swing" = 10, "knee" = 50)), + "dive" = list(RIG_CHEST = list("bend" = -10), RIG_HEAD = list("nod" = -20), RIG_L_ARM = list("swing" = 175), RIG_R_ARM = list("swing" = 165, "elbow" = 10), RIG_L_LEG = list("swing" = -20, "knee" = 10), RIG_R_LEG = list("swing" = -35, "knee" = 30)), + "one_fist" = list(RIG_L_ARM = list("raise" = 30, "swing" = 20, "elbow" = 90), RIG_R_ARM = list("raise" = 170), RIG_L_LEG = list("swing" = -10, "knee" = 40), RIG_R_LEG = list("knee" = 20)), + "punch" = list(RIG_CHEST = list("bend" = 15), RIG_HEAD = list("nod" = 5), RIG_L_ARM = list("swing" = 40, "raise" = 10, "elbow" = 110), RIG_R_ARM = list("swing" = 80, "raise" = 25), RIG_L_LEG = list("swing" = 20, "knee" = 30), RIG_R_LEG = list("swing" = -15, "knee" = 15)), +)) + +/// When they're smeared across, zipping: list(from, to), in seconds. +GLOBAL_LIST_INIT(gag_invincible_smears, list(list(6.5, 6.85), list(7.6, 7.95))) + +/datum/gag/invincible + /// Ends it come what may, invincibility and all. + var/failsafe + +/datum/gag/invincible/New(mob/living/carbon/human/victim, invincible = FALSE) + . = ..() + if(invincible) + ADD_TRAIT(victim, TRAIT_GODMODE, GAG_TRAIT) + // Whatever happens to the rest of it, this ends when the clip does, and the invincibility with it + // (see /datum/gag/Destroy()). + failsafe = QDEL_IN_STOPPABLE(src, GAG_INVINCIBLE_LENGTH + 1 SECONDS) + // Moved off their spot (pulled, thrown, teleported): over. + RegisterSignal(victim, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + +/datum/gag/invincible/act() + playsound(victim, 'voidcrew/modules/gags/sound/invincible.ogg', 80, FALSE) + var/list/beats = GLOB.gag_invincible_beats + var/list/posing = GLOB.gag_invincible_posing + var/list/smears = GLOB.gag_invincible_smears + var/next_beat = 1 + var/next_pose = 1 + var/next_smear = 1 + var/smearing = FALSE + while(next_beat <= length(beats)) + // Whichever comes next: a beat, a pose or a smear starting or stopping. + var/list/beat = beats[next_beat] + var/list/pose = next_pose <= length(posing) ? posing[next_pose] : null + var/list/smear = next_smear <= length(smears) ? smears[next_smear] : null + var/smear_at = smear ? smear[smearing ? 2 : 1] : INFINITY + var/at = min(beat[1], pose ? pose[1] : INFINITY, smear_at) + GAG_AT(at SECONDS) + if(smear_at == at) + if(smearing) + victim.remove_filter("gag_invincible_smear") + next_smear++ + else + victim.add_filter("gag_invincible_smear", 2, motion_blur_filter(x = 4, y = 0)) + smearing = !smearing + if(pose && pose[1] == at) + victim.setDir(pose[2]) + gag_pose(victim, GLOB.gag_invincible_poses[pose[3]], pose[4]) + next_pose++ + if(beat[1] == at) + flick_to(beat) + next_beat++ + GAG_AT(GAG_INVINCIBLE_LENGTH) + qdel(src) + +/** + * One flick (see GLOB.gag_invincible_beats): over to where it settles plus the flick, then swinging + * to the other side and back, smaller each time, and settling. Cuts off whatever swinging came before. + */ +/datum/gag/invincible/proc/flick_to(list/beat) + var/pixel_w = victim.base_pixel_w + beat[7] + var/pixel_z = victim.base_pixel_z + beat[8] + var/half_swing = beat[12] / 2 + var/swing = 1 + animate(victim, transform = old_transform * gag_invincible_matrix(beat[3] + beat[9], beat[4] + beat[10], beat[5] + beat[11], beat[6]), pixel_w = pixel_w, pixel_z = pixel_z, time = beat[2], easing = SINE_EASING | EASE_OUT) + if(!beat[9] && !beat[10] && !beat[11]) + return + for(var/ring in 1 to GAG_INVINCIBLE_RINGS) + swing *= -GAG_INVINCIBLE_DECAY + animate(transform = old_transform * gag_invincible_matrix(beat[3] + beat[9] * swing, beat[4] + beat[10] * swing, beat[5] + beat[11] * swing, beat[6]), time = half_swing, easing = SINE_EASING) + animate(transform = old_transform * gag_invincible_matrix(beat[3], beat[4], beat[5], beat[6]), time = half_swing, easing = SINE_EASING) + +/datum/gag/invincible/proc/on_moved(datum/source) + SIGNAL_HANDLER + qdel(src) + +/datum/gag/invincible/Destroy() + deltimer(failsafe) + if(victim) + UnregisterSignal(victim, COMSIG_MOVABLE_MOVED) + // Stops the wobble where it is, before they're put back. + animate(victim) + victim.remove_filter("gag_invincible_smear") + victim.limb_rig?.settle() + return ..() + +/// Leans, squashes, turns and sizes something about its feet (the bottom of its tile), as the wobble does. +/proc/gag_invincible_matrix(shear, squash, turn, size) + var/matrix/wobble = matrix(1, shear, 0, 0, 1, 0) + wobble.Scale(size * (2 - squash), size * squash) + wobble.Turn(turn) + // Back onto its feet: whatever the rest did to the middle of the bottom edge, undone. + wobble.Translate(16 * wobble.b, 16 * wobble.e - 16) + return wobble + +#undef GAG_INVINCIBLE_LENGTH +#undef GAG_INVINCIBLE_COOLDOWN +#undef GAG_INVINCIBLE_GODMODE_COOLDOWN +#undef GAG_INVINCIBLE_RINGS +#undef GAG_INVINCIBLE_DECAY diff --git a/voidcrew/modules/gags/skibidi.dm b/voidcrew/modules/gags/skibidi.dm new file mode 100644 index 00000000000..d6970063605 --- /dev/null +++ b/voidcrew/modules/gags/skibidi.dm @@ -0,0 +1,142 @@ +/** + * *skibidi: for a chorus of the song, the body's gone and there's a toilet, and their head comes up out + * of it on a long neck, grinning far too wide, bobbing to the beat, twitching, and turning about. + * Then it's all as it was. + * + * The body's masked away below the neck (gags_mask.dmi) and the head drawn twice the size, as + * the meme's heads are; the face over it is drawn at that size too (gags_face.dmi), so it gets + * every pixel of its grin. + */ +/datum/emote/living/carbon/human/skibidi + key = "skibidi" + key_third_person = "skibidis" + message = "goes skibidi." + emote_type = EMOTE_VISIBLE | EMOTE_AUDIBLE + cooldown = 15 SECONDS + +/datum/emote/living/carbon/human/skibidi/can_run_emote(mob/user, status_check = TRUE, intentional, params) + var/mob/living/carbon/human/human_user = user + if(!istype(human_user) || human_user.current_gag || human_user.body_position == LYING_DOWN || human_user.buckled || !isturf(human_user.loc) || !human_user.get_bodypart(BODY_ZONE_HEAD)) + return FALSE + return ..() + +/datum/emote/living/carbon/human/skibidi/run_emote(mob/user, params, type_override, intentional = FALSE) + . = ..() + new /datum/gag/skibidi(user) + +/// How long the song is, and how far apart its beats are. +#define SKIBIDI_LENGTH (11.4 SECONDS) +#define SKIBIDI_BEAT 3 +/// The bottom of the toilet's bowl, where the neck comes out, in pixels up its sprite. +#define SKIBIDI_BOWL 12 +/// How far the bottom of the head is above the bowl (pixels): sunk into it, and out on the neck. +#define SKIBIDI_SUNK 2 +#define SKIBIDI_OUT 24 +/// How tall the head's drawn, in pixels, whatever size it really is (the meme's heads are big). +#define SKIBIDI_HEAD_HEIGHT 15 +/// Where a human's head sits in its sprite: the bottom of it, and the top. +#define SKIBIDI_HUMAN_NECK 21.5 +#define SKIBIDI_HUMAN_CROWN 29 + +/datum/gag/skibidi + var/obj/effect/abstract/skibidi_part/toilet + var/obj/effect/abstract/skibidi_part/neck + var/mutable_appearance/face + /// What the face is drawn on: the rig's face piece, or the mob itself. + var/atom/face_holder + /// How high the bottom of their head is in their sprite, and how much bigger it's drawn. + var/neck_y = SKIBIDI_HUMAN_NECK + var/head_scale = 2 + +/obj/effect/abstract/skibidi_part + icon = 'icons/obj/watercloset.dmi' + icon_state = "toilet10" + layer = MOB_LAYER - 0.02 + appearance_flags = PIXEL_SCALE + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + +/datum/gag/skibidi/act() + playsound(victim, 'voidcrew/modules/gags/sound/skibidi.ogg', 75, FALSE) + victim.setDir(SOUTH) + toilet = new(get_turf(victim)) + neck = new(get_turf(victim)) + neck.icon = 'icons/effects/alphacolors.dmi' + neck.icon_state = "white" + neck.layer = MOB_LAYER - 0.01 + var/obj/item/bodypart/head/head = victim.get_bodypart(BODY_ZONE_HEAD) + neck.color = head?.get_rig_skin_colour() || "#d9b98c" + face = mutable_appearance('voidcrew/modules/gags/icons/gags_face.dmi', "skibidi", FLOAT_LAYER + 0.1) + // The face is drawn at twice a human head's size: 64 pixels for its 32, drawn from 16 off. + face.pixel_w = -16 + face.pixel_z = -16 + var/matrix/fitted = matrix() * 0.5 + face_holder = victim + // On a rig, the head's wherever its skeleton puts it. + var/datum/limb_rig/sprites/rig = victim.limb_rig + if(istype(rig) && rig.face_part) + face_holder = rig.face_part + var/list/bones = rig.skeleton[dir2text(SOUTH)] + var/list/rig_neck = bones["head"] + var/list/crown = bones["crown"] + neck_y = rig_apply_matrix(rig.pivot.transform, rig_neck[1], rig_neck[2])[2] + head_scale = clamp(SKIBIDI_HEAD_HEIGHT / max(crown[2] - rig_neck[2], 1), 1, 2) + // Everyone else's face piece is already laid out like a human head (see get_face_anchor()); + // an Experiment's is its own head, so the face is fitted from its neck to its crown. + if(!istype(rig, /datum/limb_rig/sprites/humanoid)) + var/size = (crown[2] - rig_neck[2]) / (SKIBIDI_HUMAN_CROWN - SKIBIDI_HUMAN_NECK) + fitted = matrix() * (0.5 * size) + fitted.Translate(rig_neck[1] - 16, rig_neck[2] - 16 - (SKIBIDI_HUMAN_NECK - 16) * size) + face.transform = fitted + if(face_holder == victim) + face.layer = MOB_LAYER + 0.1 + face_holder.add_overlay(face) + // Nothing of them below the head. + victim.add_filter("gag_skibidi", 1, alpha_mask_filter(y = round(neck_y - SKIBIDI_HUMAN_NECK), icon = icon('voidcrew/modules/gags/icons/gags_mask.dmi', "head_only"))) + raise(SKIBIDI_SUNK, 0, 0) + raise(SKIBIDI_OUT, 0, 6) + var/beat = 0 + var/static/list/turning = list(SOUTH, WEST, NORTH, EAST) + while(world.time < started + SKIBIDI_LENGTH - 8) + GAG_AT(6 + beat * SKIBIDI_BEAT) + beat++ + // Up and down with the beat, never still, and about every bar. + raise(beat % 2 ? SKIBIDI_OUT + 5 : SKIBIDI_OUT - 2, rand(-7, 7), SKIBIDI_BEAT) + if(beat % 4 == 0) + victim.setDir(turning[(beat / 4) % 4 + 1]) + raise(SKIBIDI_SUNK, 0, 5) + GAG_AT(SKIBIDI_LENGTH) + qdel(src) + +/// Puts the bottom of the head this many pixels above the bowl, tilted a little, over this long; +/// the neck stretched from the bowl up to it. +/datum/gag/skibidi/proc/raise(height, tilt, time) + // Where the bottom of the head ends up once it's drawn bigger, about the sprite's middle. + var/head_bottom = 16 + (neck_y - 16) * head_scale + var/matrix/head = matrix(old_transform) + head.Scale(head_scale) + head.Turn(tilt) + head.Translate(rand(-1, 1), SKIBIDI_BOWL + height - head_bottom) + animate(victim, transform = head, time = time, easing = SINE_EASING) + var/neck_length = max(height + 2, 1) + var/matrix/stretched = matrix() + stretched.Scale(8 / 32, neck_length / 32) + stretched.Translate(0, SKIBIDI_BOWL - (16 - neck_length / 2)) + animate(neck, transform = stretched, time = time, easing = SINE_EASING) + +/datum/gag/skibidi/Destroy() + if(victim) + victim.remove_filter("gag_skibidi") + face_holder?.cut_overlay(face) + face_holder = null + QDEL_NULL(toilet) + QDEL_NULL(neck) + return ..() + +#undef SKIBIDI_LENGTH +#undef SKIBIDI_BEAT +#undef SKIBIDI_BOWL +#undef SKIBIDI_SUNK +#undef SKIBIDI_OUT +#undef SKIBIDI_HEAD_HEIGHT +#undef SKIBIDI_HUMAN_NECK +#undef SKIBIDI_HUMAN_CROWN diff --git a/voidcrew/modules/gags/sound/invincible.ogg b/voidcrew/modules/gags/sound/invincible.ogg new file mode 100644 index 00000000000..a4a8fa559d5 Binary files /dev/null and b/voidcrew/modules/gags/sound/invincible.ogg differ diff --git a/voidcrew/modules/gags/sound/peter_stairs.ogg b/voidcrew/modules/gags/sound/peter_stairs.ogg new file mode 100644 index 00000000000..eb55bdb2d2e Binary files /dev/null and b/voidcrew/modules/gags/sound/peter_stairs.ogg differ diff --git a/voidcrew/modules/gags/sound/skibidi.ogg b/voidcrew/modules/gags/sound/skibidi.ogg new file mode 100644 index 00000000000..ef92c9ea368 Binary files /dev/null and b/voidcrew/modules/gags/sound/skibidi.ogg differ diff --git a/voidcrew/modules/gags/sound/springlock.ogg b/voidcrew/modules/gags/sound/springlock.ogg new file mode 100644 index 00000000000..90ef71c9410 Binary files /dev/null and b/voidcrew/modules/gags/sound/springlock.ogg differ diff --git a/voidcrew/modules/gags/sound/trains.ogg b/voidcrew/modules/gags/sound/trains.ogg new file mode 100644 index 00000000000..2038a399070 Binary files /dev/null and b/voidcrew/modules/gags/sound/trains.ogg differ diff --git a/voidcrew/modules/gags/sound/trains_dancing.ogg b/voidcrew/modules/gags/sound/trains_dancing.ogg new file mode 100644 index 00000000000..1bd14417cba Binary files /dev/null and b/voidcrew/modules/gags/sound/trains_dancing.ogg differ diff --git a/voidcrew/modules/gags/sound/trains_singing.ogg b/voidcrew/modules/gags/sound/trains_singing.ogg new file mode 100644 index 00000000000..62ed97bf0cc Binary files /dev/null and b/voidcrew/modules/gags/sound/trains_singing.ogg differ diff --git a/voidcrew/modules/gags/springlock.dm b/voidcrew/modules/gags/springlock.dm new file mode 100644 index 00000000000..b3d0d7663a2 --- /dev/null +++ b/voidcrew/modules/gags/springlock.dm @@ -0,0 +1,140 @@ +/** + * *springlock: the springlocks give. They're laughing (the clip's laugh, not theirs), it creaks, and + * then the suit's parts snap into where their insides were, one after another: every crunch a + * spasm and blood out of them, gibs and the suit's metal thrown off every which way, their organs pushed out onto + * the floor, the puddle under them growing. Their knees go; they reach out for something; they + * fold over. They don't get up. + */ +/datum/emote/living/carbon/human/springlock + key = "springlock" + message = "laughs... and something inside clicks." + emote_type = EMOTE_VISIBLE + cooldown = 30 SECONDS + +/datum/emote/living/carbon/human/springlock/can_run_emote(mob/user, status_check = TRUE, intentional, params) + var/mob/living/carbon/human/human_user = user + // Only ever on purpose: nothing makes anyone do this. + if(!intentional || !istype(human_user) || human_user.current_gag || human_user.body_position == LYING_DOWN || !isturf(human_user.loc)) + return FALSE + return ..() + +/datum/emote/living/carbon/human/springlock/run_emote(mob/user, params, type_override, intentional = FALSE) + . = ..() + new /datum/gag/springlock(user) + +/// Every crunch, in seconds into springlock.ogg (measured off the clip). +GLOBAL_LIST_INIT(gag_springlock_crunches, list(8.12, 8.86, 9.9, 10.24, 10.66, 11.46, 11.76, 13, 14.4, 14.8, 15.16, 15.48, 16.14, 16.42, 17.4, 18.2, 18.52, 19.06, 20.42, 22.4, 23.18, 24.28, 24.74, 25.04, 25.36, 25.68, 27.4)) +/// The crunches that push an organ out. +GLOBAL_LIST_INIT(gag_springlock_spills, list(11.46, 16.42, 22.4)) + +/datum/gag/springlock + var/obj/effect/decal/cleanable/blood/puddle + /// Whether their knees have gone, and whether they're reaching out. + var/knelt = FALSE + var/reaching = FALSE + +/datum/gag/springlock/act() + playsound(victim, 'voidcrew/modules/gags/sound/springlock.ogg', 80, FALSE) + // Laughing: head back, hands to the belly, shaking with it. + var/list/laugh_in = list(RIG_HEAD = list("nod" = -28), RIG_CHEST = list("bend" = -8, "breath" = 0.05), RIG_L_ARM = list("swing" = 30, "elbow" = 110), RIG_R_ARM = list("swing" = 30, "elbow" = 110)) + var/list/laugh_out = list(RIG_HEAD = list("nod" = -18), RIG_CHEST = list("bend" = -2), RIG_L_ARM = list("swing" = 25, "elbow" = 105), RIG_R_ARM = list("swing" = 25, "elbow" = 105)) + victim.limb_rig?.play(list(list(laugh_in, 2), list(laugh_out, 2)), loop = -1, settle_after = FALSE) + // It creaks: they stop, and look down at themselves. + GAG_AT(5.4 SECONDS) + gag_pose(victim, list(RIG_HEAD = list("nod" = 32), RIG_L_ARM = list("swing" = 15, "raise" = 20), RIG_R_ARM = list("swing" = 15, "raise" = 20)), 1) + shake(1) + GAG_AT(7 SECONDS) + // The first one goes. + puddle = new(get_turf(victim)) + if(QDELETED(puddle)) + puddle = locate() in get_turf(victim) + if(puddle) + puddle.transform = matrix() * 0.3 + animate(puddle, transform = matrix() * 2.2, time = 22 SECONDS) + crunch(TRUE) + for(var/when in GLOB.gag_springlock_crunches) + GAG_AT(when SECONDS) + if(when >= 18.2) + knelt = TRUE + if(when >= 20.42) + reaching = TRUE + crunch(when in GLOB.gag_springlock_spills) + // They fold over, and that's it. + GAG_AT(28 SECONDS) + gag_pose(victim, list(RIG_CHEST = list("bend" = 60), RIG_HEAD = list("nod" = 40), RIG_L_ARM = list("swing" = 10, "raise" = 5), RIG_R_ARM = list("swing" = 10, "raise" = 5)), 10) + GAG_AT(29.5 SECONDS) + UnregisterSignal(victim, COMSIG_LIVING_SET_BODY_POSITION) + give_back_transform() + victim.limb_rig?.set_seated(null) + var/mob/living/carbon/human/dead = victim + dead.adjustBruteLoss(150) + if(dead.stat != DEAD) + dead.death() + qdel(src) + +/// One crunch: a spasm, blood out of them, something thrown off; with spill, an organ pushed out. +/datum/gag/springlock/proc/crunch(spill) + var/list/spasm = list( + RIG_CHEST = list("bend" = rand(-35, 35)), + RIG_HEAD = list("nod" = rand(-40, 40), "tilt" = rand(-25, 25)), + RIG_L_ARM = list("swing" = rand(-60, 140), "raise" = rand(0, 70), "elbow" = rand(0, 130)), + RIG_R_ARM = list("swing" = rand(-60, 140), "raise" = rand(0, 70), "elbow" = rand(0, 130)), + ) + if(reaching) + // One arm out for something, shaking, whatever the rest of them does. + spasm[RIG_L_ARM] = list("swing" = 95 + rand(-6, 6), "raise" = 25 + rand(-6, 6), "elbow" = rand(0, 10)) + if(knelt) + victim.limb_rig?.set_seated("crouch") + gag_pose(victim, spasm, 0.5) + shake(2) + var/turf/here = get_turf(victim) + for(var/spurt in 1 to rand(1, 2)) + spurt(here, rand(2, 4)) + var/obj/effect/decal/cleanable/blood/gibs/thrown = prob(50) ? new /obj/effect/decal/cleanable/blood/gibs(here) : new /obj/effect/decal/cleanable/blood/gibs/robot_debris(here) + if(!QDELETED(thrown)) + fling(thrown, here, rand(1, 3)) + playsound(victim, 'sound/effects/wounds/crack1.ogg', 40, TRUE) + if(!spill) + return + for(var/slot in list(ORGAN_SLOT_STOMACH, ORGAN_SLOT_LIVER, ORGAN_SLOT_APPENDIX)) + var/obj/item/organ/organ = victim.get_organ_slot(slot) + if(!organ) + continue + organ.Remove(victim) + organ.forceMove(here) + organ.throw_at(get_turf_in_angle(rand(0, 359), here, rand(2, 4)) || here, 4, 2) + return + +// Everything thrown off goes any which way, not just along the eight directions (which leaves a star). + +/// Blood out of them, off at any angle, this many tiles. +/datum/gag/springlock/proc/spurt(turf/here, distance) + if(victim.can_bleed(BLOOD_COVER_TURFS) != BLEED_SPLATTER) + return + var/obj/effect/decal/cleanable/blood/hitsplatter/splatter = new(here, victim.get_static_viruses(), victim.get_blood_dna_list(), distance) + if(!QDELETED(splatter)) + splatter.fly_towards(get_turf_in_angle(rand(0, 359), here, distance) || here, distance) + +/// Sends some gibs sliding off at any angle, this many tiles, smearing as they go, and lands them anywhere on the tile. +/datum/gag/springlock/proc/fling(obj/effect/decal/cleanable/blood/gibs/thrown, turf/here, distance) + thrown.pixel_w = rand(-10, 10) + thrown.pixel_z = rand(-10, 10) + var/turf/target = get_turf_in_angle(rand(0, 359), here, distance) + if(!target || target == here) + return + var/datum/move_loop/loop = GLOB.move_manager.move_towards(thrown, target, 2, timeout = distance * 2 + 1, priority = MOVEMENT_ABOVE_SPACE_PRIORITY) + if(loop && thrown.leave_blood) + thrown.RegisterSignal(loop, COMSIG_MOVELOOP_POSTPROCESS, TYPE_PROC_REF(/obj/effect/decal/cleanable/blood/gibs, spread_movement_effects)) + +/// Jolts them where they stand, a few times, this many pixels each way. +/datum/gag/springlock/proc/shake(pixels) + var/home = victim.base_pixel_w + animate(victim, pixel_w = home + pixels, time = 0.5) + for(var/i in 1 to 2) + animate(pixel_w = home - pixels, time = 0.5) + animate(pixel_w = home + pixels, time = 0.5) + animate(pixel_w = home, time = 0.5) + +/datum/gag/springlock/Destroy() + puddle = null + return ..() diff --git a/voidcrew/modules/gags/stairs.dm b/voidcrew/modules/gags/stairs.dm new file mode 100644 index 00000000000..2c707096a1c --- /dev/null +++ b/voidcrew/modules/gags/stairs.dm @@ -0,0 +1,100 @@ +/** + * Fall Down The Stairs: Peter Griffin's fall (Family Guy, "The Blind Side"). They walk along the + * upstairs hallway to the top of the stairs, trip, and go all the way down, hitting every step, to + * land in his pose: face down, arms twisted up behind their back, a leg folded up over it. Then + * they're put back where they were, still in it, hurt. + * + * In stairs.dmm: the hallway's along y 26, the top of the stairs is (10, 25), the eighteen steps + * run down from y 24 to y 7, and the living room's below. + */ +/datum/smite/fall_down_stairs + name = "Fall Down The Stairs" + +/datum/smite/fall_down_stairs/effect(client/user, mob/living/target) + . = ..() + var/mob/living/carbon/human/victim = target + if(!istype(victim) || victim.current_gag) + to_chat(user, span_warning("This must be used on a human who isn't in the middle of another gag."), confidential = TRUE) + return + new /datum/gag/stairs(victim) + +/// Everything they hit on the way down, in seconds into peter_stairs.ogg (measured off the clip): +/// the trip at the top, seventeen more steps, then the floor. +GLOBAL_LIST_INIT(gag_stairs_hits, list(0.12, 0.5, 0.84, 1.18, 1.54, 2.2, 2.54, 3.08, 3.46, 3.82, 4.18, 4.56, 5.34, 5.66, 6.26, 6.56, 7.08, 7.52, 7.9)) + +/// The pose they end up in, seen from the side: on their front, both arms wrenched up behind their +/// back, one leg folded up over it, the other out straight. +/proc/gag_stairs_pose() + return list( + RIG_L_ARM = list("swing" = -135, "raise" = 10, "elbow" = 100), + RIG_R_ARM = list("swing" = -115, "raise" = 20, "elbow" = 125), + RIG_L_LEG = list("swing" = -95, "knee" = 115), + RIG_R_LEG = list("swing" = 5, "knee" = 10), + RIG_HEAD = list("nod" = -20), + RIG_CHEST = list("bend" = -10), + ) + +/datum/gag/stairs/act() + room = gag_load_room("stairs.dmm") + if(!room) + qdel(src) + return + came_from = get_turf(victim) + // Nothing's to stop it partway. + victim.add_traits(list(TRAIT_GODMODE), GAG_TRAIT) + victim.forceMove(gag_room_turf(room, 3, 26)) + victim.setDir(EAST) + sleep(1 SECONDS) + // Along the hallway to the top of the stairs. + victim.set_glide_size(DELAY_TO_GLIDE_SIZE(4)) + for(var/x in 4 to 10) + victim.Move(gag_room_turf(room, x, 26), EAST) + sleep(4) + if(QDELETED(src)) + return + victim.Move(gag_room_turf(room, 10, 25), SOUTH) + sleep(4) + if(QDELETED(src)) + return + // Side on, for the fall. + victim.setDir(EAST) + started = world.time + SEND_SOUND(victim, sound('voidcrew/modules/gags/sound/peter_stairs.ogg')) + var/list/hits = GLOB.gag_stairs_hits + var/angle = 0 + for(var/index in 1 to length(hits)) + GAG_AT(hits[index] SECONDS) + var/landed = index == length(hits) + var/next_hit = landed ? 0.4 SECONDS : (hits[index + 1] - hits[index]) SECONDS + victim.forceMove(gag_room_turf(room, 10, 25 - index)) + var/matrix/turned = matrix(old_transform) + if(landed) + // Face down. + turned.Turn(90) + animate(victim, transform = turned, pixel_z = victim.base_pixel_z, time = 1) + gag_pose(victim, gag_stairs_pose(), 1) + break + // Tumbling end over end, bouncing off each step. + angle += pick(90, 135, 180) + turned.Turn(angle) + animate(victim, transform = turned, pixel_z = victim.base_pixel_z + 6, time = next_hit / 2, easing = SINE_EASING | EASE_OUT) + animate(pixel_z = victim.base_pixel_z, time = next_hit / 2, easing = SINE_EASING | EASE_IN) + gag_pose(victim, gag_flail_pose(), next_hit) + shake_camera(victim, 1, 1) + // A step on, sliding across the floor; then the hiss through his teeth. + GAG_AT(8.1 SECONDS) + victim.forceMove(gag_room_turf(room, 10, 5)) + GAG_AT(11 SECONDS) + // Home, still in it. + UnregisterSignal(victim, COMSIG_LIVING_SET_BODY_POSITION) + give_back_transform() + victim.remove_traits(list(TRAIT_GODMODE, TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED), GAG_TRAIT) + victim.forceMove(came_from) + victim.adjustBruteLoss(25) + victim.Paralyze(8 SECONDS) + QDEL_NULL(room) + sleep(8 SECONDS) + if(QDELETED(src)) + return + victim.limb_rig?.settle() + qdel(src) diff --git a/voidcrew/modules/gags/trains.dm b/voidcrew/modules/gags/trains.dm new file mode 100644 index 00000000000..13d2edfdc36 --- /dev/null +++ b/voidcrew/modules/gags/trains.dm @@ -0,0 +1,146 @@ +/** + * I Like Trains (asdfmovie): they're stood in a row with two kids, in the sketch's white, on a tram + * track. "I like singing!" "I like dancing!" Then it's their turn, and it waits for them to say it + * (or says it for them after a while). When they do, the tram comes through and takes all three. + * The room goes shortly after. + * + * In trains.dmm the track runs along y 7 to 11; they stand in the middle of it, on y 9, the kids at + * x 12 and 13 and them at 14. The tram's tramstation's own car (tram.dmm), loaded somewhere out of + * the way and copied onto one thing that slides through. + */ +/datum/smite/i_like_trains + name = "I Like Trains" + +/datum/smite/i_like_trains/effect(client/user, mob/living/target) + . = ..() + var/mob/living/carbon/human/victim = target + if(!istype(victim) || victim.current_gag) + to_chat(user, span_warning("This must be used on a human who isn't in the middle of another gag."), confidential = TRUE) + return + new /datum/gag/trains(victim) + +/// How long into trains.ogg the tram hits (measured off the clip). +#define TRAINS_HIT (1.55 SECONDS) +/// How long the tram takes to cross the room, and how far into that its front reaches them. +#define TRAINS_CROSSING 10 +#define TRAINS_REACH (TRAINS_CROSSING * 12 / 38) +/// How long they're given to say it. +#define TRAINS_PATIENCE (30 SECONDS) + +/datum/gag/trains + var/list/mob/living/carbon/human/kids = list() + var/datum/turf_reservation/tram_room + var/obj/effect/abstract/gag_tram/tram + /// Whether they've said it. + var/said = FALSE + +/// The tram going by: how the car looks where it's loaded, a tile at a time (one thing can only carry +/// so many overlays), all of them sliding together. +/obj/effect/abstract/gag_tram + layer = ABOVE_MOB_LAYER + appearance_flags = PIXEL_SCALE + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + var/list/obj/effect/abstract/gag_tram/tiles = list() + +/// Takes on the car's looks, laid out from this one tile as they are from the car's bottom left. Done +/// once it's had time to settle (its walls join up a little after it's loaded). +/obj/effect/abstract/gag_tram/proc/copy_car(datum/turf_reservation/car) + var/turf/corner = car.bottom_left_turfs[1] + for(var/turf/spot as anything in car.reserved_turfs) + var/obj/effect/abstract/gag_tram/tile = new(null) + tile.pixel_w = (spot.x - corner.x) * world.icon_size + tile.pixel_z = (spot.y - corner.y) * world.icon_size + // Not the floor under it all: the car's own floor covers it. + var/list/parts = list() + for(var/atom/movable/thing as anything in spot) + parts += new /mutable_appearance(thing.appearance) + tile.add_overlay(parts) + tiles += tile + vis_contents += tile + +/obj/effect/abstract/gag_tram/Destroy() + vis_contents.Cut() + QDEL_LIST(tiles) + return ..() + +/datum/gag/trains/act() + room = gag_load_room("trains.dmm") + tram_room = gag_load_room("tram.dmm") + if(!room || !tram_room) + qdel(src) + return + came_from = get_turf(victim) + victim.forceMove(gag_room_turf(room, 14, 9)) + victim.setDir(SOUTH) + var/mob/living/carbon/human/singer = make_kid(12) + var/mob/living/carbon/human/dancer = make_kid(13) + GAG_AT(2 SECONDS) + singer.say("I like singing!", forced = "i like trains") + playsound(singer, 'voidcrew/modules/gags/sound/trains_singing.ogg', 80, FALSE) + gag_pose(singer, list(RIG_HEAD = list("nod" = -15), RIG_L_ARM = list("raise" = 80, "swing" = 20), RIG_R_ARM = list("raise" = 80, "swing" = 20)), 3) + GAG_AT(4 SECONDS) + dancer.say("I like dancing!", forced = "i like trains") + playsound(dancer, 'voidcrew/modules/gags/sound/trains_dancing.ogg', 80, FALSE) + var/list/sway_left = list(RIG_CHEST = list("lean" = 12), RIG_L_ARM = list("raise" = 120), RIG_R_ARM = list("raise" = 30), RIG_L_LEG = list("swing" = 20)) + var/list/sway_right = list(RIG_CHEST = list("lean" = -12), RIG_L_ARM = list("raise" = 30), RIG_R_ARM = list("raise" = 120), RIG_R_LEG = list("swing" = 20)) + dancer.limb_rig?.play(list(list(sway_left, 3), list(sway_right, 3)), loop = -1, settle_after = FALSE) + // Their turn, once the dancer's done. + GAG_AT(6.5 SECONDS) + to_chat(victim, span_notice("They're both looking at you. What do you like?")) + RegisterSignal(victim, COMSIG_MOB_SAY, PROC_REF(on_say)) + while(!said && world.time < started + 6.5 SECONDS + TRAINS_PATIENCE) + sleep(world.tick_lag) + if(QDELETED(src)) + return + if(!said) + victim.say("I like trains.", forced = "i like trains") + UnregisterSignal(victim, COMSIG_MOB_SAY) + started = world.time + playsound(victim, 'voidcrew/modules/gags/sound/trains.ogg', 90, FALSE) + // From off the right of the room to off its left, front first. + GAG_AT(TRAINS_HIT - TRAINS_REACH) + // On their tile, so it's drawn while they can see it, wherever it's slid to. + tram = new(gag_room_turf(room, 14, 7)) + tram.copy_car(tram_room) + tram.pixel_w = (26 - 14) * 32 + animate(tram, pixel_w = (-12 - 14) * 32, time = TRAINS_CROSSING) + // It takes them as it gets to them, a tile apart: the one who likes trains first. Their going + // doesn't end this; the room's to be cleared up after. + var/list/in_line = list(victim, dancer, singer) + UnregisterSignal(victim, list(COMSIG_QDELETING, COMSIG_LIVING_SET_BODY_POSITION)) + victim.remove_traits(list(TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED), GAG_TRAIT) + victim.current_gag = null + victim = null + for(var/tiles in 0 to 2) + GAG_AT(TRAINS_HIT + tiles * TRAINS_CROSSING / 38) + var/mob/living/carbon/human/hit = in_line[tiles + 1] + if(!QDELETED(hit)) + hit.gib(DROP_ORGANS|DROP_BODYPARTS) + GAG_AT(TRAINS_HIT + 4 SECONDS) + qdel(src) + +/datum/gag/trains/proc/make_kid(x) + var/mob/living/carbon/human/kid = new(gag_room_turf(room, x, 9)) + kid.randomize_human_appearance(~RANDOMIZE_SPECIES) + kid.fully_replace_character_name(kid.real_name, "Kid") + kid.equip_to_slot_or_del(new /obj/item/clothing/under/color/random(kid), ITEM_SLOT_ICLOTHING) + kid.setDir(SOUTH) + kid.add_traits(list(TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED), GAG_TRAIT) + kids += kid + return kid + +/datum/gag/trains/proc/on_say(datum/source, list/speech_args) + SIGNAL_HANDLER + if(findtext(speech_args[SPEECH_MESSAGE], "train")) + said = TRUE + +/datum/gag/trains/Destroy() + QDEL_NULL(tram) + QDEL_LIST(kids) + QDEL_NULL(tram_room) + return ..() + +#undef TRAINS_HIT +#undef TRAINS_CROSSING +#undef TRAINS_REACH +#undef TRAINS_PATIENCE diff --git a/voidcrew/modules/limb_rig/animations.dm b/voidcrew/modules/limb_rig/animations.dm new file mode 100644 index 00000000000..a5c55bb168a --- /dev/null +++ b/voidcrew/modules/limb_rig/animations.dm @@ -0,0 +1,725 @@ +/** + * What the rig does with its pieces. + * + * A pose is a list of part id -> joint angles: + * - arms: "raise" (out to the side), "swing" (forward and back) and "elbow" (forearm bends forward), + * Arms hang off the torso, so they bend with it; + * "aim" instead of "swing" points the whole arm that way in the world, whatever the torso and + * posture are doing (90 is straight out in front). "hand_y" works the elbow out for you so the + * hand ends up at that height on the sprite (the face is about 26), however long the arm is. + * - legs: "raise", "swing" and "knee" (shin bends back) + * - head: "nod" (down is positive) and "tilt" (toward the mob's right) + * - torso: "bend" (forward) and "lean" (toward the mob's right), plus "breath" (0.03 = 3% taller, + * negative squashes), and "air" (pixels the whole body is off the floor) + * - head and torso: "skew", sheared sideways by this much (0.3 is a lot) + * - tail (species with one): "lift" (up, degrees) and "wag" (side to side, degrees) + * - any part: "dx" and "dy" in pixels + * + * Angles are in 3D terms, and the pose maths works out how they look from the way the mob is + * facing: a raised arm points out sideways from the front and barely moves side-on, a swung + * arm does the opposite and foreshortens from the front. So one pose works for all four + * directions, and turning mid-animation still looks right. + * + * Arms and legs are two pieces each, laid along a little joint chain: shoulder to elbow to + * hand, hip to knee to foot. Each piece gets its own complete transform rather than hanging + * off the one above it, so foreshortening the upper arm doesn't squash the forearm too. + * Limbs are also drawn longer than the sprite (RIG_ARM_STRETCH, RIG_LEG_STRETCH, or whatever + * the species' limb_rig_shape says), and the body is lifted so the lowest foot always rests on + * the floor, whatever the legs are doing. A species can also give the rig a posture, which is + * added to every pose (a hunch, say), and its own walk. + * + * A sequence is a list of keyframes, each list(pose, time in deciseconds). + */ + +/// Heights of the elbow, the middle of the hand, the knee and the sole, in BYOND pixel +/// coordinates from the bottom of the sprite. The same in every direction. +#define RIG_ELBOW_Y 17 +#define RIG_HAND_Y 12.5 +#define RIG_KNEE_Y 5 +#define RIG_SOLE_Y 0 + +/// Joint positions per part and direction, in BYOND pixel coordinates (from the bottom left). +/// Arms swing at the shoulder, legs at the hip, the head at the neck, the torso at the hips. +/proc/get_rig_joint(part_id, facing) + switch(part_id) + if(RIG_HEAD) + return (facing & (NORTH|SOUTH)) ? list(16, 23) : list(16, 22) + if(RIG_CHEST) + return list(16, 11) + if(RIG_L_ARM) + switch(facing) + if(NORTH) + return list(11, 21) + if(SOUTH) + return list(21, 21) + if(EAST) + return list(17, 20) + else + return list(19, 20) + if(RIG_R_ARM) + switch(facing) + if(NORTH) + return list(21, 21) + if(SOUTH) + return list(11, 21) + if(EAST) + return list(14, 20) + else + return list(16, 20) + if(RIG_L_LEG) + switch(facing) + if(NORTH) + return list(14, 10) + if(SOUTH) + return list(18, 10) + else + return list(16, 10) + if(RIG_R_LEG) + switch(facing) + if(NORTH) + return list(18, 10) + if(SOUTH) + return list(14, 10) + else + return list(17, 10) + return list(16, 16) + +/// A transform that scales a piece along its length (and optionally across it) from a point, +/// turns it about that point, then moves that point somewhere else. Points are in BYOND pixel +/// coordinates. +/proc/rig_joint_matrix(from_x, from_y, length_scale, angle, to_x, to_y, width_scale = 1) + var/matrix/joint = matrix() + joint.Translate(16.5 - from_x, 16.5 - from_y) + joint.Scale(width_scale, length_scale) + joint.Turn(angle) + joint.Translate(to_x - 16.5, to_y - 16.5) + return joint + +/** + * Transforms for the head or torso in a pose. + * + * * size - how much bigger to draw it, from the joint it hangs off (the neck, for a head) + * * joint - where it hangs off, if not the human joint (list(x, y)) + */ +/proc/rig_pose_matrix(part_id, list/entry, facing, size = 1, list/joint) + var/front = (facing == NORTH || facing == SOUTH) + var/angle = 0 + var/scale_y = 1 + var/dy = entry?["dy"] || 0 + switch(part_id) + if(RIG_HEAD) + var/nod = entry?["nod"] || 0 + var/tilt = entry?["tilt"] || 0 + if(front) + angle = facing == SOUTH ? -tilt : tilt + dy -= nod / 15 + else + angle = facing == EAST ? nod : -nod + if(RIG_CHEST) + var/bend = entry?["bend"] || 0 + var/lean = entry?["lean"] || 0 + if(front) + angle = facing == SOUTH ? -lean : lean + scale_y = cos(bend) + else + angle = facing == EAST ? bend : -bend + scale_y *= 1 + (entry?["breath"] || 0) + joint = joint || get_rig_joint(part_id, facing) + var/matrix/pose = rig_joint_matrix(joint[1], joint[2], scale_y * size, angle, joint[1], joint[2], size) + var/skew = entry?["skew"] + if(skew) + pose = matrix(1, skew, 0, 0, 1, 0) * pose + pose.Translate(entry?["dx"] || 0, dy) + return pose + +/** + * The tail's transform: "lift" raises it, "wag" swings it from side to side. + * + * Side-on, lifting turns it about its root and wagging foreshortens it. From the front or back, + * wagging turns it and lifting stands it up taller. + */ +/proc/rig_tail_matrix(list/root, list/entry, facing) + var/lift = entry?["lift"] || 0 + var/wag = entry?["wag"] || 0 + if(facing == NORTH || facing == SOUTH) + return rig_joint_matrix(root[1], root[2], 1 + lift / 200, facing == NORTH ? wag : -wag, root[1], root[2]) + return rig_joint_matrix(root[1], root[2], 1, facing == EAST ? lift : -lift, root[1], root[2], cos(wag)) + +/** + * Transforms for one arm or leg in a pose. + * + * Returns list(upper piece, lower piece, end, end height). The end is for whatever rides on the + * hand without being stretched (held items). The end height is where the hand or foot finished + * up, before any lift. + * + * * bend - how far the torso is bent forward, for arms: they hang off it and tip back with it. + */ +/proc/rig_limb_matrices(part_id, list/entry, facing, stretch, bend = 0) + var/is_leg = (part_id == RIG_L_LEG || part_id == RIG_R_LEG) + var/list/root = get_rig_joint(part_id, facing) + var/mid_y = is_leg ? RIG_KNEE_Y : RIG_ELBOW_Y + var/end_y = is_leg ? RIG_SOLE_Y : RIG_HAND_Y + var/upper_length = root[2] - mid_y + var/lower_length = mid_y - end_y + + var/raise = entry?["raise"] || 0 + var/swing = entry?["swing"] || 0 + // Elbows fold the forearm forward; knees fold the shin back. + var/lower_swing = swing + (is_leg ? -(entry?["knee"] || 0) : (entry?["elbow"] || 0)) + var/hand_y = entry?["hand_y"] + if(!isnull(hand_y)) + // Bend the elbow however far it takes to put the hand at that height. Heights come out + // the same from every side, so this holds whichever way the mob faces. It's worked out + // as things really stand with the torso bent over, which takes the face down with it. + var/hips_y = get_rig_joint(RIG_CHEST, facing)[2] + var/shoulder_height = hips_y + (root[2] - hips_y) * cos(bend) + var/target_height = hips_y + (hand_y - hips_y) * cos(bend) + var/elbow_height = shoulder_height - upper_length * stretch * cos(swing - bend) * cos(raise) + var/forearm_drop = lower_length * stretch * max(cos(raise), 0.1) + lower_swing = arccos(clamp((elbow_height - target_height) / forearm_drop, -1, 1)) + bend + + var/upper_angle + var/lower_angle + var/upper_scale + var/lower_scale + // How much bigger the hand draws for being closer to the viewer. + var/near = 1 + if(facing == NORTH || facing == SOUTH) + // From the front, the mob's right side is on the left of the screen. From behind it flips. + var/right = (part_id == RIG_R_ARM || part_id == RIG_R_LEG) + upper_angle = (right == (facing == SOUTH)) ? raise : -raise + lower_angle = upper_angle + // Swinging toward or away from the viewer just shortens the limb. Past 90 degrees the + // scale goes negative and the piece flips up over its joint. Arms go by where they really + // point once the torso's bend tips them back, and undo the squash the bending torso already + // gives everything on it. + var/torso_squash = max(cos(bend), 0.25) + upper_scale = cos(swing - bend) / torso_squash + lower_scale = cos(lower_swing - bend) / torso_squash + if(!is_leg) + // Closer is bigger: a hand reaching out at the viewer grows, one reaching away shrinks. + var/toward = sin(lower_swing - bend) * (facing == SOUTH ? 1 : -1) + near = 1 + toward * (toward > 0 ? 0.35 : 0.15) + else + upper_angle = facing == EAST ? -swing : swing + lower_angle = facing == EAST ? -lower_swing : lower_swing + upper_scale = cos(raise) + lower_scale = upper_scale + upper_scale *= stretch + lower_scale *= stretch + + var/offset_x = entry?["dx"] || 0 + var/offset_y = entry?["dy"] || 0 + var/root_x = root[1] + offset_x + var/root_y = root[2] + offset_y + // Where the elbow or knee ends up, and then the hand or foot. Turn() is clockwise. + var/mid_x = root_x - upper_length * upper_scale * sin(upper_angle) + var/mid_to_y = root_y - upper_length * upper_scale * cos(upper_angle) + var/end_x = mid_x - lower_length * lower_scale * sin(lower_angle) + var/end_to_y = mid_to_y - lower_length * lower_scale * cos(lower_angle) + // Fingers foreshorten with the forearm, but never all the way: pointed straight at the + // viewer they'd collapse into a flat line. + return list( + rig_joint_matrix(root[1], root[2], upper_scale, upper_angle, root_x, root_y), + rig_joint_matrix(root[1], mid_y, lower_scale, lower_angle, mid_x, mid_to_y, near), + rig_joint_matrix(root[1], end_y, near, lower_angle, end_x, end_to_y, near), + end_to_y, + ) + +/// Adds a posture to a pose, angle by angle. +/proc/rig_merge_pose(list/posture, list/pose) + if(!posture) + return pose + . = list() + for(var/part_id in posture | (pose || list())) + var/list/merged = list() + var/list/base_entry = posture[part_id] + var/list/pose_entry = pose?[part_id] + for(var/key in base_entry) + merged[key] = base_entry[key] + for(var/key in pose_entry) + merged[key] = (merged[key] || 0) + pose_entry[key] + .[part_id] = merged + +/** + * Turns any "aim" on the arms of a pose into the swing that points them that way, once the + * posture and the torso's bend are added on. Returns the pose, or a changed copy. + */ +/proc/rig_resolve_aim(list/posture, list/pose) + . = pose + for(var/arm_id in list(RIG_L_ARM, RIG_R_ARM)) + var/list/entry = pose?[arm_id] + if(isnull(entry?["aim"])) + continue + if(. == pose) + . = pose.Copy() + var/list/posture_chest = posture?[RIG_CHEST] + var/list/pose_chest = pose[RIG_CHEST] + var/bend = (posture_chest?["bend"] || 0) + (pose_chest?["bend"] || 0) + var/list/posture_arm = posture?[arm_id] + var/list/aimed = entry.Copy() + aimed -= "aim" + // The posture's swing and elbow are added back on when it's merged, so take them off here. + aimed["swing"] = entry["aim"] + bend - (posture_arm?["swing"] || 0) + aimed["elbow"] = (entry["elbow"] || 0) - (posture_arm?["elbow"] || 0) + .[arm_id] = aimed + +/// The transform for every moving piece in a pose, seen from the given direction. +/datum/limb_rig/proc/get_pose_matrices(list/pose, facing) + . = list() + pose = rig_merge_pose(posture, rig_resolve_aim(posture, pose)) + var/list/legs = list() + var/lowest_foot = INFINITY + var/list/chest_entry = pose?[RIG_CHEST] + var/bend = chest_entry?["bend"] || 0 + for(var/side in list("l", "r")) + var/arm_id = side == "l" ? RIG_L_ARM : RIG_R_ARM + var/list/arm = rig_limb_matrices(arm_id, pose?[arm_id], facing, arm_stretch, bend) + .[parts[arm_id]] = arm[1] + .[parts["[side]_forearm"]] = arm[2] + .[item_parts[side]] = arm[3] + var/leg_id = side == "l" ? RIG_L_LEG : RIG_R_LEG + var/list/leg = rig_limb_matrices(leg_id, pose?[leg_id], facing, leg_stretch) + legs[parts[leg_id]] = leg[1] + legs[parts["[side]_shin"]] = leg[2] + lowest_foot = min(lowest_foot, leg[4]) + // Stand the body up (or crouch it down) so the lowest foot is on the floor. + // "air" on the torso then lifts the whole body off the floor on top of that, for jumps and + // the airborne part of a run. + var/lift = RIG_SOLE_Y - lowest_foot + (chest_entry?["air"] || 0) + for(var/obj/effect/abstract/limb_rig_part/leg_part as anything in legs) + var/matrix/leg_matrix = legs[leg_part] + leg_matrix.Translate(0, lift) + .[leg_part] = leg_matrix + var/matrix/torso = rig_pose_matrix(RIG_CHEST, pose?[RIG_CHEST], facing) + torso.Translate(0, lift) + .[pivot] = torso + .[parts[RIG_HEAD]] = rig_pose_matrix(RIG_HEAD, pose?[RIG_HEAD], facing, head_scale) + if(tail_part) + .[tail_part] = rig_tail_matrix(get_tail_root(facing), pose?[RIG_TAIL], facing) * torso + +/// Where a tail joins the body, in BYOND pixel coordinates: the small of the back, which is +/// off to one side seen side-on. A species can set its own with "tail_root", by direction. +/datum/limb_rig/proc/get_tail_root(facing) + var/mob/living/carbon/human/human_owner = owner + var/list/roots = istype(human_owner) ? human_owner.dna?.species?.limb_rig_shape?["tail_root"] : null + var/list/root = roots?[dir2text(facing)] + if(root) + return root + switch(facing) + if(EAST) + return list(13, 12) + if(WEST) + return list(19, 12) + return list(16, 12) + + +/// Puts every piece in a pose immediately. +/datum/limb_rig/proc/snap_to(list/pose) + // Physics has the pieces while it's on. + if(physics) + held_pose = pose + return + var/list/matrices = get_pose_matrices(pose, get_facing()) + for(var/obj/effect/abstract/limb_rig_part/part as anything in matrices) + animate(part, transform = matrices[part], time = 0) + +/** + * Animates every piece through a sequence of keyframes. + * + * * keyframes - list of list(pose, deciseconds), with an optional third entry for the easing + * * loop - how many times to play it, -1 for forever + * * activity - what the rig is doing while this plays + * * settle_after - go back to idle once the sequence has played through (ignored when looping) + */ +/datum/limb_rig/proc/play(list/keyframes, loop = 1, activity = RIG_ACTIVITY_ONESHOT, settle_after = TRUE) + // Physics has the pieces while it's on: authored animation waits until it's off again. + if(physics) + return + src.activity = activity + deltimer(settle_timer) + settle_timer = null + var/facing = get_facing() + var/total_time = 0 + for(var/list/keyframe as anything in keyframes) + total_time += keyframe[2] + var/list/last_keyframe = keyframes[length(keyframes)] + held_pose = last_keyframe[1] + var/list/matrices_per_keyframe = list() + for(var/list/keyframe as anything in keyframes) + matrices_per_keyframe += list(get_pose_matrices(keyframe[1], facing)) + for(var/obj/effect/abstract/limb_rig_part/part as anything in matrices_per_keyframe[1]) + for(var/i in 1 to length(keyframes)) + var/list/keyframe = keyframes[i] + var/list/matrices = matrices_per_keyframe[i] + var/easing = length(keyframe) >= 3 ? keyframe[3] : SINE_EASING + if(i == 1) + animate(part, transform = matrices[part], time = keyframe[2], loop = loop, easing = easing) + else + animate(transform = matrices[part], time = keyframe[2], easing = easing) + if(settle_after && loop == 1) + settle_timer = addtimer(CALLBACK(src, PROC_REF(settle)), total_time, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/// Goes back to the right background loop: working a tool, or just breathing. +/datum/limb_rig/proc/settle() + deltimer(settle_timer) + settle_timer = null + if(QDELETED(owner)) + return + if(working && owner.stat == CONSCIOUS) + play_work() + return + var/list/own_idle = (owner.stat == CONSCIOUS && owner.body_position != LYING_DOWN) ? get_own_idle() : null + if(own_idle) + play(own_idle, loop = -1, activity = RIG_ACTIVITY_IDLE) + var/list/first = own_idle[1] + held_pose = first[1] + return + var/deep = owner.stat != CONSCIOUS + // A tail, if there is one, sways lazily from side to side, or droops when out cold. + var/list/inhale = list(RIG_CHEST = list("breath" = deep ? 0.05 : 0.035), RIG_HEAD = list("nod" = deep ? 4 : 0), RIG_TAIL = list("wag" = deep ? 0 : 7, "lift" = deep ? -25 : 0)) + var/list/exhale = list(RIG_HEAD = list("nod" = deep ? 4 : 0), RIG_TAIL = list("wag" = deep ? 0 : -7, "lift" = deep ? -25 : 0)) + play(list( + list(inhale, deep ? 22 : 15), + list(exhale, deep ? 28 : 20), + ), loop = -1, activity = RIG_ACTIVITY_IDLE) + held_pose = exhale + +/// How long crossing one tile takes right now, in deciseconds: whatever slows or speeds the mob up. +/datum/limb_rig/proc/get_move_delay() + if(step_delay_override) + return step_delay_override + var/delay = owner.cached_multiplicative_slowdown + if(!delay) + delay = world.icon_size / max(owner.glide_size, 1) * world.tick_lag + return clamp(delay, 0.5, 20) + +/// How many tiles one step covers. Longer legs take longer strides, and running ones longer again. +/datum/limb_rig/proc/get_stride_tiles(running) + return (running ? 1.4 : 1.25) * sqrt(leg_stretch / RIG_LEG_STRETCH) + +/// How big to make each step: moving faster than usual for the gait means bigger, bouncier +/// strides, slower means little shuffling ones. +/datum/limb_rig/proc/get_gait_scale(running) + var/usual_delay = running ? CONFIG_GET(number/movedelay/run_delay) : CONFIG_GET(number/movedelay/walk_delay) + return clamp(sqrt(max(usual_delay, 0.5) / get_move_delay()), 0.6, 1.4) + +/// A copy of a stepping pose with its swings, knees, lean and airtime scaled up or down. +/proc/rig_scale_gait(list/pose, amount) + . = list() + for(var/part_id in pose) + var/list/entry = pose[part_id] + var/list/scaled = entry.Copy() + for(var/key in list("swing", "knee", "air", "lean", "tilt")) + if(scaled[key]) + scaled[key] = scaled[key] * amount + .[part_id] = scaled + +/** + * One step of walking, or one pull of crawling when lying down. + * + * A step takes as long as the mob takes to cross however many tiles one stride covers, so the + * legs keep time with the actual movement speed, and long legs stride slower. Moves that land + * mid-stride just keep it going. + */ +/datum/limb_rig/proc/play_step() + if(activity == RIG_ACTIVITY_ONESHOT) + return // Let an emote or a swing finish; the next step catches up. + var/move_delay = get_move_delay() + if(activity == RIG_ACTIVITY_MOVING && world.time < step_ends_at - move_delay * 0.5) + // Still mid-stride. Put off standing still until this move is over too. + deltimer(settle_timer) + settle_timer = addtimer(CALLBACK(src, PROC_REF(settle)), step_ends_at - world.time + move_delay + 1, TIMER_STOPPABLE|TIMER_DELETE_ME) + return + var/lying = owner.body_position == LYING_DOWN + var/running = !lying && owner.move_intent == MOVE_INTENT_RUN + var/step_time = lying ? move_delay : move_delay * get_stride_tiles(running) + step_ends_at = world.time + step_time + left_foot_forward = !left_foot_forward + var/lead = left_foot_forward ? 1 : -1 + var/list/own_step = get_own_step(step_time, running, lying) + if(own_step) + play(own_step, activity = RIG_ACTIVITY_MOVING, settle_after = FALSE) + settle_timer = addtimer(CALLBACK(src, PROC_REF(settle)), max(step_time, move_delay) + 1, TIMER_STOPPABLE|TIMER_DELETE_ME) + return + var/list/stride + var/list/passing + var/list/keyframes + if(lying) + // Crawling: one arm reaches past the head and drags, the legs kick a little. + stride = list( + RIG_L_ARM = list("raise" = lead > 0 ? 165 : 70, "elbow" = lead > 0 ? 10 : 70), + RIG_R_ARM = list("raise" = lead > 0 ? 70 : 165, "elbow" = lead > 0 ? 70 : 10), + RIG_L_LEG = list("raise" = 12 * lead, "knee" = lead > 0 ? 50 : 10), + RIG_R_LEG = list("raise" = -12 * lead, "knee" = lead > 0 ? 10 : 50), + RIG_HEAD = list("nod" = -10), + ) + passing = list( + RIG_L_ARM = list("raise" = 110, "elbow" = 40), + RIG_R_ARM = list("raise" = 110, "elbow" = 40), + RIG_HEAD = list("nod" = -6), + RIG_CHEST = list("dx" = 1), + ) + else if(running) + // Running, after Kris's run in Deltarune, in the four poses a good run needs: + // contact (front leg reaching, nearly straight), down (the stomp: body drops and squashes, + // the planted knee gives), push (driving off) and up (off the ground, stretched out, + // trailing knee tucked). Slamming fast into the down pose and holding it for a moment is + // what gives it weight. + var/list/contact = list( + RIG_L_LEG = list("swing" = 40 * lead, "knee" = lead > 0 ? 5 : 60), + RIG_R_LEG = list("swing" = -40 * lead, "knee" = lead > 0 ? 60 : 5), + RIG_L_ARM = list("swing" = -50 * lead, "raise" = 6, "elbow" = 95), + RIG_R_ARM = list("swing" = 50 * lead, "raise" = 6, "elbow" = 95), + RIG_CHEST = list("bend" = 14, "lean" = 3 * lead), + RIG_HEAD = list("nod" = -10, "tilt" = -3 * lead), + ) + var/list/down = list( + RIG_L_LEG = list("swing" = lead > 0 ? 25 : -20, "knee" = lead > 0 ? 45 : 70), + RIG_R_LEG = list("swing" = lead > 0 ? -20 : 25, "knee" = lead > 0 ? 70 : 45), + RIG_L_ARM = list("swing" = -40 * lead, "raise" = 8, "elbow" = 105), + RIG_R_ARM = list("swing" = 40 * lead, "raise" = 8, "elbow" = 105), + RIG_CHEST = list("bend" = 20, "lean" = 4 * lead, "breath" = -0.07), + RIG_HEAD = list("nod" = 4, "tilt" = -4 * lead), + ) + var/list/push = list( + RIG_L_LEG = list("swing" = lead > 0 ? -15 : 40, "knee" = lead > 0 ? 10 : 110), + RIG_R_LEG = list("swing" = lead > 0 ? 40 : -15, "knee" = lead > 0 ? 110 : 10), + RIG_L_ARM = list("swing" = 10 * lead, "raise" = 6, "elbow" = 95), + RIG_R_ARM = list("swing" = -10 * lead, "raise" = 6, "elbow" = 95), + RIG_CHEST = list("bend" = 16, "air" = 1), + RIG_HEAD = list("nod" = -8), + ) + var/list/up = list( + RIG_L_LEG = list("swing" = lead > 0 ? -35 : 50, "knee" = lead > 0 ? 50 : 90), + RIG_R_LEG = list("swing" = lead > 0 ? 50 : -35, "knee" = lead > 0 ? 90 : 50), + RIG_L_ARM = list("swing" = 45 * lead, "raise" = 6, "elbow" = 90), + RIG_R_ARM = list("swing" = -45 * lead, "raise" = 6, "elbow" = 90), + RIG_CHEST = list("bend" = 12, "lean" = -3 * lead, "breath" = 0.04, "air" = 3), + RIG_HEAD = list("nod" = -12, "tilt" = 3 * lead), + ) + keyframes = list( + list(contact, step_time * 0.15, SINE_EASING | EASE_IN), + list(down, step_time * 0.2, CUBIC_EASING | EASE_OUT), + list(down, step_time * 0.1), + list(push, step_time * 0.2, QUAD_EASING | EASE_OUT), + list(up, step_time * 0.35, SINE_EASING | EASE_OUT), + ) + else if(walk_style == RIG_WALK_LOPE) + // Long legs: huge strides, the trailing knee hauled up high, the body swaying side to + // side and the arms swinging loose from the elbow. + stride = list( + RIG_L_LEG = list("swing" = 38 * lead, "knee" = lead > 0 ? 4 : 35), + RIG_R_LEG = list("swing" = -38 * lead, "knee" = lead > 0 ? 35 : 4), + RIG_L_ARM = list("swing" = -30 * lead, "raise" = 6, "elbow" = lead > 0 ? 5 : 50), + RIG_R_ARM = list("swing" = 30 * lead, "raise" = 6, "elbow" = lead > 0 ? 50 : 5), + RIG_CHEST = list("lean" = 7 * lead, "bend" = 4), + RIG_HEAD = list("tilt" = -8 * lead, "nod" = 4), + ) + passing = list( + RIG_L_LEG = list("swing" = lead > 0 ? 0 : 30, "knee" = lead > 0 ? 0 : 80), + RIG_R_LEG = list("swing" = lead > 0 ? 30 : 0, "knee" = lead > 0 ? 80 : 0), + RIG_L_ARM = list("raise" = 8, "elbow" = 30), + RIG_R_ARM = list("raise" = 8, "elbow" = 30), + RIG_CHEST = list("lean" = 2 * lead, "bend" = -2), + RIG_HEAD = list("nod" = -4), + ) + else + // Front leg planted almost straight, back leg pushing off with a bent knee. + stride = list( + RIG_L_LEG = list("swing" = 26 * lead, "knee" = lead > 0 ? 6 : 28), + RIG_R_LEG = list("swing" = -26 * lead, "knee" = lead > 0 ? 28 : 6), + RIG_L_ARM = list("swing" = -22 * lead, "raise" = 4, "elbow" = lead > 0 ? 8 : 30), + RIG_R_ARM = list("swing" = 22 * lead, "raise" = 4, "elbow" = lead > 0 ? 30 : 8), + RIG_CHEST = list("bend" = 3, "lean" = 2 * lead), + RIG_HEAD = list("tilt" = -2 * lead), + ) + // The trailing leg swings through with its knee lifted. + passing = list( + RIG_CHEST = list("dy" = 1, "bend" = 2), + RIG_L_LEG = list("swing" = lead > 0 ? 0 : 18, "knee" = lead > 0 ? 0 : 45), + RIG_R_LEG = list("swing" = lead > 0 ? 18 : 0, "knee" = lead > 0 ? 45 : 0), + RIG_L_ARM = list("raise" = 4, "elbow" = 15), + RIG_R_ARM = list("raise" = 4, "elbow" = 15), + ) + // A tail swings against the stride, and streams out flatter behind a run. + if(keyframes) + for(var/i in 1 to length(keyframes)) + var/list/keyframe = keyframes[i] + var/list/pose = keyframe[1] + pose[RIG_TAIL] = list("wag" = (i % 2 ? 10 : -10) * lead, "lift" = -12 + (i % 2) * 4) + else + stride[RIG_TAIL] = list("wag" = -9 * lead, "lift" = -3) + passing[RIG_TAIL] = list("wag" = 0, "lift" = 3) + if(!lying) + var/gait = get_gait_scale(running) + if(keyframes) + for(var/list/keyframe as anything in keyframes) + keyframe[1] = rig_scale_gait(keyframe[1], gait) + else + stride = rig_scale_gait(stride, gait) + passing = rig_scale_gait(passing, gait) + if(!keyframes) + keyframes = list(list(stride, step_time * 0.5), list(passing, step_time * 0.5)) + play(keyframes, activity = RIG_ACTIVITY_MOVING, settle_after = FALSE) + // Stand still again if no step follows. + settle_timer = addtimer(CALLBACK(src, PROC_REF(settle)), max(step_time, move_delay) + 1, TIMER_STOPPABLE|TIMER_DELETE_ME) + +/// A body's own keyframes for one step, or null to use the ones above. left_foot_forward says which step. +/datum/limb_rig/proc/get_own_step(step_time, running, lying) + return null + +/// A body's own looping keyframes for standing still, or null to just breathe. +/datum/limb_rig/proc/get_own_idle() + return null + +/// The working hand saws away until the progress bar is done. +/datum/limb_rig/proc/play_work() + var/arm = IS_RIGHT_INDEX(owner.active_hand_index) ? RIG_R_ARM : RIG_L_ARM + var/list/push = list(RIG_HEAD = list("nod" = 14), RIG_CHEST = list("bend" = 6)) + push[arm] = list("swing" = 35, "raise" = 12, "elbow" = 55) + var/list/pull = list(RIG_HEAD = list("nod" = 12), RIG_CHEST = list("bend" = 5)) + pull[arm] = list("swing" = 20, "raise" = 6, "elbow" = 95) + play(list(list(push, 3), list(pull, 3)), loop = -1, activity = RIG_ACTIVITY_WORKING) + +/// A quick swing of the active arm, for hitting things. +/datum/limb_rig/proc/play_attack() + var/arm = IS_RIGHT_INDEX(owner.active_hand_index) ? RIG_R_ARM : RIG_L_ARM + var/list/wind_up = list(RIG_CHEST = list("bend" = -6)) + wind_up[arm] = list("swing" = 100, "raise" = 15, "elbow" = 60) + var/list/follow_through = list(RIG_CHEST = list("bend" = 10)) + follow_through[arm] = list("swing" = 70, "raise" = 10, "elbow" = 0) + play(list(list(wind_up, 1), list(follow_through, 1), list(null, 2.5))) + +/// Acts out an emote, if there's an animation for it. +/datum/limb_rig/proc/play_emote(emote_key) + var/list/keyframes = get_rig_emote(emote_key) + if(keyframes) + play(keyframes) + +/// The keyframes for an emote, or null if it doesn't move anything. +/proc/get_rig_emote(emote_key) + switch(emote_key) + if("wave") + var/list/up = list(RIG_R_ARM = list("raise" = 120, "elbow" = 40), RIG_HEAD = list("tilt" = 6)) + var/list/over = list(RIG_R_ARM = list("raise" = 125, "elbow" = 0), RIG_HEAD = list("tilt" = 6)) + return list(list(up, 2), list(over, 1.5), list(up, 1.5), list(over, 1.5), list(up, 1.5), list(null, 2)) + if("clap") + var/list/apart = list(RIG_L_ARM = list("swing" = 35, "raise" = -5, "elbow" = 60), RIG_R_ARM = list("swing" = 35, "raise" = -5, "elbow" = 60)) + var/list/together = list(RIG_L_ARM = list("swing" = 35, "raise" = -25, "elbow" = 60), RIG_R_ARM = list("swing" = 35, "raise" = -25, "elbow" = 60)) + return list(list(apart, 1.5), list(together, 1), list(apart, 1), list(together, 1), list(apart, 1), list(together, 1), list(null, 2)) + if("shrug") + var/list/shrug = list(RIG_L_ARM = list("raise" = 35, "swing" = 35), RIG_R_ARM = list("raise" = 35, "swing" = 35), RIG_HEAD = list("tilt" = 12), RIG_CHEST = list("dy" = 1)) + return list(list(shrug, 2), list(shrug, 6), list(null, 3)) + if("nod") + var/list/down = list(RIG_HEAD = list("nod" = 22)) + return list(list(down, 1.5), list(null, 1.5), list(down, 1.5), list(null, 1.5)) + if("shake") + var/list/left = list(RIG_HEAD = list("tilt" = -14)) + var/list/right = list(RIG_HEAD = list("tilt" = 14)) + return list(list(left, 1), list(right, 1.5), list(left, 1.5), list(right, 1.5), list(null, 1)) + if("point") + var/list/point = list(RIG_R_ARM = list("swing" = 90, "raise" = 20), RIG_HEAD = list("nod" = -4)) + return list(list(point, 1.5), list(point, 12), list(null, 3)) + if("salute") + var/list/salute = list(RIG_R_ARM = list("raise" = 90, "swing" = 20, "elbow" = 130), RIG_CHEST = list("dy" = 1)) + return list(list(salute, 2), list(salute, 10), list(null, 3)) + if("bow", "curtsy") + var/list/bow = list(RIG_CHEST = list("bend" = 45), RIG_HEAD = list("nod" = 20), RIG_L_ARM = list("swing" = 20), RIG_R_ARM = list("swing" = 20)) + return list(list(bow, 4), list(bow, 6), list(null, 5)) + if("laugh", "giggle", "chuckle", "cackle", "snicker") + var/list/up = list(RIG_CHEST = list("bend" = -8, "dy" = 1), RIG_HEAD = list("nod" = -18)) + var/list/down = list(RIG_CHEST = list("bend" = 6), RIG_HEAD = list("nod" = -8)) + return list(list(up, 1.5), list(down, 1.5), list(up, 1.5), list(down, 1.5), list(up, 1.5), list(null, 2.5)) + if("cry", "sob", "whimper", "sniff") + // Face buried in both hands, elbows up and out so the forearms fold in over it, shoulders + // hitching with each sob: a sharp gulp in, a slow shudder out. + var/list/cry = list(RIG_HEAD = list("nod" = 22), RIG_L_ARM = list("swing" = 55, "raise" = 25, "hand_y" = 25), RIG_R_ARM = list("swing" = 55, "raise" = 25, "hand_y" = 25), RIG_CHEST = list("bend" = 10)) + var/list/sob = list(RIG_HEAD = list("nod" = 16), RIG_L_ARM = list("swing" = 58, "raise" = 27, "hand_y" = 26), RIG_R_ARM = list("swing" = 58, "raise" = 27, "hand_y" = 26), RIG_CHEST = list("bend" = 6, "breath" = 0.05, "dy" = 1)) + . = list(list(cry, 3)) + for(var/hitch in 1 to 3) + . += list(list(sob, 0.8, CUBIC_EASING | EASE_OUT), list(cry, 2.2, SINE_EASING | EASE_IN)) + . += list(list(null, 4)) + return . + if("scream", "screech", "shriek") + var/list/scream = list(RIG_L_ARM = list("raise" = 165), RIG_R_ARM = list("raise" = 165), RIG_HEAD = list("nod" = -25), RIG_CHEST = list("bend" = -10)) + return list(list(scream, 1.5), list(scream, 8), list(null, 4)) + if("jump") + var/list/crouch = list(RIG_L_LEG = list("swing" = 45, "knee" = 80), RIG_R_LEG = list("swing" = 45, "knee" = 80), RIG_CHEST = list("dy" = -3, "bend" = 15), RIG_L_ARM = list("swing" = -30), RIG_R_ARM = list("swing" = -30)) + var/list/air = list(RIG_L_ARM = list("raise" = 60), RIG_R_ARM = list("raise" = 60), RIG_L_LEG = list("swing" = -10), RIG_R_LEG = list("swing" = -10)) + return list(list(crouch, 1.5), list(air, 1.5), list(crouch, 1.5), list(null, 2)) + if("dance") + . = list() + for(var/move in 1 to 10) + . += list(list(list( + RIG_L_ARM = list("raise" = rand(-20, 175), "swing" = rand(-40, 90), "elbow" = rand(0, 120)), + RIG_R_ARM = list("raise" = rand(-20, 175), "swing" = rand(-40, 90), "elbow" = rand(0, 120)), + RIG_L_LEG = list("raise" = rand(-10, 35), "swing" = rand(-30, 40), "knee" = rand(0, 70)), + RIG_R_LEG = list("raise" = rand(-10, 35), "swing" = rand(-30, 40), "knee" = rand(0, 70)), + RIG_CHEST = list("bend" = rand(-15, 20), "lean" = rand(-15, 15), "dy" = rand(0, 2)), + RIG_HEAD = list("nod" = rand(-25, 25), "tilt" = rand(-20, 20)), + ), 2.5)) + . += list(list(null, 3)) + return . + if("wiggle") + var/list/a = list(RIG_L_ARM = list("swing" = 45, "raise" = -6), RIG_R_ARM = list("swing" = 45, "raise" = -6)) + var/list/b = list(RIG_L_ARM = list("swing" = 50, "raise" = 4), RIG_R_ARM = list("swing" = 50, "raise" = 4)) + return list(list(a, 1.5), list(b, 1), list(a, 1), list(b, 1), list(a, 1), list(b, 1), list(null, 2)) + if("crack") + var/list/crack = list(RIG_L_ARM = list("swing" = 85, "raise" = -30), RIG_R_ARM = list("swing" = 85, "raise" = -30), RIG_CHEST = list("bend" = 5)) + var/list/pop = list(RIG_L_ARM = list("swing" = 90, "raise" = -34), RIG_R_ARM = list("swing" = 90, "raise" = -34), RIG_CHEST = list("bend" = 7)) + return list(list(crack, 2), list(pop, 0.5), list(crack, 0.5), list(pop, 0.5), list(null, 2)) + if("snap") + var/list/snap = list(RIG_R_ARM = list("raise" = 70, "swing" = 60)) + var/list/flick = list(RIG_R_ARM = list("raise" = 80, "swing" = 70)) + return list(list(snap, 1.5), list(flick, 0.5), list(null, 2)) + if("facepalm") + var/list/palm = list(RIG_R_ARM = list("swing" = 55, "raise" = 22, "hand_y" = 26), RIG_HEAD = list("nod" = 20), RIG_CHEST = list("bend" = 4)) + return list(list(palm, 1.5), list(palm, 8), list(null, 3)) + if("stretch", "yawn") + var/list/stretch = list(RIG_L_ARM = list("raise" = 170), RIG_R_ARM = list("raise" = 170), RIG_CHEST = list("bend" = -10, "breath" = 0.05), RIG_HEAD = list("nod" = -20)) + return list(list(stretch, 4), list(stretch, 5), list(null, 4)) + if("sigh") + var/list/sigh = list(RIG_CHEST = list("dy" = -1, "bend" = 6), RIG_HEAD = list("nod" = 18), RIG_L_ARM = list("swing" = -6), RIG_R_ARM = list("swing" = -6)) + return list(list(sigh, 5), list(null, 5)) + if("cough", "sneeze") + // Doubled over, a fist up at the mouth. + var/list/hack = list(RIG_CHEST = list("bend" = 18), RIG_HEAD = list("nod" = 25), RIG_R_ARM = list("swing" = 55, "raise" = 25, "hand_y" = 24)) + return list(list(hack, 1), list(null, 1.5), list(hack, 1), list(null, 2)) + if("shiver", "tremble", "twitch", "twitch_s", "shudder") + . = list() + for(var/shake in 1 to 8) + . += list(list(list( + RIG_CHEST = list("lean" = rand(-6, 6)), + RIG_HEAD = list("tilt" = rand(-8, 8)), + RIG_L_ARM = list("raise" = rand(-8, 10)), + RIG_R_ARM = list("raise" = rand(-8, 10)), + ), 0.6)) + . += list(list(null, 1.5)) + return . + if("flap", "aflap") + var/list/up = list(RIG_L_ARM = list("raise" = 120), RIG_R_ARM = list("raise" = 120)) + var/list/down = list(RIG_L_ARM = list("raise" = 30), RIG_R_ARM = list("raise" = 30)) + return list(list(up, 1), list(down, 1), list(up, 1), list(down, 1), list(null, 1.5)) + if("hug") + var/list/hug = list(RIG_L_ARM = list("swing" = 70, "raise" = -20, "elbow" = 50), RIG_R_ARM = list("swing" = 70, "raise" = -20, "elbow" = 50), RIG_CHEST = list("bend" = 8)) + return list(list(hug, 2), list(hug, 6), list(null, 3)) + if("dab") + var/list/dab = list(RIG_L_ARM = list("raise" = 150), RIG_R_ARM = list("raise" = 115, "swing" = 70), RIG_HEAD = list("nod" = 30, "tilt" = 20)) + return list(list(dab, 1), list(dab, 8), list(null, 3)) + if("glare", "frown", "grumble", "huff") + var/list/glare = list(RIG_HEAD = list("nod" = 10), RIG_CHEST = list("bend" = 4)) + return list(list(glare, 2), list(glare, 5), list(null, 3)) + if("gasp", "choke") + // Hand flying up to the mouth, head jerking back. + var/list/gasp = list(RIG_HEAD = list("nod" = -18), RIG_CHEST = list("bend" = -6, "breath" = 0.06), RIG_R_ARM = list("swing" = 50, "raise" = 25, "hand_y" = 24)) + return list(list(gasp, 1), list(gasp, 5), list(null, 3)) + if("collapse", "faint") + var/list/slump = list(RIG_CHEST = list("bend" = 25, "dy" = -2), RIG_HEAD = list("nod" = 30), RIG_L_ARM = list("raise" = 20), RIG_R_ARM = list("raise" = 20)) + return list(list(slump, 3), list(null, 3)) + return null + +#undef RIG_ELBOW_Y +#undef RIG_HAND_Y +#undef RIG_KNEE_Y +#undef RIG_SOLE_Y diff --git a/voidcrew/modules/limb_rig/humanoid_rig.dm b/voidcrew/modules/limb_rig/humanoid_rig.dm new file mode 100644 index 00000000000..edec1c3f6d1 --- /dev/null +++ b/voidcrew/modules/limb_rig/humanoid_rig.dm @@ -0,0 +1,216 @@ +/** + * The body every roundstart species is drawn with: a generated, shaded body a quarter bigger than + * a human, built piece by piece like the Experiment's (see sprite_rig.dm), so an arm or leg moves + * as a whole limb rather than a cut-out of a flat sprite. + * + * What makes the species stays their own. The body is tinted to each limb's colour (skin tone, + * scales, fur; prosthetics come out their own colour). The real head goes on top, with its hair, + * eyes, snout, horns, frills, ears and antennae, stretched onto the bigger body like a hat. Tails + * come off the torso onto the tail bone, wings onto their own piece behind the back, and markings + * and spines onto the torso like a shirt. + */ +/datum/limb_rig/sprites/humanoid + /// Wings off the torso, unmasked, stretched onto the body like the shirt is. Hung on the mob, not + /// the upper body, so they can go behind the legs. + var/obj/effect/abstract/limb_rig_part/wings_part + /// The head, as the species draws it, on the head's cloth piece. + var/list/head_images + /// Markings and spines off the torso, on the torso's cloth piece. + var/list/chest_marks + /// The body sprite on each piece, by piece id. + var/list/skin_images = list() + /// How much deeper (or shallower) clothes are drawn on the torso side-on. torso_width is front-on. + var/torso_depth = 1 + +/datum/limb_rig/sprites/humanoid/refresh_shape() + . = ..() + var/mob/living/carbon/human/human_owner = owner + torso_depth = human_owner.dna.species.limb_rig_shape["torso_depth"] || torso_width + +/** + * Clothes are stretched from human bones, but a human limb's joint isn't the middle of the limb + * (a human arm hangs a pixel or so outside its shoulder). Line the middle of each human sleeve and + * trouser leg up with the middle of this body's limb instead, and draw the torso side-on to its + * own depth. + */ +/datum/limb_rig/sprites/humanoid/get_cloth_map(part_id, facing) + var/matrix/map = ..() + var/facing_name = dir2text(facing) + if(part_id == RIG_CHEST && (facing & (EAST|WEST))) + var/list/hips = skeleton[facing_name][RIG_CHEST] + map.Translate(16.5 - hips[1], 0) + map.Scale(torso_depth / torso_width, 1) + map.Translate(hips[1] - 16.5, 0) + var/middle = get_human_limb_middles()[facing_name][part_id] + if(isnull(middle)) + return map + var/side = copytext(part_id, 1, 2) + var/segment = copytext(part_id, 3) + var/is_arm = findtext(segment, "arm") + var/list/root = part_id == RIG_CHEST ? list(16, 0) : get_rig_joint(side == "l" ? (is_arm ? RIG_L_ARM : RIG_L_LEG) : (is_arm ? RIG_R_ARM : RIG_R_LEG), facing) + var/width = part_id == RIG_CHEST ? ((facing & (EAST|WEST)) ? torso_depth : torso_width) : (cloth_widths?[segment] || 1) + map.Translate(-(middle - root[1]) * width, 0) + return map + +/// Where the middle of each piece of a human is across, by direction then piece: sleeves and +/// trouser legs are cut around these. Arms hidden behind the body aren't moved. +/proc/get_human_limb_middles() + var/static/list/middles = list( + "south" = list("l_arm" = 22, "r_arm" = 10, "l_forearm" = 22.5, "r_forearm" = 9.5, "l_thigh" = 18.5, "r_thigh" = 13.5, "l_shin" = 19.1, "r_shin" = 12.9), + "north" = list("l_arm" = 10, "r_arm" = 22, "l_forearm" = 9.5, "r_forearm" = 22.5, "l_thigh" = 13.5, "r_thigh" = 18.5, "l_shin" = 12.9, "r_shin" = 19.1), + "east" = list("r_arm" = 13.25, "r_forearm" = 14.5, "l_thigh" = 16.5, "r_thigh" = 15.8, "l_shin" = 17.1, "r_shin" = 17.1, RIG_CHEST = 16.1), + "west" = list("l_arm" = 19.75, "l_forearm" = 18.5, "l_thigh" = 17.2, "r_thigh" = 16.5, "l_shin" = 16.9, "r_shin" = 16.9, RIG_CHEST = 16.9), + ) + return middles + +/datum/limb_rig/sprites/humanoid/build_pieces() + . = ..() + wings_part = new_part(RIG_CHEST) + hang_on_owner(wings_part) + // The tail as the species draws it, carried on the tail bone. + tail_part = new_part(RIG_TAIL) + hang_on_owner(tail_part) + +// The body sprite goes on in refresh_skin(), tinted. +/datum/limb_rig/sprites/humanoid/refresh_sprite_image(part_id) + return + +/datum/limb_rig/sprites/humanoid/set_sprite_saturation(value) + if(sprite_saturation == value) + return + sprite_saturation = value + if(length(parts)) + refresh_skin() + +/datum/limb_rig/sprites/humanoid/Destroy() + . = ..() + QDEL_NULL(wings_part) + head_images = null + chest_marks = null + skin_images = null + +/datum/limb_rig/sprites/humanoid/set_layer(cache_index, standing) + . = ..() + if(cache_index == BODYPARTS_LAYER) + refresh_species_parts() + +/datum/limb_rig/sprites/humanoid/refresh_limbs() + . = ..() + refresh_skin() + +/// Puts each piece's body sprite on, tinted to its limb. +/datum/limb_rig/sprites/humanoid/proc/refresh_skin() + var/list/zones = get_rig_piece_zones() + var/mob/living/carbon/human/human_owner = owner + var/female = istype(human_owner) && human_owner.physique == FEMALE + for(var/part_id in zones) + var/obj/effect/abstract/limb_rig_part/part = parts[part_id] + part.cut_overlay(skin_images[part_id]) + var/image/skin = image(sprite_icon, (part_id == RIG_CHEST && female) ? "chest_f" : part_id) + skin.pixel_w = -16 + var/obj/item/bodypart/limb = owner.get_bodypart(zones[part_id]) + skin.color = get_drained_colour(limb?.get_rig_skin_colour(), sprite_saturation) + part.add_overlay(skin) + skin_images[part_id] = skin + +/// A tint with only this much of its colour left (0 to 1), as a colour if it was one, else a matrix. +/proc/get_drained_colour(colour, saturation) + if(saturation >= 1) + return colour + if(!istext(colour)) + return color_matrix_saturation(saturation) + var/list/rgb = rgb2num(colour) + var/grey = rgb[1] * 0.3 + rgb[2] * 0.59 + rgb[3] * 0.11 + return rgb(grey + (rgb[1] - grey) * saturation, grey + (rgb[2] - grey) * saturation, grey + (rgb[3] - grey) * saturation) + +/// Takes the head, tail, wings and markings from the species' own limb sprites. +/datum/limb_rig/sprites/humanoid/proc/refresh_species_parts() + var/obj/effect/abstract/limb_rig_part/head_cloth = cloth_parts[RIG_HEAD] + var/obj/effect/abstract/limb_rig_part/chest_cloth = cloth_parts[RIG_CHEST] + head_cloth.cut_overlay(head_images) + chest_cloth.cut_overlay(chest_marks) + wings_part.cut_overlays() + tail_part.cut_overlays() + head_images = get_limb_images(BODY_ZONE_HEAD) + head_cloth.add_overlay(head_images) + + chest_marks = list() + var/list/wings = list() + var/list/tail = list() + var/obj/item/bodypart/chest/chest = owner.get_bodypart(BODY_ZONE_CHEST) + if(!chest) + return + for(var/image/overlay as anything in get_limb_images(BODY_ZONE_CHEST)) + var/state = overlay.icon_state + // The chest sprite itself (and its emissive blocker): the body is drawn instead. + if(!istext(state) || findtext(state, "[chest.limb_id]_chest") == 1) + continue + if(findtext(state, "tail")) + tail += overlay + else if(findtext(state, "wing")) + wings += overlay + else + chest_marks += overlay + chest_cloth.add_overlay(chest_marks) + wings_part.add_overlay(wings) + tail_part.add_overlay(tail) + +/// The images a limb is drawn with, as the mob last drew it. +/datum/limb_rig/sprites/humanoid/proc/get_limb_images(zone) + var/key = owner.icon_render_keys[zone] + var/list/images = key && owner.limb_icon_cache[key] + return images ? images.Copy() : list() + +/datum/limb_rig/sprites/humanoid/refresh_facing() + . = ..() + var/facing = get_facing() + // Behind everything, legs included, unless the back is what's seen. + wings_part.layer = facing == NORTH ? -1 : -10 + tail_part.layer = parts[RIG_TAIL].layer + 0.1 + sort_pieces() + +// A species' own head, and its face, are drawn on the head's cloth piece. +/datum/limb_rig/sprites/humanoid/get_face_anchor() + return cloth_parts[RIG_HEAD] + +/datum/limb_rig/sprites/humanoid/get_pose_matrices(list/pose, facing) + . = ..() + .[wings_part] = .[cloth_parts[RIG_CHEST]] * .[pivot] + // From where a human's tail joins on to this body's tail bone, then swung with it. + var/list/root = get_tail_root(facing) + var/list/bone = skeleton[dir2text(facing)][RIG_TAIL] + .[tail_part] = rig_joint_matrix(root[1], root[2], hat_scale, 0, bone[1], bone[2], hat_scale) * .[parts[RIG_TAIL]] + +/** + * The colour a limb's skin is, to tint the body with: what the species colours it, or for a limb + * drawn in its own colours (moths, plasmamen, prosthetics), the average of its sprite. + */ +/obj/item/bodypart/proc/get_rig_skin_colour() + if(should_draw_greyscale && draw_color) + return draw_color + var/sprite_icon = (should_draw_greyscale && icon_greyscale) ? icon_greyscale : icon_static + var/sprite_state = is_dimorphic ? "[limb_id]_[body_zone]_[limb_gender]" : "[limb_id]_[body_zone]" + var/static/list/colours = list() + var/key = "[sprite_icon]-[sprite_state]" + if(!isnull(colours[key])) + return colours[key] + var/icon/sprite = icon(sprite_icon, sprite_state, SOUTH) + var/red = 0 + var/green = 0 + var/blue = 0 + var/count = 0 + for(var/x in 1 to 32) + for(var/y in 1 to 32) + var/pixel = sprite.GetPixel(x, y) + if(!pixel) + continue + var/list/channels = rgb2num(pixel) + if(length(channels) > 3 && channels[4] < 128) + continue + red += channels[1] + green += channels[2] + blue += channels[3] + count++ + // Lighten it a little: the body's shading darkens it again, and averages come out dull. + colours[key] = count ? rgb(min(red / count * 1.1, 255), min(green / count * 1.1, 255), min(blue / count * 1.1, 255)) : "#ffffff" + return colours[key] diff --git a/voidcrew/modules/limb_rig/icons/humanoid.dmi b/voidcrew/modules/limb_rig/icons/humanoid.dmi new file mode 100644 index 00000000000..5073c561891 Binary files /dev/null and b/voidcrew/modules/limb_rig/icons/humanoid.dmi differ diff --git a/voidcrew/modules/limb_rig/icons/humanoid_cloth_masks.dmi b/voidcrew/modules/limb_rig/icons/humanoid_cloth_masks.dmi new file mode 100644 index 00000000000..27fa9dbaac3 Binary files /dev/null and b/voidcrew/modules/limb_rig/icons/humanoid_cloth_masks.dmi differ diff --git a/voidcrew/modules/limb_rig/icons/rig_masks.dmi b/voidcrew/modules/limb_rig/icons/rig_masks.dmi new file mode 100644 index 00000000000..818fe37b7f7 Binary files /dev/null and b/voidcrew/modules/limb_rig/icons/rig_masks.dmi differ diff --git a/voidcrew/modules/limb_rig/limb_rig.dm b/voidcrew/modules/limb_rig/limb_rig.dm new file mode 100644 index 00000000000..aa91822cc07 --- /dev/null +++ b/voidcrew/modules/limb_rig/limb_rig.dm @@ -0,0 +1,533 @@ +/** + * Limb rig. + * + * Every living, human-shaped carbon with the Overanimated quirk (quirk.dm) has its sprite cut + * into moving pieces: head, torso, both arms, both legs, and whatever each hand is holding. The pieces then walk, crawl, + * breathe, work tools, swing at things and act out emotes (animations.dm). + * + * How the cut works: a human is drawn entirely from overlays_standing. While rigged, those + * layers go onto the pieces instead of the mob. Every piece gets every layer, and a + * per-direction alpha mask (icons/rig_masks.dmi) keeps only its own region, so clothes, + * hair and skin split along the same lines as the body. Held items skip the masks and ride on + * their own pieces that move with the arm, so a long gun stays whole. + * + * The pieces are vis_contents, so they follow the mob, turn with it, pass clicks through + * to it, and inherit its transform when it lies down. Arms and legs are two pieces each + * (upper arm and forearm, thigh and shin). The head and arms sit inside a torso pivot, so + * bending or breathing the torso carries them along; the legs hang off the mob directly. + * + * Idea and joint positions from the dancing PR, tgstation/tgstation#95581. That PR paints + * each dancer's clothes into new icons every time; this masks the existing overlays instead, + * so it's cheap enough to leave on everyone. + */ + +#define RIG_MASKS 'voidcrew/modules/limb_rig/icons/rig_masks.dmi' +/// How far across the torso the side-on arm hole is patched from, in pixels. +#define RIG_PATCH_SHIFT 4 + +/// One cut-out piece of a rigged mob. +/obj/effect/abstract/limb_rig_part + name = "" + appearance_flags = KEEP_TOGETHER|PIXEL_SCALE|TILE_BOUND + vis_flags = VIS_INHERIT_ID|VIS_INHERIT_DIR|VIS_INHERIT_PLANE + layer = FLOAT_LAYER + /// Which piece of the body this is, one of the RIG_ defines. Held item pieces use their arm's. + var/part_id + +/datum/limb_rig + /// The mob being cut up. + var/mob/living/carbon/owner + /// Invisible piece at the hips that the whole upper body hangs off. + var/obj/effect/abstract/limb_rig_part/pivot + /// Pieces that show a masked copy of the body, by part id. RIG_CHEST is the torso inside the pivot. + var/list/obj/effect/abstract/limb_rig_part/parts = list() + /// Unmasked pieces holding each hand's held item, "l" and "r". + var/list/obj/effect/abstract/limb_rig_part/item_parts = list() + /// Inside each of them, what the held item's actually drawn on, so it can be twirled about the + /// hand (see spin_held_item()) while the hand goes on moving. + var/list/obj/effect/abstract/limb_rig_part/item_spinners = list() + /// Kinds of held item not drawn in the hands for now (see set_hidden_held()). + var/list/hidden_held_types + /// The overlays_standing layers currently copied onto the pieces, by cache index. + var/list/mirrored_layers = list() + /// What the rig is currently doing, a RIG_ACTIVITY_ define. + var/activity = RIG_ACTIVITY_IDLE + /// The pose the rig is holding (or animating toward), used to snap it right after turning. + var/list/held_pose + /// Nested do_afters in progress. Tool work animates for as long as this is above zero. + var/working = 0 + /// Which leg leads on the next step. + var/left_foot_forward = FALSE + /// When the step in progress finishes, in world.time. + var/step_ends_at = 0 + /// Timer that puts the rig back to its idle loop after a one-off animation or a walk. + var/settle_timer + /// How much longer than the sprite the arms and legs are drawn. + var/arm_stretch = RIG_ARM_STRETCH + var/leg_stretch = RIG_LEG_STRETCH + /// How much bigger than the sprite the head (and whatever is on it) is drawn. + var/head_scale = 1 + /// Angles added to every pose, from the species. + var/list/posture + /// How this body walks, a RIG_WALK_ define. + var/walk_style = RIG_WALK_NORMAL + /// Pieces hung directly on the mob (the rest hang off the pivot), to take back off. + var/list/obj/effect/abstract/limb_rig_part/owner_pieces = list() + /// A tail, if the body has one: taken out of the body sprite so it can swing on its own. + var/obj/effect/abstract/limb_rig_part/tail_part + /// How long a step takes, in deciseconds, when something's moving the mob at its own pace (a + /// scripted run) rather than at the mob's speed. Null for the mob's speed. + var/step_delay_override + /// Side-on, covers the hole cutting the near arm out of the torso leaves, with a bit of the torso + /// beside it. Holds chest_patch_fill, a copy of the body nudged across so the torso lines up. + var/obj/effect/abstract/limb_rig_part/chest_patch + var/obj/effect/abstract/limb_rig_part/chest_patch_fill + +/datum/limb_rig/New(mob/living/carbon/owner) + src.owner = owner + refresh_shape() + build_pieces() + + // Take the body off the mob and put it on the pieces. + for(var/cache_index in 1 to length(owner.overlays_standing)) + var/standing = owner.overlays_standing[cache_index] + if(!standing) + continue + owner.cut_overlay(standing) + set_layer(cache_index, standing) + + RegisterSignal(owner, COMSIG_ATOM_POST_DIR_CHANGE, PROC_REF(on_dir_change)) + RegisterSignal(owner, COMSIG_MOVABLE_MOVED, PROC_REF(on_moved)) + RegisterSignal(owner, COMSIG_DO_AFTER_BEGAN, PROC_REF(on_work_began)) + RegisterSignal(owner, COMSIG_DO_AFTER_ENDED, PROC_REF(on_work_ended)) + RegisterSignals(owner, list(COMSIG_MOB_STATCHANGE, COMSIG_LIVING_SET_BODY_POSITION), PROC_REF(on_posture_change)) + RegisterSignal(owner, COMSIG_MOB_EMOTE, PROC_REF(on_emote)) + refresh_facing() + settle() + +/datum/limb_rig/Destroy() + UnregisterSignal(owner, list( + COMSIG_ATOM_POST_DIR_CHANGE, + COMSIG_MOVABLE_MOVED, + COMSIG_DO_AFTER_BEGAN, + COMSIG_DO_AFTER_ENDED, + COMSIG_MOB_STATCHANGE, + COMSIG_LIVING_SET_BODY_POSITION, + COMSIG_MOB_EMOTE, + )) + deltimer(settle_timer) + QDEL_NULL(physics) + // Give the body back to the mob, as it stands now. + if(owner.limb_rig == src) + owner.limb_rig = null + for(var/cache_index in 1 to length(owner.overlays_standing)) + var/standing = owner.overlays_standing[cache_index] + if(standing) + owner.add_overlay(standing) + owner.vis_contents -= owner_pieces + owner_pieces = null + QDEL_LIST_ASSOC_VAL(parts) + QDEL_LIST_ASSOC_VAL(item_spinners) + QDEL_LIST_ASSOC_VAL(item_parts) + QDEL_NULL(pivot) + QDEL_NULL(tail_part) + QDEL_NULL(chest_patch_fill) + QDEL_NULL(chest_patch) + mirrored_layers = null + owner = null + return ..() + +/// Takes the limb lengths, head size, posture and walk from the species. +/datum/limb_rig/proc/refresh_shape() + var/mob/living/carbon/human/human_owner = owner + var/list/shape = istype(human_owner) ? human_owner.dna?.species?.limb_rig_shape : null + arm_stretch = shape?["arm_stretch"] || RIG_ARM_STRETCH + leg_stretch = shape?["leg_stretch"] || RIG_LEG_STRETCH + head_scale = shape?["head_scale"] || 1 + posture = shape?["posture"] + walk_style = shape?["walk"] || RIG_WALK_NORMAL + +/// Makes the pieces and hangs them up: the upper body off the pivot, the legs off the mob. +/datum/limb_rig/proc/build_pieces() + pivot = new_part(RIG_CHEST) + hang_on_owner(pivot) + for(var/part_id in list(RIG_CHEST, RIG_HEAD, RIG_L_ARM, RIG_R_ARM, "l_forearm", "r_forearm")) + parts[part_id] = new_part(part_id) + pivot.vis_contents += parts[part_id] + for(var/side in list("l", "r")) + item_parts[side] = new_part(side == "l" ? RIG_L_ARM : RIG_R_ARM) + pivot.vis_contents += item_parts[side] + for(var/part_id in list(RIG_L_LEG, RIG_R_LEG, "l_shin", "r_shin")) + parts[part_id] = new_part(part_id) + hang_on_owner(parts[part_id]) + // On the mob rather than the pivot, so it can go behind the legs. It follows the torso anyway. + tail_part = new_part(RIG_TAIL) + hang_on_owner(tail_part) + chest_patch = new_part(RIG_CHEST) + chest_patch_fill = new_part(RIG_CHEST) + chest_patch.vis_contents += chest_patch_fill + pivot.vis_contents += chest_patch + +/** + * Drains the colour from the body's own sprites, if it's drawn from them (see sprite_rig.dm), and + * only them: whatever's drawn over them keeps its own. 0 for grey to 1 for as they are. + */ +/datum/limb_rig/proc/set_sprite_saturation(value) + return + +/datum/limb_rig/proc/hang_on_owner(obj/effect/abstract/limb_rig_part/part) + owner.vis_contents += part + owner_pieces += part + +/datum/limb_rig/proc/new_part(part_id) + var/obj/effect/abstract/limb_rig_part/part = new + part.part_id = part_id + return part + +/// Copies one overlays_standing layer onto the pieces, replacing what was there for that layer. +/// A tail (and its spines) comes out of the body sprite onto the tail piece, unmasked. +/datum/limb_rig/proc/set_layer(cache_index, standing) + if(cache_index == HANDS_LAYER) + refresh_held_items() + return + var/key = "[cache_index]" + var/list/old = mirrored_layers[key] + if(old) + for(var/part_id in parts) + var/obj/effect/abstract/limb_rig_part/part = parts[part_id] + part.cut_overlay(old) + tail_part?.cut_overlay(old) + chest_patch_fill?.cut_overlay(old) + mirrored_layers -= key + if(!standing) + return + var/list/body = list() + var/list/tail = list() + for(var/image/overlay as anything in (islist(standing) ? standing : list(standing))) + if(tail_part && cache_index == BODYPARTS_LAYER && istext(overlay.icon_state) && findtext(overlay.icon_state, "tail")) + tail += overlay + else + body += overlay + for(var/part_id in parts) + var/obj/effect/abstract/limb_rig_part/part = parts[part_id] + part.add_overlay(body) + chest_patch_fill?.add_overlay(body) + tail_part?.add_overlay(tail) + mirrored_layers[key] = body + tail + +/// Rebuilds each hand's held item piece. Held items aren't masked, so a long gun stays whole. +/datum/limb_rig/proc/refresh_held_items() + for(var/side in item_parts) + var/obj/effect/abstract/limb_rig_part/part = get_item_spinner(side) + part.cut_overlays() + if(owner.handcuffed) + return + for(var/obj/item/held in owner.held_items) + if(hidden_held_types && is_type_in_list(held, hidden_held_types)) + continue + var/hand_index = owner.get_held_index_of_item(held) + var/right = IS_RIGHT_INDEX(hand_index) + var/obj/effect/abstract/limb_rig_part/part = get_item_spinner(right ? "r" : "l") + part.add_overlay(held.build_worn_icon( + default_layer = HANDS_LAYER, + default_icon_file = right ? held.righthand_file : held.lefthand_file, + isinhands = TRUE, + )) + +/// Stops drawing these kinds of held item in the hands (they're still held), or (with null) draws them all again. +/datum/limb_rig/proc/set_hidden_held(list/types) + if(types ~= hidden_held_types) + return + hidden_held_types = types + refresh_held_items() + +/// What one hand's held item is drawn on (see item_spinners). +/datum/limb_rig/proc/get_item_spinner(side) + var/obj/effect/abstract/limb_rig_part/spinner = item_spinners[side] + if(!spinner && item_parts[side]) + spinner = new_part(side == "l" ? RIG_L_ARM : RIG_R_ARM) + item_parts[side].vis_contents += spinner + item_spinners[side] = spinner + return spinner + +/** + * Twirls whatever one hand ("l" or "r") holds right round about the hand, turns times over time + * deciseconds, the way Pico spins his gun. + */ +/datum/limb_rig/proc/spin_held_item(side, time = 3, turns = 1) + var/obj/effect/abstract/limb_rig_part/spinner = item_spinners[side] + if(!spinner) + return + // Held items are drawn for a human hand: hanging under the shoulder, at hand height. + var/list/shoulder = get_rig_joint(side == "l" ? RIG_L_ARM : RIG_R_ARM, get_facing()) + var/offset_x = shoulder[1] - 16 + var/offset_y = 12.5 - 16 + // Spun forward, the way the hand faces. + var/way = get_facing() == WEST ? -1 : 1 + var/steps = max(round(turns * 3), 1) + for(var/i in 1 to steps) + var/matrix/turned = matrix() + turned.Translate(-offset_x, -offset_y) + turned.Turn(way * 120 * i) + turned.Translate(offset_x, offset_y) + if(i == 1) + animate(spinner, transform = turned, time = time / steps) + else + animate(transform = turned, time = time / steps) + animate(transform = matrix(), time = 0) + +/// Redraws wounds a body draws for itself, rather than the mob's damage overlay. Most don't. +/datum/limb_rig/proc/refresh_damage_marks() + return + +/// Everything the rig took off the mob, for things that copy the mob's look (mirrors). +/datum/limb_rig/proc/get_body_overlays() + . = list() + for(var/standing in owner.overlays_standing) + if(standing) + . += standing + +/// Which of the four ways the mob faces. Facing a diagonal draws a four-direction sprite side-on, +/// so northeast and southeast count as east, and the same for west. +/datum/limb_rig/proc/get_facing() + var/facing = owner.dir + if(facing & EAST) + return EAST + if(facing & WEST) + return WEST + return facing || SOUTH + +/// Matches the masks and the draw order to the way the mob is facing. +/datum/limb_rig/proc/refresh_facing() + var/facing = get_facing() + var/static/list/torso_and_arms = list("torso_cut", "l_arm", "l_forearm", "r_arm", "r_forearm") + for(var/part_id in parts) + var/obj/effect/abstract/limb_rig_part/part = parts[part_id] + var/mask_state = part_id + var/flags = NONE + if(part_id == RIG_HEAD) + mask_state = "head_cut" + flags = MASK_INVERSE + else if(part_id == RIG_CHEST) + mask_state = "torso_cut" + flags = MASK_INVERSE + var/icon/mask = get_rig_mask(mask_state, facing) + // Side-on the arms lie over the torso, so the torso's copy of them would stay put when they + // move (a third arm glued to the side). Cut them out of it too. + if(part_id == RIG_CHEST && (facing & (EAST|WEST))) + mask = get_rig_mask_union(torso_and_arms, facing) + part.add_filter("limb_rig_mask", 1, alpha_mask_filter(icon = mask, flags = flags)) + // The near arm's hole gets filled with the torso just in front of it (the torso alone, nudged + // across, and cut to the hole). + if(chest_patch) + chest_patch_fill.add_filter("limb_rig_mask", 1, alpha_mask_filter(icon = get_rig_mask_union(torso_and_arms, facing), flags = MASK_INVERSE)) + var/near_side = facing == EAST ? "r" : "l" + chest_patch.alpha = (facing & (EAST|WEST)) ? 255 : 0 + chest_patch.add_filter("limb_rig_mask", 1, alpha_mask_filter(icon = get_rig_mask_union(list("[near_side]_arm", "[near_side]_forearm"), facing))) + chest_patch_fill.pixel_w = facing == EAST ? -RIG_PATCH_SHIFT : RIG_PATCH_SHIFT + + // Seen side-on, the arm on the far side goes behind the torso. Seen from behind, both do: + // anything the arms do in front of the body happens on the other side of the spine. + var/far_side = facing == EAST ? "l" : (facing == WEST ? "r" : null) + pivot.layer = -2 + for(var/part_id in list(RIG_L_LEG, RIG_R_LEG, "l_shin", "r_shin")) + parts[part_id].layer = -3 + parts[RIG_CHEST].layer = -5 + chest_patch?.layer = -4.9 + parts[RIG_HEAD].layer = -4 + if(tail_part) + // Behind everything, unless it's seen from behind. + tail_part.layer = facing == NORTH ? -1 : -9 + for(var/side in list("l", "r")) + var/obj/effect/abstract/limb_rig_part/arm = parts[side == "l" ? RIG_L_ARM : RIG_R_ARM] + var/obj/effect/abstract/limb_rig_part/item = item_parts[side] + var/obj/effect/abstract/limb_rig_part/forearm = parts["[side]_forearm"] + var/behind = (side == far_side || facing == NORTH) + arm.layer = behind ? -7 : -3 + forearm.layer = arm.layer + 0.2 + item.layer = behind ? -6 : -2 + sort_pieces() + +/// Pieces draw in the order they sit in vis_contents, whatever their layers say, so every time +/// the layers change, put the pieces in layer order: lowest first, drawn at the back. +/datum/limb_rig/proc/sort_pieces() + for(var/atom/movable/container as anything in list(owner, pivot)) + var/list/pieces = list() + for(var/obj/effect/abstract/limb_rig_part/part in container.vis_contents) + pieces += part + if(length(pieces) < 2) + continue + sortTim(pieces, GLOBAL_PROC_REF(cmp_atom_layer_asc)) + container.vis_contents -= pieces + container.vis_contents += pieces + +/// The mask icon for one piece facing one way. Built once each and kept. +/proc/get_rig_mask(mask_state, facing, mask_icon = RIG_MASKS) + var/static/list/masks = list() + var/key = "[mask_icon]-[mask_state]-[facing]" + var/icon/mask = masks[key] + if(!mask) + mask = icon(mask_icon, mask_state, facing) + masks[key] = mask + return mask + +/// Several rig masks laid over each other, as one mask. +/proc/get_rig_mask_union(list/mask_states, facing, mask_icon = RIG_MASKS) + var/static/list/masks = list() + var/key = "[mask_icon]-[mask_states.Join("+")]-[facing]" + var/icon/mask = masks[key] + if(!mask) + mask = icon(get_rig_mask(mask_states[1], facing, mask_icon)) + for(var/i in 2 to length(mask_states)) + mask.Blend(get_rig_mask(mask_states[i], facing, mask_icon), ICON_OVERLAY) + masks[key] = mask + return mask + +/datum/limb_rig/proc/on_dir_change(mob/living/carbon/source, old_dir, new_dir) + SIGNAL_HANDLER + if(old_dir == new_dir) + return + refresh_facing() + // The simulation is built from the bones facing one way: start it over facing the new way. + if(physics) + physics.rebuild() + return + // A pose looks different from every side, so snap to how the current one reads from here, + // then carry on with whatever loop was running. + snap_to(held_pose) + if(activity == RIG_ACTIVITY_IDLE || activity == RIG_ACTIVITY_WORKING) + settle() + +/datum/limb_rig/proc/on_moved(mob/living/carbon/source, atom/old_loc, movement_dir, forced, list/old_locs, momentum_change) + SIGNAL_HANDLER + if(forced || !isturf(owner.loc) || !isturf(old_loc) || owner.buckled || owner.stat >= UNCONSCIOUS) + return + play_step() + +/datum/limb_rig/proc/on_work_began(mob/living/carbon/source) + SIGNAL_HANDLER + working++ + if(working == 1 && activity != RIG_ACTIVITY_ONESHOT) + settle() + +/datum/limb_rig/proc/on_work_ended(mob/living/carbon/source) + SIGNAL_HANDLER + working = max(working - 1, 0) + if(!working && activity == RIG_ACTIVITY_WORKING) + settle() + +/// Emotes act themselves out, once they've actually gone through. +/datum/limb_rig/proc/on_emote(mob/living/carbon/source, datum/emote/emote, act, m_type, message, intentional) + SIGNAL_HANDLER + play_emote(emote.key) + +/datum/limb_rig/proc/on_posture_change(mob/living/carbon/source) + SIGNAL_HANDLER + if(activity != RIG_ACTIVITY_ONESHOT) + settle() + +// The body is drawn from overlays_standing; while rigged, it goes to the pieces instead. +// Mirrors, MODlinks and status displays listen for these signals, so they still fire. + +/mob/living/carbon + /// The cut-up version of this mob's sprite, while it has one. + var/datum/limb_rig/limb_rig + +/mob/living/carbon/apply_overlay(cache_index) + if(!limb_rig) + return ..() + . = overlays_standing[cache_index] + limb_rig.set_layer(cache_index, .) + SEND_SIGNAL(src, COMSIG_CARBON_APPLY_OVERLAY, cache_index, .) + +/mob/living/carbon/remove_overlay(cache_index) + if(!limb_rig) + return ..() + var/old = overlays_standing[cache_index] + if(old) + limb_rig.set_layer(cache_index, null) + overlays_standing[cache_index] = null + SEND_SIGNAL(src, COMSIG_CARBON_REMOVE_OVERLAY, cache_index, old) + +/// Whether this mob should be cut into moving pieces right now. +/mob/living/carbon/proc/can_have_limb_rig() + if(QDELETED(src) || stat == DEAD || !ishuman(src) || istype(src, /mob/living/carbon/human/dummy)) + return FALSE + var/mob/living/carbon/human/human_self = src + var/list/shape = human_self.dna?.species?.limb_rig_shape + // Some species can do without it, if whoever plays them would rather (see preference.dm). + if(!has_quirk(/datum/quirk/overanimated) && (!shape?["always"] || (shape["optional"] && !human_self.limb_rig_wanted))) + return FALSE + var/obj/item/bodypart/chest/chest = get_bodypart(BODY_ZONE_CHEST) + // The masks are cut for a human-shaped body. Monkeys and xenos are built differently. + return chest && (chest.bodyshape & BODYSHAPE_HUMANOID) && !(chest.bodyshape & BODYSHAPE_MONKEY) + +/// Adds or removes the rig to match can_have_limb_rig(), and keeps its shape matching the species. +/mob/living/carbon/proc/update_limb_rig() + if(can_have_limb_rig()) + // Start over, so a new species' pieces are right from the first layer. + QDEL_NULL(limb_rig) + var/mob/living/carbon/human/human_self = src + var/list/shape = human_self.dna?.species?.limb_rig_shape + var/rig_type = shape?["rig_type"] || (shape?["sprites"] ? /datum/limb_rig/sprites : /datum/limb_rig) + limb_rig = new rig_type(src) + else if(limb_rig) + QDEL_NULL(limb_rig) + +/mob/living/carbon/proc/on_limb_rig_species_change(mob/living/carbon/source) + SIGNAL_HANDLER + // Species swaps rebuild the body over several procs; look once it has settled. + addtimer(CALLBACK(src, PROC_REF(update_limb_rig)), 0.1 SECONDS, TIMER_UNIQUE) + +// Dying takes the rig off and being revived puts it back. This hooks set_stat() rather than +// the death signal, because navigation registers that same signal on the mob mid-round. +/mob/living/carbon/human/set_stat(new_stat) + . = ..() + if(stat == DEAD || limb_rig == null) + update_limb_rig() + +/datum/species + /** + * How the limb rig draws this species, or null for the usual. Keys: + * - "arm_stretch", "leg_stretch": how much longer than the sprite + * - "head_scale": how much bigger than the sprite the head is drawn, hats and all + * - "always": the species is always rigged, quirk or not, because it only looks right rigged + * - "optional": with "always", players can still turn it off for themselves (see preference.dm) + * - "sprites", "skeleton", "leg_rest", "cloth_masks", "hat_scale", "torso_width", "cloth_widths", "paw_height", + * "damage": + * a body built from its own piece sprites (see sprite_rig.dm) + * - "rig_type": a /datum/limb_rig subtype with the species' own animations + * - "posture": a pose added to every pose (see animations.dm) + * - "walk": a RIG_WALK_ define + */ + var/list/limb_rig_shape + +/mob/living/carbon/human/Initialize(mapload) + . = ..() + RegisterSignal(src, COMSIG_SPECIES_GAIN, PROC_REF(on_limb_rig_species_change)) + // The body finishes drawing itself during Initialize, so cut it up just after. + addtimer(CALLBACK(src, PROC_REF(update_limb_rig)), 0.1 SECONDS, TIMER_UNIQUE) + +/mob/living/carbon/Destroy() + QDEL_NULL(limb_rig) + return ..() + +/// Bodies that draw their own wounds redraw them whenever the damage overlay would change. +/mob/living/carbon/update_damage_overlays() + . = ..() + limb_rig?.refresh_damage_marks() + +/// Swinging at something swings an arm. +/mob/living/carbon/human/do_attack_animation(atom/A, visual_effect_icon, obj/item/used_item, no_effect) + . = ..() + limb_rig?.play_attack() + +/// Mirrors copy the mob's appearance, which has no body on it while rigged, so add it back. +/datum/component/reflection/copy_appearance_to_reflection(obj/effect/abstract/reflection, atom/movable/target) + . = ..() + var/mob/living/carbon/rigged = target + if(istype(rigged) && rigged.limb_rig) + reflection.add_overlay(rigged.limb_rig.get_body_overlays()) + +#undef RIG_MASKS +#undef RIG_PATCH_SHIFT diff --git a/voidcrew/modules/limb_rig/physics/admin.dm b/voidcrew/modules/limb_rig/physics/admin.dm new file mode 100644 index 00000000000..0539391633f --- /dev/null +++ b/voidcrew/modules/limb_rig/physics/admin.dm @@ -0,0 +1,109 @@ +// Debug verbs for trying out limb rig physics. Nothing in the game turns physics on by itself yet. + +ADMIN_VERB(spawn_physics_ragdoll, R_DEBUG, "Spawn Physics Ragdoll", "Spawns a test dummy whose limb rig is driven by Box2D instead of its animations.", ADMIN_CATEGORY_DEBUG) + if(!vcphys_available()) + to_chat(user, span_warning("The physics library (vcphys) isn't loaded. It goes next to the game, like rust_g."), confidential = TRUE) + return + var/static/list/species_choices = list( + "Experiment" = /datum/species/experiment, + "Milkie" = /datum/species/experiment/milkie, + "Human" = /datum/species/human, + "Felinid" = /datum/species/human/felinid, + "Lizard" = /datum/species/lizard, + "Moth" = /datum/species/moth, + "Ethereal" = /datum/species/ethereal, + "Plasmaman" = /datum/species/plasmaman, + ) + var/choice = tgui_input_list(user, "What kind of body?", "Physics ragdoll", species_choices) + if(!choice) + return + var/turf/spot = get_step(user.mob, user.mob.dir) || get_turf(user.mob) + var/mob/living/carbon/human/dummy = new(spot) + dummy.set_species(species_choices[choice]) + dummy.fully_replace_character_name(dummy.real_name, "ragdoll test dummy") + dummy.setDir(EAST) + // The rig is put together a moment after a mob is made. + addtimer(CALLBACK(dummy, TYPE_PROC_REF(/mob/living/carbon, set_limb_physics), TRUE), 0.5 SECONDS) + to_chat(user, span_notice("Spawned a [choice] ragdoll. Right-click it for Limb Physics: Push, Toggle and Inspect."), confidential = TRUE) + log_admin("[key_name(user)] spawned a physics ragdoll at [AREACOORD(spot)].") + BLACKBOX_LOG_ADMIN_VERB("Spawn Physics Ragdoll") + +ADMIN_VERB_AND_CONTEXT_MENU(toggle_limb_physics, R_DEBUG, "Limb Physics: Toggle", "Turns Box2D physics on or off for a mob's limb rig.", ADMIN_CATEGORY_DEBUG, mob/living/carbon/target in world) + // The context menu offers it on any mob. + if(!iscarbon(target)) + to_chat(user, span_warning("[target] has no limb rig."), confidential = TRUE) + return + var/on = target.set_limb_physics(!target.limb_rig?.physics) + if(!on && !target.limb_rig?.physics && !istype(target.limb_rig, /datum/limb_rig/sprites)) + to_chat(user, span_warning("[target] has no sprite-built limb rig to simulate."), confidential = TRUE) + return + to_chat(user, span_notice("Limb physics on [target]: [on ? "on" : "off"]."), confidential = TRUE) + log_admin("[key_name(user)] turned limb physics [on ? "on" : "off"] for [key_name(target)].") + +ADMIN_VERB_AND_CONTEXT_MENU(push_limb_physics, R_DEBUG, "Limb Physics: Push", "Gives one of a ragdoll's segments an impulse or a spin.", ADMIN_CATEGORY_DEBUG, mob/living/carbon/target in world) + // The context menu offers it on any mob. + if(!iscarbon(target)) + to_chat(user, span_warning("[target] has no limb rig."), confidential = TRUE) + return + var/datum/limb_physics/physics = target.limb_rig?.physics + if(!physics) + to_chat(user, span_warning("[target] isn't physical. Toggle limb physics on first."), confidential = TRUE) + return + var/part_id = tgui_input_list(user, "Which segment?", "Push", physics.body_by_part, RIG_CHEST) + if(!part_id) + return + var/static/list/pushes = list("Shove right", "Shove left", "Launch up", "Slam down", "Spin clockwise", "Spin counter-clockwise") + var/kind = tgui_input_list(user, "What kind of push?", "Push", pushes) + if(!kind) + return + // Newton-seconds (or for a spin, newton-metre-seconds). A torso is about 15 kg. + var/strength = tgui_input_number(user, "How hard? 10 is a nudge, 40 a shove, 100 a car.", "Push", 40, 500, 0.1, round_value = FALSE) + if(!strength || QDELETED(physics)) + return + switch(kind) + if("Shove right") + physics.push(part_id, strength, 0) + if("Shove left") + physics.push(part_id, -strength, 0) + if("Launch up") + physics.push(part_id, 0, strength) + if("Slam down") + physics.push(part_id, 0, -strength) + if("Spin clockwise") + physics.twist(part_id, -strength / 10) + if("Spin counter-clockwise") + physics.twist(part_id, strength / 10) + +ADMIN_VERB_AND_CONTEXT_MENU(inspect_limb_physics, R_DEBUG, "Limb Physics: Inspect", "Shows a ragdoll's simulation: every body, and the last calls made to the library.", ADMIN_CATEGORY_DEBUG, mob/living/carbon/target in world) + // The context menu offers it on any mob. + if(!iscarbon(target)) + to_chat(user, span_warning("[target] has no limb rig."), confidential = TRUE) + return + var/datum/limb_physics/physics = target.limb_rig?.physics + var/list/lines = list("Limb physics: [target]") + lines += "Library: [vcphys_available() ? "[GLOB.vcphys_library] ([vcphys_call("version")])" : "not loaded"]" + if(physics) + lines += "World [physics.world_handle] ([vcphys_call("world_stats", physics.world_handle)]), facing [dir2text(physics.facing)], [physics.steps] steps ([physics.steps * LIMB_PHYSICS_DT] s simulated)." + lines += "" + for(var/part_id in physics.body_by_part) + var/handle = physics.body_by_part[part_id] + var/list/state = physics.last_states?["[handle]"] + if(state) + lines += "" + else + lines += "" + lines += "
segmentbodyx (m)y (m)angle (deg)speed (m/s)
[part_id][handle][round(state[1], 0.01)][round(state[2], 0.01)][round(TODEGREES(state[3]), 0.1)][round(sqrt(state[4] ** 2 + state[5] ** 2), 0.01)]
[part_id][handle]not read yet
" + else + lines += "Not physical." + lines += "Call log: [GLOB.vcphys_logging ? "on" : "off"] (toggle with Limb Physics: Toggle Call Log)" + for(var/entry in GLOB.vcphys_log) + lines += "[html_encode(entry)]" + var/datum/browser/popup = new(user.mob, "limb_physics", "Limb physics", 640, 520) + popup.set_content(jointext(lines, "
")) + popup.open() + +ADMIN_VERB(toggle_limb_physics_log, R_DEBUG, "Limb Physics: Toggle Call Log", "Keeps (or stops keeping) the last calls made to the physics library, to read in Limb Physics: Inspect.", ADMIN_CATEGORY_DEBUG) + GLOB.vcphys_logging = !GLOB.vcphys_logging + if(!GLOB.vcphys_logging) + GLOB.vcphys_log.Cut() + to_chat(user, span_notice("Physics call log [GLOB.vcphys_logging ? "on" : "off"]."), confidential = TRUE) diff --git a/voidcrew/modules/limb_rig/physics/limb_physics.dm b/voidcrew/modules/limb_rig/physics/limb_physics.dm new file mode 100644 index 00000000000..ff223d47d5e --- /dev/null +++ b/voidcrew/modules/limb_rig/physics/limb_physics.dm @@ -0,0 +1,452 @@ +/** + * A rigged body taken over by physics: a ragdoll. + * + * The limb rig normally plays authored poses. With physics on, it plays none: every body segment + * (torso, head, upper and lower arms, thighs, shins, feet) becomes a Box2D body, pinned to its + * neighbours by joints that bend like the real ones, and each tick the pieces are put wherever the + * simulation says. Everything else on the rig (clothes, shoes, held items, the tail, wings) rides + * along on the segment it belongs to, as it does in a pose. Turn physics off and the rig goes + * straight back to its authored animation. + * + * Only sprite-built bodies can go physical (Experiments and the roundstart species): the bones + * come from their skeletons. + * + * The simulation is in the body's own frame, drawn on the mob's tile: one metre is + * LIMB_PHYSICS_PPM pixels, the floor is the bottom of the tile, and a wall stands a tile and a half + * out on either side. The mob itself doesn't move. + * + * Only Box2D handles are kept here. The simulation is stepped LIMB_PHYSICS_STEPS_PER_FIRE steps of + * LIMB_PHYSICS_DT every time SSlimb_physics fires, so it runs the same every time, however the + * server's doing; fewer if they're costing more than LIMB_PHYSICS_STEP_BUDGET, so it slows down + * rather than the server. + */ +/datum/limb_physics + /// The rig being simulated. + var/datum/limb_rig/sprites/rig + /// Which way the body faced when it went physical. The bones are that direction's. + var/facing + /// The Box2D world, as the library's handle. + var/world_handle + /// Each segment's body handle, by piece id. + var/list/body_by_part = list() + /// Each segment's origin in its sprite (the joint it hangs off), by piece id: the point its + /// body sits on. + var/list/origins = list() + /// Each joint's handle, by the piece id of its lower half. + var/list/joint_by_part = list() + /// Steps taken so far. + var/steps = 0 + /// The last positions read back, for inspecting: handle => list(x, y, angle, vx, vy, spin). + var/list/last_states + /// Every piece the simulation has put somewhere, to let go of again after. + var/list/drawn_pieces = list() + /// The segments the world was built from, by piece id, as get_physics_segments() gives them. + var/list/segments + /// How hard the solver works each step. Serverblight starves it (see serverblight.dm). + var/velocity_iterations = LIMB_PHYSICS_VELOCITY_ITERATIONS + var/position_iterations = LIMB_PHYSICS_POSITION_ITERATIONS + /// Whether Serverblight has this body. + var/blighted = FALSE + /// What Serverblight grew on it: list(skin piece, clothes piece or null, body handle, the + /// segment's origin in its sprite, length scale, width scale, the clothes' map or null) each. + var/list/growths = list() + /// The bodies at the ends of Serverblight's growths, pulled on every fire. + var/list/tips = list() + /// Serverblight's hands: each a list of its fingers, list(body handle, list(x, y) from the + /// finger's joint to its tip in metres, before it's turned). + var/list/hands = list() + /// Every joint Serverblight has seizing: list(joint handle, top speed, torque) each. + var/list/spasms = list() + /// Which of Serverblight's hands have hold of its prey, by their index in hands, as text. + var/list/latched = list() + /// The joints along Serverblight's long arms, which go slack to grab. + var/list/chain_joints = list() + /// Where whoever Serverblight is reaching for stands, in metres in the body's frame, if anyone. + var/prey_x + var/prey_y + /// Everyone Serverblight has taken into this body, to grow back on if it starts over. + var/list/merged = list() + /// Serverblight's walls and furniture from around it, as it goes. + var/list/surroundings = list() + /// Whether Serverblight's body is dead, lying slack until it gets up. + var/dormant = FALSE + /// The joint Serverblight hangs the body up by. + var/neck_pin + /// Pieces Serverblight has glued together: list(body handle, body handle) each. + var/list/glued = list() + /// Serverblight's walking leg joints: list(joint handle, where in the stride it starts) each. + var/list/legs = list() + /// How fast the body's being moved about, in tiles a second, for its legs to keep up with. + var/walk_speed = 0 + /// How far through a stride the legs are, in radians. + var/walk_phase = 0 + /// The joints grow_copy() made last, by piece id. + var/list/last_joints + /// The joint grow() made last. + var/last_growth_joint + /// Serverblight's hunt, moving the body about, while it has one. + var/datum/serverblight_chase/chase + /// When Serverblight's body can next start over, having come apart (see is_torn()). + COOLDOWN_DECLARE(rebuild_cooldown) + /// Fires so far. A Serverblighted body, with all it's grown, is drawn every other one. + var/fires = 0 + /// How many steps it takes a fire: fewer while they're costing too much (see LIMB_PHYSICS_STEP_BUDGET). + var/steps_per_fire = LIMB_PHYSICS_STEPS_PER_FIRE + /// How long its steps have been taking each fire, in milliseconds, on average. + var/step_cost = 0 + +/datum/limb_physics/New(datum/limb_rig/sprites/rig) + src.rig = rig + rig.physics = src + if(!build()) + qdel(src) + return + START_PROCESSING(SSlimb_physics, src) + +/datum/limb_physics/Destroy() + STOP_PROCESSING(SSlimb_physics, src) + // Let go of the rig first: ending the hunt can kill the body, which takes the rig (and this) away. + var/was_driving = rig?.physics == src + if(was_driving) + rig.physics = null + QDEL_NULL(chase) + merged.Cut() + if(blighted && !QDELETED(rig?.owner)) + REMOVE_TRAITS_IN(rig.owner, SERVERBLIGHT_TRAIT) + destroy_world() + if(was_driving) + if(!QDELETED(rig) && !QDELETED(rig.owner)) + // Let go of everything physics placed first: a pose doesn't place every piece (the torso + // moves with its pivot, not by itself), and anything it skips would stay where the + // ragdoll left it. + for(var/atom/movable/piece as anything in drawn_pieces) + animate(piece, transform = matrix(), time = 0) + // Back to the authored animation. + rig.snap_to(rig.held_pose) + rig.settle() + drawn_pieces.Cut() + rig = null + return ..() + +/datum/limb_physics/proc/destroy_world() + if(world_handle) + vcphys_call("world_destroy", world_handle) + world_handle = null + clear_growths() + blighted = FALSE + dormant = FALSE + neck_pin = null + velocity_iterations = LIMB_PHYSICS_VELOCITY_ITERATIONS + position_iterations = LIMB_PHYSICS_POSITION_ITERATIONS + body_by_part.Cut() + joint_by_part.Cut() + origins.Cut() + +/// Starts over from the rig's current pose, facing the way the mob faces now. Serverblight comes +/// back with it. +/datum/limb_physics/proc/rebuild() + var/was_blighted = blighted + destroy_world() + if(!build()) + qdel(src) + return + if(was_blighted) + blight() + for(var/mob/living/carbon/prey as anything in merged.Copy()) + if(QDELETED(prey) || prey.loc != rig.owner) + merged -= prey + continue + merge(prey) + +/** + * Makes the world: a floor and walls, and a body for every segment, starting exactly where the + * rig has it drawn now, joined at its joints. Returns FALSE if anything couldn't be made. + */ +/datum/limb_physics/proc/build() + facing = rig.get_facing() + world_handle = vcphys_call("world_create", 0, LIMB_PHYSICS_GRAVITY) + if(!world_handle) + return FALSE + // The floor, the bottom of the tile, and a wall a tile and a half out either side. + if(!add_static_box(0, -0.5, 4, 0.5) || !add_static_box(-2.9, 2, 0.5, 2.5) || !add_static_box(2.9, 2, 0.5, 2.5)) + return FALSE + + segments = rig.get_physics_segments(facing) + var/list/drawn = rig.get_segment_matrices(rig.held_pose, facing) + for(var/part_id in segments) + var/list/segment = segments[part_id] + var/list/origin = segment["origin"] + var/list/end = segment["end"] + var/matrix/now = drawn[part_id] || matrix() + // Where the origin is drawn now, and how far it's turned (counter-clockwise, as Box2D has it). + var/list/at = rig_apply_matrix(now, origin[1], origin[2]) + var/angle = TORADIANS(rig_matrix_angle(now)) + var/body = vcphys_call("body_create", world_handle, LIMB_PHYSICS_DYNAMIC, (at[1] - 16.5) / LIMB_PHYSICS_PPM, at[2] / LIMB_PHYSICS_PPM, angle, 0.05, 0.4) + if(!body) + return FALSE + // The segment's box in the body's own frame: its sprite, before it's turned, with the + // origin at (0, 0). + var/length = max(sqrt((end[1] - origin[1]) ** 2 + (end[2] - origin[2]) ** 2), 2) + var/centre_x = ((origin[1] + end[1]) / 2 - origin[1]) / LIMB_PHYSICS_PPM + var/centre_y = ((origin[2] + end[2]) / 2 - origin[2]) / LIMB_PHYSICS_PPM + if(!vcphys_call("fixture_box", world_handle, body, segment["width"] / 2 / LIMB_PHYSICS_PPM, length / 2 / LIMB_PHYSICS_PPM, centre_x, centre_y, 0, segment["density"], 0.7, 0.05, LIMB_PHYSICS_RAGDOLL_GROUP)) + return FALSE + body_by_part[part_id] = body + origins[part_id] = origin + + // Joints, each at the lower segment's origin, where it starts out. + for(var/part_id in segments) + var/list/segment = segments[part_id] + var/parent = segment["parent"] + if(!parent) + continue + var/list/origin = segment["origin"] + var/list/at = rig_apply_matrix(drawn[part_id] || matrix(), origin[1], origin[2]) + var/list/limits = limb_physics_joint_limits(segment["joint"], facing) + var/joint = vcphys_call("joint_revolute", world_handle, body_by_part[parent], body_by_part[part_id], (at[1] - 16.5) / LIMB_PHYSICS_PPM, at[2] / LIMB_PHYSICS_PPM, TORADIANS(limits[1]), TORADIANS(limits[2]), limits[3]) + if(!joint) + return FALSE + joint_by_part[part_id] = joint + return TRUE + +/datum/limb_physics/proc/add_static_box(x, y, half_width, half_height) + var/body = vcphys_call("body_create", world_handle, LIMB_PHYSICS_STATIC, x, y, 0, 0, 0) + return body && vcphys_call("fixture_box", world_handle, body, half_width, half_height, 0, 0, 0, 0, 0.8, 0, 0) + +/datum/limb_physics/process(seconds_per_tick) + if(QDELETED(rig) || QDELETED(rig.owner)) + qdel(src) + return PROCESS_KILL + if(blighted && !dormant) + seize() + walk_legs() + pull_tips() + push_apart() + // Equal steps, the same number every fire (the tick's actual length never comes into it), unless + // they're costing too much: then fewer, and it runs slow. + var/started = TICK_USAGE_REAL + if(!vcphys_call("world_step", world_handle, LIMB_PHYSICS_DT, velocity_iterations, position_iterations, steps_per_fire)) + qdel(src) + return PROCESS_KILL + steps += steps_per_fire + step_cost = step_cost * 0.8 + TICK_USAGE_TO_MS(started) * 0.2 + if(step_cost > LIMB_PHYSICS_STEP_BUDGET && steps_per_fire > 1) + steps_per_fire-- + else if(step_cost < LIMB_PHYSICS_STEP_BUDGET / 2 && steps_per_fire < LIMB_PHYSICS_STEPS_PER_FIRE) + steps_per_fire++ + var/list/states = read_states() + if(!states) + qdel(src) + return PROCESS_KILL + if(blighted && is_torn(states)) + if(COOLDOWN_FINISHED(src, rebuild_cooldown)) + COOLDOWN_START(src, rebuild_cooldown, SERVERBLIGHT_REBUILD_COOLDOWN) + rebuild() + return + fires++ + if(!blighted) + draw(states, SSlimb_physics.wait) + else if(fires % 2) + draw(states, SSlimb_physics.wait * 2) + +/** + * Every body's state, by handle: list(x, y, angle, vx, vy, spin). Null if the reply was garbled, + * or anything has gone NaN or flown off (which would mean the simulation blew up). The angle isn't + * bounded: a body that keeps turning one way just keeps adding to it. + */ +/datum/limb_physics/proc/read_states() + var/reply = vcphys_call("world_read", world_handle) + if(!reply) + return null + var/list/states = list() + for(var/entry in splittext(reply, ";")) + var/list/fields = splittext(entry, " ") + if(length(fields) != 7) + stack_trace("vcphys: a garbled body in world_read: [entry]") + return null + var/list/state = list() + for(var/i in 2 to 7) + var/value = text2num(fields[i]) + if(!isnum(value) || value != value || (i != 4 && abs(value) > 1000)) + stack_trace("vcphys: body [fields[1]] blew up: [entry]") + return null + state += value + states[fields[1]] = state + last_states = states + return states + +/// Puts every piece where the simulation has it, easing there over time deciseconds. +/datum/limb_physics/proc/draw(list/states, time) + var/list/segment_matrices = list() + for(var/part_id in body_by_part) + var/list/state = states["[body_by_part[part_id]]"] + if(!state) + continue + var/list/origin = origins[part_id] + // Box2D turns counter-clockwise in radians, BYOND clockwise in degrees. + segment_matrices[part_id] = rig_joint_matrix(origin[1], origin[2], 1, -TODEGREES(state[3]), state[1] * LIMB_PHYSICS_PPM + 16.5, state[2] * LIMB_PHYSICS_PPM) + var/list/matrices = rig.get_physics_matrices(segment_matrices, facing) + for(var/atom/movable/piece as anything in matrices) + animate(piece, transform = matrices[piece], time = time) + drawn_pieces[piece] = TRUE + if(length(growths)) + draw_growths(states, time) + +/// Shoves a segment: an impulse in newton-seconds, right and up are positive. +/datum/limb_physics/proc/push(part_id, impulse_x, impulse_y) + var/body = body_by_part[part_id] + return body && vcphys_call("body_impulse", world_handle, body, impulse_x, impulse_y) + +/// Spins a segment: an angular impulse, counter-clockwise positive. +/datum/limb_physics/proc/twist(part_id, impulse) + var/body = body_by_part[part_id] + return body && vcphys_call("body_angular_impulse", world_handle, body, impulse) + +/** + * How far each joint bends, in degrees, relative to how it sits at the start, and how stiff it is: + * list(lower, upper, friction torque in newton-metres). Side-on, bending is anatomical (elbows and + * knees only go one way), mirrored facing west. Seen from the front, it's a looser, symmetric range. + */ +/proc/limb_physics_joint_limits(joint, facing) + // Facing east: counter-clockwise is forward. + var/static/list/side_on = list( + "neck" = list(-40, 40, 1.5), + "shoulder" = list(-80, 170, 2), + "elbow" = list(0, 145, 1), + "hip" = list(-30, 110, 3), + "knee" = list(-120, 10, 2), + "ankle" = list(-30, 30, 1), + ) + var/static/list/front_on = list( + "neck" = list(-25, 25, 1.5), + "shoulder" = list(-150, 150, 2), + "elbow" = list(-60, 60, 1), + "hip" = list(-50, 50, 3), + "knee" = list(-15, 15, 2), + "ankle" = list(-15, 15, 1), + ) + var/list/table = (facing & (EAST|WEST)) ? side_on : front_on + var/list/limits = table[joint] || list(-30, 30, 1) + if(facing == WEST) + return list(-limits[2], -limits[1], limits[3]) + return limits.Copy() + +/// Where a matrix puts a point, as rig_joint_matrix() works them: about the middle of a 32 pixel +/// tile, in the skeleton's pixel coordinates. Scale and skew are taken as they come. +/proc/rig_apply_matrix(matrix/transform, x, y) + return list( + transform.a * (x - 16.5) + transform.b * (y - 16.5) + transform.c + 16.5, + transform.d * (x - 16.5) + transform.e * (y - 16.5) + transform.f + 16.5, + ) + +/// How far a matrix turns things, in degrees counter-clockwise. +/proc/rig_matrix_angle(matrix/transform) + return arctan(transform.a, transform.d) + +// The rig's side of it. + +/datum/limb_rig + /// The physics driving this rig instead of its animations, while it has any. + var/datum/limb_physics/physics + +/** + * The segments physics simulates, by piece id, from the skeleton facing this way: + * list("origin" = the joint it hangs off, "end" = its far end, "width" = pixels across, + * "density" = kilograms per square metre, "parent" = the piece it hangs off, "joint" = what kind + * of joint that is). The torso hangs off nothing. + */ +/datum/limb_rig/sprites/proc/get_physics_segments(facing) + var/list/bones = skeleton[dir2text(facing)] + var/front = facing & (NORTH|SOUTH) + var/list/hips = bones[RIG_CHEST] + var/list/neck = bones[RIG_HEAD] + var/list/crown = bones["crown"] || list(neck[1], neck[2] + 10) + var/list/l_shoulder = bones["l_arm"][1] + var/list/r_shoulder = bones["r_arm"][1] + . = list() + .[RIG_CHEST] = list("origin" = hips, "end" = neck, "width" = front ? max(abs(l_shoulder[1] - r_shoulder[1]) - 2, 6) : 7, "density" = 60) + var/head_height = crown[2] - neck[2] + .[RIG_HEAD] = list("origin" = neck, "end" = crown, "width" = clamp(head_height * 0.8, 6, 12), "density" = 50, "parent" = RIG_CHEST, "joint" = "neck") + for(var/side in list("l", "r")) + var/list/arm = bones["[side]_arm"] + var/list/wrist = arm[3] + .["[side]_arm"] = list("origin" = arm[1], "end" = arm[2], "width" = 4, "density" = 45, "parent" = RIG_CHEST, "joint" = "shoulder") + // On past the wrist to take in the hand. + .["[side]_forearm"] = list("origin" = arm[2], "end" = list(wrist[1], wrist[2] - 3), "width" = 3.5, "density" = 45, "parent" = "[side]_arm", "joint" = "elbow") + var/list/leg = bones["[side]_leg"] + .["[side]_thigh"] = list("origin" = leg[1], "end" = leg[2], "width" = 5, "density" = 50, "parent" = RIG_CHEST, "joint" = "hip") + .["[side]_shin"] = list("origin" = leg[2], "end" = leg[3], "width" = 4, "density" = 45, "parent" = "[side]_thigh", "joint" = "knee") + .["[side]_foot"] = list("origin" = leg[3], "end" = leg[4], "width" = 4, "density" = 45, "parent" = "[side]_shin", "joint" = "ankle") + +/** + * Where a pose draws each segment, in the body's own frame (not relative to the torso, as the arms + * and head usually are): the matrix taking the segment's sprite to where it's drawn, by piece id. + */ +/datum/limb_rig/sprites/proc/get_segment_matrices(list/pose, facing) + var/list/posed = get_pose_matrices(pose, facing) + var/matrix/torso = posed[pivot] || matrix() + . = list() + .[RIG_CHEST] = torso + for(var/part_id in list(RIG_HEAD, "l_arm", "l_forearm", "r_arm", "r_forearm")) + .[part_id] = (posed[parts[part_id]] || matrix()) * torso + for(var/part_id in list("l_thigh", "l_shin", "l_foot", "r_thigh", "r_shin", "r_foot")) + .[part_id] = posed[parts[part_id]] || matrix() + +/** + * Transforms for every piece of the rig, from where physics has each segment (in the body's own + * frame, as get_segment_matrices()): the segments themselves, their clothes and shoes, held items + * at the wrists, and the tail on the torso. The torso's pivot stays put, since the segments are + * already placed in the body's frame. + */ +/datum/limb_rig/sprites/proc/get_physics_matrices(list/segment_matrices, facing) + . = list() + .[pivot] = matrix() + for(var/part_id in segment_matrices) + var/matrix/segment = segment_matrices[part_id] + .[parts[part_id]] = segment + if(cloth_parts[part_id]) + .[cloth_parts[part_id]] = get_cloth_map(part_id, facing) * segment + var/list/bones = skeleton[dir2text(facing)] + for(var/side in list("l", "r")) + var/matrix/foot = segment_matrices["[side]_foot"] + if(foot) + .[shoe_parts[side]] = get_cloth_map("[side]_foot", facing) * foot + var/matrix/forearm = segment_matrices["[side]_forearm"] + if(forearm) + // Held items are drawn for a human hand: put that hand at this wrist, turned with it. + var/list/wrist = bones["[side]_arm"][3] + var/list/at = rig_apply_matrix(forearm, wrist[1], wrist[2]) + var/list/human_hand = get_rig_joint(side == "l" ? RIG_L_ARM : RIG_R_ARM, facing) + .[item_parts[side]] = rig_joint_matrix(human_hand[1], LIMB_PHYSICS_HUMAN_HAND_Y, item_scale, -rig_matrix_angle(forearm), at[1], at[2], item_scale) + var/matrix/torso = segment_matrices[RIG_CHEST] + if(torso && parts[RIG_TAIL]) + .[parts[RIG_TAIL]] = rig_tail_matrix(bones[RIG_TAIL], null, facing) * torso + add_face_matrix(.) + +/datum/limb_rig/sprites/humanoid/get_physics_matrices(list/segment_matrices, facing) + . = ..() + var/matrix/torso = segment_matrices[RIG_CHEST] + if(!torso) + return + .[wings_part] = get_cloth_map(RIG_CHEST, facing) * torso + var/list/root = get_tail_root(facing) + var/list/bone = skeleton[dir2text(facing)][RIG_TAIL] + .[tail_part] = rig_joint_matrix(root[1], root[2], hat_scale, 0, bone[1], bone[2], hat_scale) * .[parts[RIG_TAIL]] + +/// Turns physics on or off for this mob's rig. Returns whether it's on now. +/mob/living/carbon/proc/set_limb_physics(enabled) + if(!enabled) + if(limb_rig?.physics) + qdel(limb_rig.physics) + return FALSE + if(!istype(limb_rig, /datum/limb_rig/sprites) || !vcphys_available()) + return FALSE + if(!limb_rig.physics) + new /datum/limb_physics(limb_rig) + return !!limb_rig.physics + +/// Steps every rig that's gone physical, every decisecond. +PROCESSING_SUBSYSTEM_DEF(limb_physics) + name = "Limb Physics" + flags = SS_NO_INIT + wait = 1 diff --git a/voidcrew/modules/limb_rig/physics/serverblight.dm b/voidcrew/modules/limb_rig/physics/serverblight.dm new file mode 100644 index 00000000000..111c5046cb5 --- /dev/null +++ b/voidcrew/modules/limb_rig/physics/serverblight.dm @@ -0,0 +1,600 @@ +/** + * Serverblight: an admin smite that gets into a body's physics and ruins it, then sets it loose. + * + * Nothing here is animated. The body is made physical and hung upright by the neck from nothing, + * and everything that keeps a ragdoll looking like a body is turned against it: every joint is + * bent the way it doesn't go, and a motor seizes it back and forth at random every tick. The arms + * carry on past the hands into long stiff limbs ending in six long fingers, a second pair of arms + * comes out of the ribs, a hand comes out of the mouth, and the head splits into mirrored copies. + * All of it collides with itself, and the solver gets too few iterations to ever settle any of + * it: the shaking is the simulation failing to, every step. + * + * Then it hunts (see serverblight_chase.dm): the body slides after the nearest person without + * walking tile to tile, and anyone it gets every hand on is taken out of their body, which joins + * the mass. + * + * Turning limb physics off (the Limb Physics: Toggle admin verb) ends it and lets go of everyone. + */ +/datum/smite/serverblight + name = "Serverblight" + +/datum/smite/serverblight/effect(client/user, mob/living/target) + . = ..() + if(!iscarbon(target)) + to_chat(user, span_warning("This must be used on a carbon mob."), confidential = TRUE) + return + if(!vcphys_available()) + to_chat(user, span_warning("The physics library (vcphys) isn't loaded. It goes next to the game, like rust_g."), confidential = TRUE) + return + var/mob/living/carbon/victim = target + if(!victim.set_limb_physics(TRUE)) + to_chat(user, span_warning("[victim] has no sprite-built limb rig to corrupt."), confidential = TRUE) + return + var/datum/limb_physics/physics = victim.limb_rig.physics + if(!physics.blight()) + to_chat(user, span_warning("[victim] is already blighted, or the physics library refused."), confidential = TRUE) + return + new /datum/serverblight_chase(physics) + playsound(victim, 'sound/effects/wounds/crack2.ogg', 100, TRUE) + to_chat(victim, span_userdanger("Something reaches into you and rewrites where your bones go. Your body gets up without you.")) + victim.visible_message(span_danger("[victim]'s body folds the wrong way, and keeps folding."), ignored_mobs = victim) + +/** + * How far each joint is forced, in degrees relative to where it started: list(lower, upper). + * Facing east, every band is on the side the joint can't go; mirrored facing west. + */ +/proc/serverblight_joint_band(joint, facing) + var/static/list/bands = list( + "neck" = list(100, 170), + "shoulder" = list(120, 179), + "elbow" = list(-175, -110), + "hip" = list(-55, -15), + "knee" = list(45, 95), + "ankle" = list(100, 175), + ) + var/list/band = bands[joint] || list(100, 150) + if(facing == WEST) + return list(-band[2], -band[1]) + return band.Copy() + +/** + * Blights the body. It must already be physical. Returns FALSE if it was already, or the library + * wouldn't have it. + */ +/datum/limb_physics/proc/blight() + if(blighted || !world_handle) + return FALSE + var/list/states = read_states() + if(!states) + return FALSE + blighted = TRUE + // Too few iterations to ever solve all of it: what's left over each step is the shaking. Enough + // position iterations, though, that its joints hold together. + velocity_iterations = 4 + position_iterations = 3 + var/mirror = facing == WEST ? -1 : 1 + var/side_on = facing & (EAST|WEST) + + // Every segment collides with every other: a second, weightless box outside the ragdoll's group. + for(var/part_id in body_by_part) + var/list/segment = segments[part_id] + var/list/origin = segment["origin"] + var/list/end = segment["end"] + vcphys_call("fixture_box", world_handle, body_by_part[part_id], segment["width"] / 2 / LIMB_PHYSICS_PPM, segment_length(segment) / 2 / LIMB_PHYSICS_PPM, ((origin[1] + end[1]) / 2 - origin[1]) / LIMB_PHYSICS_PPM, ((origin[2] + end[2]) / 2 - origin[2]) / LIMB_PHYSICS_PPM, 0, 0, 0.7, 0.05, 0) + + // Every joint bent the way it doesn't go, seizing. + for(var/part_id in joint_by_part) + var/list/band = serverblight_joint_band(segments[part_id]["joint"], facing) + var/joint = joint_by_part[part_id] + vcphys_call("joint_set_limits", world_handle, joint, TORADIANS(band[1]), TORADIANS(band[2])) + spasms += list(list(joint, 30, 150)) + // Its legs walk it about (see walk_legs()), each in step with the other, the wrong way. + for(var/side in list("l", "r")) + make_leg(joint_by_part["[side]_thigh"], joint_by_part["[side]_shin"], side == "l" ? 0 : PI) + + // Hung by the neck from nothing, so it never gets to lie down, lurching on the pin. + var/list/chest = states["[body_by_part[RIG_CHEST]]"] + var/list/chest_segment = segments[RIG_CHEST] + var/list/hips = chest_segment["origin"] + var/list/neck = chest_segment["end"] + var/anchor = vcphys_call("body_create", world_handle, LIMB_PHYSICS_STATIC, 0, 0, 0, 0, 0) + var/list/neck_at = segment_point(chest, hips, neck) + neck_pin = anchor && vcphys_call("joint_revolute", world_handle, anchor, body_by_part[RIG_CHEST], neck_at[1], neck_at[2], TORADIANS(-18), TORADIANS(18), 0) + if(neck_pin) + spasms += list(list(neck_pin, 6, 600)) + + // The arms go on past the hands into long stiff limbs, ending in six long fingers. + for(var/side in list("l", "r")) + var/sign = (side == "l" ? 1 : -1) * mirror + var/source = "[side]_forearm" + var/list/segment = segments[source] + var/list/at = segment_point(states["[body_by_part[source]]"], segment["origin"], segment["end"]) + var/angle = TORADIANS(110 * sign) + var/layer = rig.parts[source].layer <= -6 ? -7.5 : -1.6 + var/parent = body_by_part[source] + for(var/link in 1 to 3) + parent = grow(rig, source, segment, parent, at[1], at[2], angle, 1.6, 0.9, 20, 60, -25, 25, layer) + if(!parent) + return TRUE + chain_joints += last_growth_joint + at = growth_end(at, angle, segment, 1.6, 0.9) + var/list/hand = list() + for(var/spread in list(-60, -35, -12, 12, 35, 60)) + var/finger = grow(rig, source, segment, parent, at[1], at[2], angle + TORADIANS(spread), 1.25, 0.3, 25, 5, -70, 70, layer) + if(finger) + hand += list(list(finger, segment_offset(segment, 1.25, 0.3))) + hands += list(hand) + + // A second pair of arms out of the ribs. + for(var/side in list("l", "r")) + var/list/root = segment_point(chest, hips, list(hips[1], hips[2] + (neck[2] - hips[2]) * (side == "l" ? 0.6 : 0.35))) + var/angle = TORADIANS((side == "l" ? 75 : -75) * mirror) + var/layer = rig.parts["[side]_arm"].layer <= -6 ? -7.4 : -1.7 + var/list/arm_segment = segments["[side]_arm"] + var/arm = grow(rig, "[side]_arm", arm_segment, body_by_part[RIG_CHEST], root[1], root[2], angle, 1.4, 1, 15, 40, -100, 100, layer) + if(!arm) + return TRUE + var/list/elbow = growth_end(root, angle, arm_segment, 1.4, 1) + var/list/forearm_segment = segments["[side]_forearm"] + var/hand = grow(rig, "[side]_forearm", forearm_segment, arm, elbow[1], elbow[2], angle, 1.6, 1, 15, 40, -120, 120, layer) + if(hand) + hands += list(list(list(hand, segment_offset(forearm_segment, 1.6, 1)))) + + // A hand out of the mouth: forward side-on, straight down from the front. + var/list/head_segment = segments[RIG_HEAD] + var/list/head_origin = head_segment["origin"] + var/list/head_end = head_segment["end"] + var/list/mouth = segment_point(states["[body_by_part[RIG_HEAD]]"], head_origin, list(head_origin[1] + (side_on ? 2 * mirror : 0), head_origin[2] + (head_end[2] - head_origin[2]) * 0.3)) + var/mouth_angle = side_on ? TORADIANS(90 * mirror) : 0 + var/list/mouth_segment = segments["r_forearm"] + var/mouth_hand = grow(rig, "r_forearm", mouth_segment, body_by_part[RIG_HEAD], mouth[1], mouth[2], mouth_angle, 1.1, 0.9, 20, 20, -40, 40, -1.5) + if(mouth_hand) + var/list/fingers_at = growth_end(mouth, mouth_angle, mouth_segment, 1.1, 0.9) + var/list/hand = list() + for(var/spread in list(-40, 0, 40)) + var/finger = grow(rig, "r_forearm", mouth_segment, mouth_hand, fingers_at[1], fingers_at[2], mouth_angle + TORADIANS(spread), 0.8, 0.3, 25, 3, -60, 60, -1.5) + if(finger) + hand += list(list(finger, segment_offset(mouth_segment, 0.8, 0.3))) + hands += list(hand) + + // The head splitting into mirrored copies of itself. + var/head_angle = states["[body_by_part[RIG_HEAD]]"][3] + for(var/spread in list(-28, 28)) + grow(rig, RIG_HEAD, head_segment, body_by_part[RIG_CHEST], neck_at[1], neck_at[2], head_angle + TORADIANS(spread), 1, -1, 10, 40, spread - 20, spread + 20, -2.6) + + for(var/list/hand as anything in hands) + for(var/list/finger as anything in hand) + tips += finger[1] + add_surroundings() + rig.sort_pieces() + ADD_TRAIT(rig.owner, TRAIT_IMMOBILIZED, SERVERBLIGHT_TRAIT) + ADD_TRAIT(rig.owner, TRAIT_HANDS_BLOCKED, SERVERBLIGHT_TRAIT) + return TRUE + +/** + * Grows someone's body into this one: their whole rig, standing up out of the torso (leaning a + * little), stretched, seizing like the rest, their legs walking with its own. Their forearms are + * two more hands to hold people with. Their rig can be anyone's; + * nothing's drawn for someone without a sprite-built one. Kept track of, so it comes back if the + * simulation starts over. + */ +/datum/limb_physics/proc/merge(mob/living/carbon/prey) + merged |= prey + var/datum/limb_rig/sprites/prey_rig = prey.limb_rig + if(!blighted || !istype(prey_rig) || length(merged) > SERVERBLIGHT_MAX_DRAWN_MERGES) + return FALSE + var/list/states = last_states || read_states() + var/list/chest = states?["[body_by_part[RIG_CHEST]]"] + if(!chest) + return FALSE + var/list/chest_segment = segments[RIG_CHEST] + var/list/hips = chest_segment["origin"] + var/list/neck = chest_segment["end"] + var/list/prey_segments = prey_rig.get_physics_segments(facing) + + // Their body, up out of the torso. + var/list/attach = segment_point(chest, hips, list(hips[1], hips[2] + (neck[2] - hips[2]) * rand(30, 60) / 100)) + var/turn = chest[3] + TORADIANS(rand(-15, 15)) + var/list/made = grow_copy(prey_rig, prey_segments, prey_segments, body_by_part[RIG_CHEST], attach, turn, 1.3) + var/prey_torso = made[RIG_CHEST] + if(!prey_torso) + return FALSE + var/first_growth = length(growths) - length(made) + 1 + // Their own forearms are hands now, and their legs walk with the rest, out of step. + var/stride = rand(0, 628) / 100 + for(var/side in list("l", "r")) + add_hand(made["[side]_forearm"], prey_segments["[side]_forearm"], 1.3, 1) + make_leg(last_joints["[side]_thigh"], last_joints["[side]_shin"], stride + (side == "l" ? 0 : PI)) + + // Glued into everything it's grown into, so it can never get out of it. + var/list/new_bodies = list() + for(var/index in first_growth to length(growths)) + new_bodies += growths[index][3] + glue(new_bodies) + rig.sort_pieces() + return TRUE + +/** + * Glues each body to the few others nearest it (within half a metre): pinned together where they + * are, still colliding with each other, the pin seizing. Every fire, glued pieces lying in each + * other are shoved apart (see push_apart()), and the glue won't let them go. + */ +/datum/limb_physics/proc/glue(list/bodies) + var/list/states = read_states() + if(!states) + return + var/list/everything = list() + for(var/part_id in body_by_part) + everything += body_by_part[part_id] + for(var/list/growth as anything in growths) + everything += growth[3] + var/list/already = list() + for(var/list/pair as anything in glued) + already["[pair[1]]-[pair[2]]"] = TRUE + already["[pair[2]]-[pair[1]]"] = TRUE + for(var/body in bodies) + var/list/here = states["[body]"] + if(!here) + continue + // The nearest few, nearest first. + var/list/nearest = list() + for(var/other in everything) + if(other == body || already["[body]-[other]"]) + continue + var/list/there = states["[other]"] + var/distance = there && sqrt((there[1] - here[1]) ** 2 + (there[2] - here[2]) ** 2) + if(isnull(distance) || distance > 0.5) + continue + nearest[other] = distance + sortTim(nearest, GLOBAL_PROC_REF(cmp_numeric_asc), associative = TRUE) + for(var/other in nearest.Copy(1, min(length(nearest), SERVERBLIGHT_GLUE_PER_PIECE) + 1)) + var/list/there = states["[other]"] + var/joint = vcphys_call("joint_revolute", world_handle, body, other, (here[1] + there[1]) / 2, (here[2] + there[2]) / 2, 0, 0, 0, 1) + if(!joint) + continue + vcphys_call("joint_set_motor", world_handle, joint, 30, 250) + spasms += list(list(joint, 30, 250)) + glued += list(list(body, other)) + already["[body]-[other]"] = TRUE + already["[other]-[body]"] = TRUE + +/** + * Pushback, as physics engines do for props stuck inside each other: every glued pair lying less + * than SERVERBLIGHT_PUSHBACK_REACH apart is pushed apart, faster the deeper in they are, up to + * SERVERBLIGHT_PUSHBACK metres a second. The glue holds, so they never get anywhere: they shake. A + * change in speed rather than a shove, so it's the same for a fingertip as for a torso (a shove that + * would nudge a torso sends a fingertip flying, and the body comes apart). And a governor: nothing + * goes faster than SERVERBLIGHT_TOP_SPEED or spins faster than SERVERBLIGHT_TOP_SPIN. + */ +/datum/limb_physics/proc/push_apart() + var/list/pushes = list() + for(var/list/pair as anything in glued) + var/list/first = last_states["[pair[1]]"] + var/list/second = last_states["[pair[2]]"] + if(!first || !second) + continue + var/dx = second[1] - first[1] + var/dy = second[2] - first[2] + var/distance = sqrt(dx ** 2 + dy ** 2) + if(distance >= SERVERBLIGHT_PUSHBACK_REACH) + continue + if(distance < 0.001) + var/direction = rand(0, 359) + dx = cos(direction) + dy = sin(direction) + distance = 1 + var/push = SERVERBLIGHT_PUSHBACK * (SERVERBLIGHT_PUSHBACK_REACH - distance) / SERVERBLIGHT_PUSHBACK_REACH + for(var/index in 1 to 2) + var/key = "[pair[index]]" + var/sign = index == 2 ? 1 : -1 + var/list/change = pushes[key] || list(0, 0) + change[1] += sign * push * dx / distance + change[2] += sign * push * dy / distance + pushes[key] = change + for(var/handle in last_states) + var/list/state = last_states[handle] + var/list/change = pushes[handle] + var/velocity_x = state[4] + (change ? change[1] : 0) + var/velocity_y = state[5] + (change ? change[2] : 0) + var/speed = sqrt(velocity_x ** 2 + velocity_y ** 2) + if(!change && speed <= SERVERBLIGHT_TOP_SPEED && abs(state[6]) <= SERVERBLIGHT_TOP_SPIN) + continue + var/scale = speed > SERVERBLIGHT_TOP_SPEED ? SERVERBLIGHT_TOP_SPEED / speed : 1 + vcphys_call("body_set_velocity", world_handle, handle, velocity_x * scale, velocity_y * scale, clamp(state[6], -SERVERBLIGHT_TOP_SPIN, SERVERBLIGHT_TOP_SPIN)) + +/** + * Grows copies of some of a rig's segments, jointed to each other as the rig has them, and the + * first to parent: laid out as the rig stands, turned by turn, with the first one's origin at + * attach, and every limb stretched (the torso and head aren't). Returns the new bodies by piece + * id; the joints go in last_joints. + */ +/datum/limb_physics/proc/grow_copy(datum/limb_rig/sprites/from, list/from_segments, list/part_ids, parent, list/attach, turn, stretch = 1) + . = list() + last_joints = list() + var/list/placed = list() + for(var/part_id in part_ids) + var/list/segment = from_segments[part_id] + var/parent_id = segment["parent"] + var/joint_to = .[parent_id] || parent + var/list/at = attach + if(placed[parent_id]) + // Where its joint is on its (stretched) parent. + var/list/parent_segment = from_segments[parent_id] + var/parent_stretch = (parent_id == RIG_CHEST || parent_id == RIG_HEAD) ? 1 : stretch + var/list/parent_origin = parent_segment["origin"] + var/list/origin = segment["origin"] + at = growth_end(placed[parent_id], turn, list("origin" = parent_origin, "end" = origin), parent_stretch, 1) + var/length_scale = (part_id == RIG_CHEST || part_id == RIG_HEAD) ? 1 : stretch + // The torso stands nearly upright in the host's; everything else bends the wrong way. + var/list/band = part_id == RIG_CHEST ? list(-15, 15) : serverblight_joint_band(segment["joint"], facing) + var/layer = -2.55 + from.parts[part_id].layer / 100 + // In the ragdoll's own group, so the bodies taken in don't collide with each other or the + // body's own frame: with every one of them colliding with every other, the cost of a step + // runs away and it comes apart. + var/body = grow(from, part_id, segment, joint_to, at[1], at[2], turn, length_scale, 1, part_id == RIG_CHEST ? 8 : 30, part_id == RIG_CHEST ? 300 : 100, band[1], band[2], layer, layer + 0.0005, LIMB_PHYSICS_RAGDOLL_GROUP) + if(!body) + return + .[part_id] = body + placed[part_id] = at + last_joints[part_id] = last_growth_joint + +/** + * Makes a leg walk, rather than seize, while the body's moving: its hip and knee driven round a + * stride, starting stride radians into it. + */ +/datum/limb_physics/proc/make_leg(hip, knee, stride) + for(var/list/spasm as anything in spasms.Copy()) + if(spasm[1] == hip || spasm[1] == knee) + spasms -= list(spasm) + if(hip) + legs += list(list(hip, stride)) + if(knee) + legs += list(list(knee, stride + PI / 2)) + +/** + * Drives every leg round its stride as fast as the body's going (walk_speed, tiles a second), a + * couple of strides a tile, no faster than it can be seen. Standing still, the legs seize with the + * rest. + */ +/datum/limb_physics/proc/walk_legs() + var/moving = walk_speed > 0.5 + if(moving) + walk_phase += min(walk_speed * 0.6, 1.2) + for(var/list/leg as anything in legs) + if(moving) + vcphys_call("joint_set_motor", world_handle, leg[1], 14 * cos(TODEGREES(walk_phase + leg[2])) + rand(-30, 30) / 10, 150) + else if(prob(60)) + vcphys_call("joint_set_motor", world_handle, leg[1], 30 * pick(-1, 1) * rand(50, 150) / 100, 150) + +/** + * Dead: everything goes slack. Every motor stops, the neck comes off its pin, and the whole mass + * falls in a heap, still glued together. It's all put back by starting over (rebuild()). + */ +/datum/limb_physics/proc/go_dormant() + if(dormant) + return + dormant = TRUE + set_prey(null, null) + for(var/list/spasm as anything in spasms) + vcphys_call("joint_set_motor", world_handle, spasm[1], 0, 0) + for(var/list/leg as anything in legs) + vcphys_call("joint_set_motor", world_handle, leg[1], 0, 0) + if(neck_pin) + vcphys_call("joint_destroy", world_handle, neck_pin) + neck_pin = null + +/// Makes a body a hand with one finger: its far end, of a segment stretched this much. +/datum/limb_physics/proc/add_hand(body, list/segment, length_scale, width_scale) + if(!body) + return + hands += list(list(list(body, segment_offset(segment, length_scale, width_scale)))) + tips += body + +/// A segment's length in pixels, as build() gives its box. +/datum/limb_physics/proc/segment_length(list/segment) + var/list/origin = segment["origin"] + var/list/end = segment["end"] + return max(sqrt((end[1] - origin[1]) ** 2 + (end[2] - origin[2]) ** 2), 2) + +/// Where a point on a segment's sprite is in the world, in metres, with the segment's body in +/// state (list(x, y, angle, ...)) and its sprite hanging off origin. +/datum/limb_physics/proc/segment_point(list/state, list/origin, list/point) + var/dx = (point[1] - origin[1]) / LIMB_PHYSICS_PPM + var/dy = (point[2] - origin[2]) / LIMB_PHYSICS_PPM + var/turn = TODEGREES(state[3]) + return list(state[1] + dx * cos(turn) - dy * sin(turn), state[2] + dx * sin(turn) + dy * cos(turn)) + +/// From a segment's origin to its end, stretched, in metres, before it's turned. +/datum/limb_physics/proc/segment_offset(list/segment, length_scale, width_scale) + var/list/origin = segment["origin"] + var/list/end = segment["end"] + return list((end[1] - origin[1]) * width_scale / LIMB_PHYSICS_PPM, (end[2] - origin[2]) * length_scale / LIMB_PHYSICS_PPM) + +/// Where the far end of a growth is, grown at (x, y) turned angle. +/datum/limb_physics/proc/growth_end(list/at, angle, list/segment, length_scale, width_scale) + var/list/offset = segment_offset(segment, length_scale, width_scale) + var/turn = TODEGREES(angle) + return list(at[1] + offset[1] * cos(turn) - offset[2] * sin(turn), at[2] + offset[1] * sin(turn) + offset[2] * cos(turn)) + +/** + * Grows a copy of one of a rig's segments off a body, pinned at (x, y), its sprite turned angle + * and stretched, and drawn as that rig draws it (clothes too). Its joint turns between lower and + * upper degrees (freely if they're equal), seizing at up to motor_speed with motor_torque. It + * never sleeps, so it never stops. It collides with everything, unless it's given a group (see + * Box2D's group_index). Returns the new body's handle. + */ +/datum/limb_physics/proc/grow(datum/limb_rig/sprites/from, part_id, list/segment, parent, x, y, angle, length_scale, width_scale, motor_speed, motor_torque, lower, upper, layer, cloth_layer, group = 0) + var/list/origin = segment["origin"] + var/list/end = segment["end"] + var/body = vcphys_call("body_create", world_handle, LIMB_PHYSICS_DYNAMIC, x, y, angle, 0.05, 0.2, 0) + if(!body) + return null + // The box as build() makes it, stretched with the sprite. + var/length = segment_length(segment) * length_scale / LIMB_PHYSICS_PPM + vcphys_call("fixture_box", world_handle, body, segment["width"] * abs(width_scale) / 2 / LIMB_PHYSICS_PPM, length / 2, ((origin[1] + end[1]) / 2 - origin[1]) * width_scale / LIMB_PHYSICS_PPM, ((origin[2] + end[2]) / 2 - origin[2]) * length_scale / LIMB_PHYSICS_PPM, 0, 12, 0.7, 0.05, group) + last_growth_joint = vcphys_call("joint_revolute", world_handle, parent, body, x, y, TORADIANS(lower), TORADIANS(upper), 0) + if(last_growth_joint) + vcphys_call("joint_set_motor", world_handle, last_growth_joint, motor_speed, motor_torque) + spasms += list(list(last_growth_joint, motor_speed, motor_torque)) + + // Hung on the mob, like the legs are. + var/obj/effect/abstract/limb_rig_part/skin = rig.new_part(part_id) + skin.appearance = from.parts[part_id].appearance + skin.layer = layer + rig.owner.vis_contents += skin + var/obj/effect/abstract/limb_rig_part/cloth + var/matrix/cloth_map + if(from.cloth_parts[part_id]) + cloth = rig.new_part(part_id) + cloth.appearance = from.cloth_parts[part_id].appearance + cloth.layer = isnull(cloth_layer) ? layer + 0.05 : cloth_layer + rig.owner.vis_contents += cloth + cloth_map = from.get_cloth_map(part_id, facing) + growths += list(list(skin, cloth, body, origin, length_scale, width_scale, cloth_map)) + return body + +/// Puts every growth where the simulation has it, as draw() does the body. +/datum/limb_physics/proc/draw_growths(list/states, time) + for(var/list/growth as anything in growths) + var/list/state = states["[growth[3]]"] + if(!state) + continue + var/list/origin = growth[4] + var/matrix/segment = rig_joint_matrix(origin[1], origin[2], growth[5], -TODEGREES(state[3]), state[1] * LIMB_PHYSICS_PPM + 16.5, state[2] * LIMB_PHYSICS_PPM, growth[6]) + animate(growth[1], transform = segment, time = time) + if(growth[2]) + animate(growth[2], transform = growth[7] * segment, time = time) + +/// Every joint that's seizing lurches one way or the other, every few ticks (its motor keeps going the +/// way it last lurched in between). +/datum/limb_physics/proc/seize() + for(var/list/spasm as anything in spasms) + if(prob(30)) + vcphys_call("joint_set_motor", world_handle, spasm[1], spasm[2] * pick(-1, 1) * rand(50, 150) / 100, spasm[3]) + +/** + * Pulls every fingertip: outward, stretching the hands as far as they go, or with someone in + * reach (see set_prey()), at them. + */ +/datum/limb_physics/proc/pull_tips() + var/list/chest = last_states?["[body_by_part[RIG_CHEST]]"] + if(!chest) + return + for(var/tip in tips) + var/list/state = last_states["[tip]"] + if(!state) + continue + if(!isnull(prey_x)) + var/dx = prey_x - state[1] + var/dy = prey_y + SERVERBLIGHT_PREY_HALF_HEIGHT - state[2] + var/distance = max(sqrt(dx ** 2 + dy ** 2), 0.01) + vcphys_call("body_force", world_handle, tip, 140 * dx / distance, 140 * dy / distance) + continue + var/dx = state[1] - chest[1] + var/dy = state[2] - chest[2] + var/distance = max(sqrt(dx ** 2 + dy ** 2), 0.01) + vcphys_call("body_force", world_handle, tip, 60 * dx / distance, 60 * dy / distance + 15) + +/** + * Tells the hands someone's in reach: their feet at (x, y) metres in the body's frame, or null + * when nobody is. The long arms go slack enough to wrap round them while someone is. + */ +/datum/limb_physics/proc/set_prey(x, y) + var/was_grabbing = !isnull(prey_x) + prey_x = x + prey_y = y + var/grabbing = !isnull(x) + if(!grabbing) + latched.Cut() + if(grabbing == was_grabbing) + return + var/limit = TORADIANS(grabbing ? 80 : 25) + for(var/joint in chain_joints) + vcphys_call("joint_set_limits", world_handle, joint, -limit, limit) + +/** + * How many hands are holding whoever's in reach. A hand takes hold with a fingertip within 0.3 + * metres of them, and keeps it until it's dragged more than 0.6 metres off. + */ +/datum/limb_physics/proc/count_gripping_hands() + if(isnull(prey_x) || !last_states) + return 0 + . = 0 + for(var/hand_index in 1 to length(hands)) + var/list/hand = hands[hand_index] + var/closest = INFINITY + for(var/list/finger as anything in hand) + var/list/state = last_states["[finger[1]]"] + if(!state) + continue + var/list/offset = finger[2] + var/turn = TODEGREES(state[3]) + var/tip_x = state[1] + offset[1] * cos(turn) - offset[2] * sin(turn) + var/tip_y = state[2] + offset[1] * sin(turn) + offset[2] * cos(turn) + // How far outside their box the tip is (0 inside it). + closest = min(closest, max(abs(tip_x - prey_x) - SERVERBLIGHT_PREY_HALF_WIDTH, abs(tip_y - prey_y - SERVERBLIGHT_PREY_HALF_HEIGHT) - SERVERBLIGHT_PREY_HALF_HEIGHT, 0)) + var/holding = closest <= (latched["[hand_index]"] ? 0.6 : 0.3) + latched["[hand_index]"] = holding + if(holding) + .++ + +/// Walls and dense furniture on the tiles around the victim, as they're drawn around it: the body +/// is simulated on its own tile, a tile being 32 pixels. Replaces the last lot, as it moves. +/datum/limb_physics/proc/add_surroundings() + for(var/handle in surroundings) + vcphys_call("body_destroy", world_handle, handle) + surroundings.Cut() + var/turf/centre = get_turf(rig.owner) + if(!centre) + return + var/tile = SERVERBLIGHT_TILE_METRES + for(var/dx in -1 to 1) + for(var/dy in -1 to 1) + if(!dx && !dy) + continue + var/turf/spot = locate(centre.x + dx, centre.y + dy, centre.z) + if(!spot) + continue + var/half + if(spot.density) + half = tile / 2 + else + for(var/obj/thing in spot) + if(thing.density) + half = tile * 0.35 + break + if(!half) + continue + var/handle = vcphys_call("body_create", world_handle, LIMB_PHYSICS_STATIC, dx * tile, (dy + 0.5) * tile, 0, 0, 0) + if(handle && vcphys_call("fixture_box", world_handle, handle, half, half, 0, 0, 0, 0, 0.8, 0, 0)) + surroundings += handle + +/// Whether any piece has come away from the torso further than it ever should (see SERVERBLIGHT_TORN_REACH). +/datum/limb_physics/proc/is_torn(list/states) + var/list/chest = states["[body_by_part[RIG_CHEST]]"] + if(!chest) + return FALSE + // (The walls and floor it's simulated in are all well inside that.) + for(var/handle in states) + var/list/state = states[handle] + if((state[1] - chest[1]) ** 2 + (state[2] - chest[2]) ** 2 > SERVERBLIGHT_TORN_REACH ** 2) + return TRUE + return FALSE + +/// Takes away everything Serverblight grew. +/datum/limb_physics/proc/clear_growths() + for(var/list/growth as anything in growths) + for(var/obj/effect/abstract/limb_rig_part/piece in growth.Copy(1, 3)) + rig?.owner?.vis_contents -= piece + qdel(piece) + growths.Cut() + tips.Cut() + spasms.Cut() + hands.Cut() + chain_joints.Cut() + legs.Cut() + glued.Cut() + surroundings.Cut() + latched.Cut() + prey_x = null + prey_y = null diff --git a/voidcrew/modules/limb_rig/physics/serverblight_chase.dm b/voidcrew/modules/limb_rig/physics/serverblight_chase.dm new file mode 100644 index 00000000000..e6ec03a3656 --- /dev/null +++ b/voidcrew/modules/limb_rig/physics/serverblight_chase.dm @@ -0,0 +1,539 @@ +/** + * Serverblight's hunt: the one thing in the game that doesn't move tile to tile. + * + * The blighted body is a puck in a top-down Box2D world of its own (no gravity, a metre to a tile), + * walled in by the dense tiles and furniture around it. It's pushed after whoever's nearest, so it + * speeds up, overshoots, slides along walls and lurches sideways, the way things move in SS14 + * rather than the way mobs walk here. The mob follows the puck: onto whichever tile it's over, and + * pixel-shifted the rest of the way, so it's drawn exactly where it is between tiles. + * + * Close enough, the ragdoll's hands go for its prey (see /datum/limb_physics/proc/set_prey()). + * Each hand that has hold of them slows them down more, until they tear loose or get away, and + * every hand on them every tick brings them closer to being taken: they leave their body, and + * their body is folded into the mass, bringing more hands. + * + * With no gravity it can't walk: it throws itself off whatever it can reach (walls, furniture, + * lattice, people) and drifts in between, so open space is somewhere to get away to. + * + * Every head in it screams, all the time, each in its own voice, distorted. + * + * It can be killed like anyone, but everyone it's taken in is more of it to get through: + * SERVERBLIGHT_HEALTH_PER_BODY more health each, and more of it to hit: a shot through a tile its + * mass sprawls over hits it (see update_hitboxes()). Damage doesn't slow it (nothing does: it isn't walking), but a hit + * jerks the body, and a gun that kicks, or a shot that would knock someone down, shoves it back. + * Dead, it falls in a heap for SERVERBLIGHT_DEATH_TIME, then gets back up, whole. Something it + * can't get back up from (no head, say) ends it. + */ +/datum/serverblight_chase + /// The ragdoll this is moving about. + var/datum/limb_physics/physics + /// The blighted mob. + var/mob/living/carbon/host + /// The top-down world, as the library's handle. + var/world_handle + /// The puck the mob follows. + var/body + /// The walls' handles, rebuilt as it goes. + var/list/wall_bodies = list() + /// Where the walls were last built around. + var/turf/walls_centre + /// The tile the mob was last put on, to notice anyone else moving it. + var/turf/last_turf + /// Who it's after, if anyone. + var/mob/living/carbon/prey + /// The way to them, when they aren't in a straight line: turfs, next one first. + var/list/path + /// Fires so far. + var/fires = 0 + /// How near the prey is to being taken: a point for every hand on them, every fire. + var/grip = 0 + /// Everyone it has taken, held inside the mob. + var/list/absorbed = list() + /// Whoever its hands are slowing down, if anyone. + var/mob/living/carbon/held + /// When each voice screams next, by its place in get_voices(), as text. + var/list/next_screams = list() + /// Invisible stand-ins on the tiles round it that its mass sprawls over, taking shots for it. + var/list/obj/effect/serverblight_hitbox/hitboxes = list() + /// How much health everyone it's taken in has added to it. + var/bonus_health = 0 + /// The fire it can next throw itself off something, with no gravity. + var/next_pushoff = 0 + /// The timer getting it back up, while it's dead. + var/rise_timer + /// Whether the mob could already pass through other mobs before this. + var/had_passmob + +/datum/serverblight_chase/New(datum/limb_physics/physics) + src.physics = physics + host = physics.rig.owner + physics.chase = src + var/turf/here = get_turf(host) + world_handle = vcphys_call("world_create", 0, 0) + body = world_handle && vcphys_call("body_create", world_handle, LIMB_PHYSICS_DYNAMIC, here.x + 0.5, here.y + 0.5, 0, 0, 0, 0, 1) + // A kilogram, so an impulse is the change in speed. + if(!body || !vcphys_call("fixture_circle", world_handle, body, 0.3, 0, 0, 1 / (PI * 0.3 ** 2), 0, 0, 0)) + qdel(src) + return + last_turf = here + had_passmob = host.pass_flags & PASSMOB + // Over people rather than stopped by them: it has to get its hands round them. + host.pass_flags |= PASSMOB + // Sliding every which way without turning round (turning would start the ragdoll over). + host.set_dir_on_move = FALSE + // Falling over is physics' job: the mob itself stays upright. + host.rotate_on_lying = FALSE + host.update_transform() + RegisterSignal(host, COMSIG_ATOM_BULLET_ACT, PROC_REF(on_shot)) + // Drifting is the puck's job too: no space drift on the mob. + RegisterSignal(host, COMSIG_MOVABLE_SPACEMOVE, PROC_REF(on_spacemove)) + QDEL_NULL(host.drift_handler) + build_walls() + START_PROCESSING(SSlimb_physics, src) + +/datum/serverblight_chase/Destroy() + STOP_PROCESSING(SSlimb_physics, src) + deltimer(rise_timer) + QDEL_LIST(hitboxes) + hold(null, 0) + if(world_handle) + vcphys_call("world_destroy", world_handle) + world_handle = null + var/turf/drop = get_turf(host) + for(var/mob/living/carbon/taken as anything in absorbed) + if(!QDELETED(taken) && taken.loc == host) + let_go(taken, drop) + absorbed.Cut() + if(!QDELETED(host)) + host.maxHealth -= bonus_health + host.updatehealth() + host.remove_offsets(SERVERBLIGHT_TRAIT, animate = FALSE) + host.set_dir_on_move = TRUE + host.rotate_on_lying = TRUE + host.update_transform() + UnregisterSignal(host, list(COMSIG_ATOM_BULLET_ACT, COMSIG_MOVABLE_SPACEMOVE)) + if(!had_passmob) + host.pass_flags &= ~PASSMOB + if(physics?.chase == src) + physics.chase = null + physics.set_prey(null, null) + physics = null + host = null + prey = null + path = null + return ..() + +/datum/serverblight_chase/process(seconds_per_tick) + if(QDELETED(host) || QDELETED(physics)) + qdel(src) + return PROCESS_KILL + if(host.stat == DEAD) + if(!physics.dormant) + die() + return + // Back up early (someone revived it). + if(physics.dormant) + rise() + return + scream() + // Shut in something, or buckled: wait it out. + if(!isturf(host.loc) || host.buckled) + return + fires++ + var/turf/here = host.loc + // Moved by something else (pulled, thrown, teleported): pick up from there. + if(here != last_turf) + vcphys_call("body_set_transform", world_handle, body, here.x + 0.5, here.y + 0.5, 0) + vcphys_call("body_set_velocity", world_handle, body, 0, 0, 0) + last_turf = here + host.remove_offsets(SERVERBLIGHT_TRAIT, animate = FALSE) + build_walls() + physics.add_surroundings() + else if(fires % 10 == 0 || get_dist(here, walls_centre) > 2) + build_walls() + if(fires % 5 == 0 || !is_prey(prey)) + find_prey() + var/list/state = read_puck() + if(!state) + qdel(src) + return PROCESS_KILL + steer(state) + if(!vcphys_call("world_step", world_handle, LIMB_PHYSICS_DT, LIMB_PHYSICS_VELOCITY_ITERATIONS, LIMB_PHYSICS_POSITION_ITERATIONS, LIMB_PHYSICS_STEPS_PER_FIRE)) + qdel(src) + return PROCESS_KILL + state = read_puck() + if(!state) + qdel(src) + return PROCESS_KILL + place(state[1], state[2]) + // Nothing to walk on, the legs just seize. + physics.walk_speed = host.has_gravity() ? sqrt(state[4] ** 2 + state[5] ** 2) : 0 + update_hitboxes() + reach() + +/// The puck: list(x, y, angle, vx, vy, spin), in tiles. Null if it can't be read. +/datum/serverblight_chase/proc/read_puck() + var/reply = vcphys_call("body_read", world_handle, body) + var/list/fields = reply && splittext(reply, " ") + if(length(fields) != 7) + return null + . = list() + for(var/i in 2 to 7) + var/value = text2num(fields[i]) + if(!isnum(value) || value != value) + return null + . += value + +/// Walls round the mob, a tile's radius past what it can see: anything it couldn't walk into. +/datum/serverblight_chase/proc/build_walls() + for(var/wall in wall_bodies) + vcphys_call("body_destroy", world_handle, wall) + wall_bodies.Cut() + walls_centre = get_turf(host) + if(!walls_centre) + return + var/range = SERVERBLIGHT_SIGHT + 1 + for(var/turf/spot as anything in RANGE_TURFS(range, walls_centre)) + if(spot == walls_centre || !spot.is_blocked_turf(TRUE, host)) + continue + var/wall = vcphys_call("body_create", world_handle, LIMB_PHYSICS_STATIC, spot.x + 0.5, spot.y + 0.5, 0, 0, 0) + if(wall && vcphys_call("fixture_box", world_handle, wall, 0.5, 0.5, 0, 0, 0, 0, 0, 0, 0)) + wall_bodies += wall + // The map's edge. + for(var/list/edge in list(list(0.5, world.maxy / 2, 0.5, world.maxy), list(world.maxx + 0.5, world.maxy / 2, 0.5, world.maxy), list(world.maxx / 2, 0.5, world.maxx, 0.5), list(world.maxx / 2, world.maxy + 0.5, world.maxx, 0.5))) + var/wall = vcphys_call("body_create", world_handle, LIMB_PHYSICS_STATIC, edge[1], edge[2], 0, 0, 0) + if(wall && vcphys_call("fixture_box", world_handle, wall, edge[3], edge[4], 0, 0, 0, 0, 0, 0, 0)) + wall_bodies += wall + +/// Whether someone is worth hunting. +/datum/serverblight_chase/proc/is_prey(mob/living/carbon/who) + return istype(who) && !QDELETED(who) && who != host && who.stat != DEAD && isturf(who.loc) && who.z == host.z && !(who in absorbed) && !who.limb_rig?.physics?.blighted && get_dist(who, host) <= SERVERBLIGHT_SIGHT + +/// The nearest person it can see, players first. +/datum/serverblight_chase/proc/find_prey() + var/mob/living/carbon/best + var/best_score = INFINITY + for(var/mob/living/carbon/who in view(SERVERBLIGHT_SIGHT, host)) + if(!is_prey(who)) + continue + var/score = get_dist(who, host) + (who.client ? 0 : 100) + if(score < best_score) + best = who + best_score = score + if(best != prey) + path = null + prey = best + +/** + * Pushes the puck toward its prey: straight at them if nothing's in the way, else along a path + * round. Not smoothly: it lurches sideways and stops dead now and then, and never quite goes + * where it means to. + */ +/datum/serverblight_chase/proc/steer(list/state) + var/goal_x + var/goal_y + if(prey) + var/turf/prey_turf = get_turf(prey) + if(get_dist(host, prey) <= 1 || !length(get_line_blockers(prey_turf))) + path = null + goal_x = prey_turf.x + 0.5 + goal_y = prey_turf.y + 0.5 + else + if(!length(path) || fires % 10 == 0) + path = get_path_to(host, prey_turf, SERVERBLIGHT_SIGHT * 3, simulated_only = FALSE) + while(length(path)) + var/turf/next = path[1] + if(abs(next.x + 0.5 - state[1]) >= 0.35 || abs(next.y + 0.5 - state[2]) >= 0.35) + goal_x = next.x + 0.5 + goal_y = next.y + 0.5 + break + path.Cut(1, 2) + var/want_x = 0 + var/want_y = 0 + if(!isnull(goal_x) && !prob(6)) + var/dx = goal_x - state[1] + var/dy = goal_y - state[2] + var/distance = sqrt(dx ** 2 + dy ** 2) + if(distance > 0.05) + var/speed = min(SERVERBLIGHT_SPEED, distance * 6) + want_x = dx / distance * speed + want_y = dy / distance * speed + if(!host.has_gravity()) + // Nothing to walk on: it throws itself off whatever it can reach, now and then, straight + // at where it wants to be, and drifts until it next can. + if(fires < next_pushoff || !can_push_off()) + return + next_pushoff = fires + SERVERBLIGHT_PUSHOFF_FIRES + vcphys_call("body_impulse", world_handle, body, want_x - state[4], want_y - state[5]) + return + // Most of the way to the speed it wants each tick, no harder than about four gees. + var/push_x = (want_x - state[4]) * 0.6 + var/push_y = (want_y - state[5]) * 0.6 + var/push = sqrt(push_x ** 2 + push_y ** 2) + if(push > 4) + push_x *= 4 / push + push_y *= 4 / push + if(prob(12)) + // A lurch across the way it's going. + var/across = pick(-1, 1) * rand(20, 40) / 10 + var/speed = max(sqrt(state[4] ** 2 + state[5] ** 2), 0.1) + push_x += -state[5] / speed * across + push_y += state[4] / speed * across + vcphys_call("body_impulse", world_handle, body, push_x, push_y) + +/// Opaque or dense turfs between the mob and a turf. +/datum/serverblight_chase/proc/get_line_blockers(turf/target) + . = list() + for(var/turf/spot as anything in get_line(get_turf(host), target)) + if(spot.opacity || spot.is_blocked_turf(TRUE, host)) + . += spot + +/// Puts the mob where the puck is: on the tile under it, shifted the rest of the way. +/datum/serverblight_chase/proc/place(x, y) + var/turf/target = locate(floor(x), floor(y), host.z) + var/old_w = host.pixel_w + var/old_z = host.pixel_z + if(target && target != last_turf) + var/turf/from = last_turf + host.Move(target, get_dir(from, target), world.icon_size) + var/turf/now = get_turf(host) + if(now != target) + // Something the walls didn't have (someone's door shut, a wall went up): back onto the + // tile it's on, stopped. + x = clamp(x, now.x + 0.2, now.x + 0.8) + y = clamp(y, now.y + 0.2, now.y + 0.8) + vcphys_call("body_set_transform", world_handle, body, x, y, 0) + vcphys_call("body_set_velocity", world_handle, body, 0, 0, 0) + walls_centre = null + old_w += (from.x - now.x) * world.icon_size + old_z += (from.y - now.y) * world.icon_size + last_turf = now + physics.add_surroundings() + // Offset the rest of the way, easing there over the tick from where it's drawn now. + host.add_offsets(SERVERBLIGHT_TRAIT, round((x - last_turf.x - 0.5) * world.icon_size), null, null, round((y - last_turf.y - 0.5) * world.icon_size), animate = FALSE) + var/new_w = host.pixel_w + var/new_z = host.pixel_z + host.pixel_w = old_w + host.pixel_z = old_z + animate(host, pixel_w = new_w, pixel_z = new_z, time = SSlimb_physics.wait) + +/** + * Tells the ragdoll's hands where the prey is, if it's in reach, and takes them if every hand's + * been on them long enough. Any hand on them holds them. + */ +/datum/serverblight_chase/proc/reach() + if(!prey) + physics.set_prey(null, null) + hold(null, 0) + grip = 0 + return + var/list/puck = read_puck() + // Where they stand in the ragdoll's frame: a tile is SERVERBLIGHT_TILE_METRES across, and a + // tile further north is drawn a tile higher. + var/dx = (prey.x + 0.5 - puck[1]) * SERVERBLIGHT_TILE_METRES + var/dy = (prey.y + 0.5 - puck[2]) * SERVERBLIGHT_TILE_METRES + if(abs(dx) > 2 * SERVERBLIGHT_TILE_METRES || abs(dy) > 2 * SERVERBLIGHT_TILE_METRES) + physics.set_prey(null, null) + hold(null, 0) + grip = 0 + return + physics.set_prey(dx, dy) + var/holding = physics.count_gripping_hands() + hold(prey, holding) + grip = holding ? grip + holding : max(grip - 2, 0) + if(grip >= SERVERBLIGHT_ASSIMILATION) + absorb(prey) + +/// Takes someone: out of their body, which goes into the mass. +/datum/serverblight_chase/proc/absorb(mob/living/carbon/taken) + grip = 0 + prey = null + path = null + physics.set_prey(null, null) + hold(null, 0) + playsound(host, 'sound/effects/wounds/crackandbleed.ogg', 100, TRUE) + taken.visible_message(span_danger("[host] folds [taken] into itself."), span_userdanger("Every hand closes. You aren't in there any more.")) + log_admin("Serverblight ([key_name(host)]) took [key_name(taken)] at [AREACOORD(host)].") + // Out of their body, and kept out of it for now, but still its owner: they're put back when it's let go. + var/mob/dead/observer/ghost = taken.ghostize(can_reenter_corpse = TRUE) + if(ghost) + ghost.can_reenter_corpse = FALSE + taken.set_limb_physics(FALSE) + absorbed += taken + bonus_health += SERVERBLIGHT_HEALTH_PER_BODY + host.maxHealth += SERVERBLIGHT_HEALTH_PER_BODY + host.updatehealth() + ADD_TRAIT(taken, TRAIT_IMMOBILIZED, SERVERBLIGHT_TRAIT) + ADD_TRAIT(taken, TRAIT_HANDS_BLOCKED, SERVERBLIGHT_TRAIT) + // Drawn from how they look before they're out of sight. + physics.merge(taken) + taken.forceMove(host) + // Held as they are in there, not suffocating in it. + taken.apply_status_effect(/datum/status_effect/grouped/stasis, SERVERBLIGHT_TRAIT) + +/** + * Lets someone it took go, out onto drop: out of stasis, dead (nobody comes out of it alive), and + * whoever played them back in their body, to be revived like anyone dead. + */ +/datum/serverblight_chase/proc/let_go(mob/living/carbon/taken, turf/drop) + taken.forceMove(drop) + REMOVE_TRAITS_IN(taken, SERVERBLIGHT_TRAIT) + taken.remove_status_effect(/datum/status_effect/grouped/stasis, SERVERBLIGHT_TRAIT) + if(taken.stat != DEAD) + taken.death() + var/mob/dead/observer/ghost = taken.get_ghost(even_if_they_cant_reenter = TRUE) + if(ghost) + ghost.can_reenter_corpse = TRUE + ghost.reenter_corpse() + taken.visible_message(span_danger("[taken] slides out of [host], limp.")) + +/// Slows someone down for every hand on them, letting go of whoever it was holding before. +/datum/serverblight_chase/proc/hold(mob/living/carbon/who, hands) + if(held && (held != who || !hands)) + held.remove_movespeed_modifier(/datum/movespeed_modifier/serverblight_grip) + held = null + if(!who || !hands) + return + if(!held) + to_chat(who, span_userdanger("Long fingers close around you.")) + held = who + who.add_or_update_variable_movespeed_modifier(/datum/movespeed_modifier/serverblight_grip, multiplicative_slowdown = clamp(hands * SERVERBLIGHT_GRIP_SLOWDOWN, 0, SERVERBLIGHT_GRIP_MAX_SLOWDOWN)) + +/// Serverblight's hands on someone: slower for each one. +/datum/movespeed_modifier/serverblight_grip + variable = TRUE + +/// Everyone screaming in it: its own head twice (it has more than one), then everyone it's taken. +/datum/serverblight_chase/proc/get_voices() + return list(host, host) + absorbed + +/// Every voice screams again when it's done, each a little apart: pitched down and doubled out of +/// tune, a quarter of them backwards. +/datum/serverblight_chase/proc/scream() + var/list/voices = get_voices() + for(var/index in 1 to length(voices)) + if(world.time < (next_screams["[index]"] || 0)) + continue + next_screams["[index]"] = world.time + rand(8, 22) + var/scream = get_serverblight_scream(voices[index]) + var/pitch = rand(55, 85) / 100 * (prob(25) ? -1 : 1) + playsound(host, scream, 55, FALSE, 4, frequency = pitch) + playsound(host, scream, 40, FALSE, 4, frequency = pitch * 1.06) + +/// Dead: slack, silent, and still for a while. +/datum/serverblight_chase/proc/die() + physics.go_dormant() + hold(null, 0) + prey = null + path = null + grip = 0 + next_screams.Cut() + vcphys_call("body_set_velocity", world_handle, body, 0, 0, 0) + // Lying dead, it's only where it lies. + for(var/obj/effect/serverblight_hitbox/hitbox as anything in hitboxes) + hitbox.moveToNullspace() + host.visible_message(span_danger("[host] comes apart and lies still.")) + rise_timer = addtimer(CALLBACK(src, PROC_REF(rise)), SERVERBLIGHT_DEATH_TIME, TIMER_STOPPABLE|TIMER_UNIQUE) + +/// Gets back up, whole (limbs aside: without a head, it stays down), and starts over. +/datum/serverblight_chase/proc/rise() + deltimer(rise_timer) + rise_timer = null + if(QDELETED(host) || QDELETED(physics)) + return + if(host.stat == DEAD) + host.revive(HEAL_DAMAGE|HEAL_ORGANS|HEAL_REFRESH_ORGANS|HEAL_WOUNDS|HEAL_BLOOD|HEAL_TEMP, force_grab_ghost = TRUE) + if(host.stat == DEAD) + host.visible_message(span_notice("[host] twitches once, and doesn't get up.")) + qdel(physics) + return + physics.rebuild() + if(QDELETED(physics)) + return + host.visible_message(span_danger("[host] pulls itself back up, every joint the wrong way.")) + playsound(host, 'sound/effects/wounds/crack2.ogg', 100, TRUE) + +/** + * Shot: the hit jerks the body. It only moves it if the gun kicks, or the shot would knock someone + * down: then it's shoved back, harder the more it kicks. + */ +/datum/serverblight_chase/proc/on_shot(mob/living/source, obj/projectile/shot, def_zone, piercing_hit, blocked) + SIGNAL_HANDLER + if(!istype(shot) || QDELETED(physics)) + return + var/dx = sin(shot.angle) + var/dy = cos(shot.angle) + physics.push(RIG_CHEST, dx * shot.damage * 0.6, abs(dy) * shot.damage * 0.3) + var/obj/item/gun/gun = shot.fired_from + var/shove = (istype(gun) ? max(gun.recoil, 0) : 0) + (shot.knockdown ? 2 : 0) + if(shove) + vcphys_call("body_impulse", world_handle, body, dx * shove * 1.5, dy * shove * 1.5) + +/// The scream a body makes, as its species screams, or a person's for anything that doesn't. +/proc/get_serverblight_scream(mob/living/carbon/voice) + var/mob/living/carbon/human/human = voice + if(istype(human)) + . = human.dna?.species?.get_scream_sound(human) + if(islist(.)) + . = pick(.) + if(!.) + . = pick('sound/mobs/humanoids/human/scream/malescream_1.ogg', 'sound/mobs/humanoids/human/scream/femalescream_1.ogg') + +/// Whether there's anything in reach to throw itself off: anything solid, or lattice. +/datum/serverblight_chase/proc/can_push_off() + return host.get_spacemove_backup() || (locate(/obj/structure/lattice) in range(1, get_turf(host))) + +/datum/serverblight_chase/proc/on_spacemove(atom/movable/source, movement_dir, continuous_move) + SIGNAL_HANDLER + return COMSIG_MOVABLE_STOP_SPACEMOVE + +/** + * Everyone it's taken in makes it bigger to hit: two more of the tiles round it for each, the ones + * its mass sprawls over (either side first, then above), wherever there's room. + */ +/datum/serverblight_chase/proc/update_hitboxes() + var/static/list/sprawl = list(EAST, WEST, NORTH, NORTHEAST, NORTHWEST) + var/wanted = min(length(absorbed) * 2, length(sprawl)) + while(length(hitboxes) < wanted) + hitboxes += new /obj/effect/serverblight_hitbox(null, src) + var/turf/here = get_turf(host) + for(var/index in 1 to length(hitboxes)) + var/obj/effect/serverblight_hitbox/hitbox = hitboxes[index] + var/turf/spot = here && get_step(here, sprawl[index]) + if(!spot || spot.density) + if(hitbox.loc) + hitbox.moveToNullspace() + continue + if(hitbox.loc != spot) + hitbox.forceMove(spot) + +/// Where Serverblight's mass is, but not its body: stops shots, and hands them to the body. +/obj/effect/serverblight_hitbox + name = "Serverblight" + invisibility = INVISIBILITY_ABSTRACT + anchored = TRUE + // Dense, so shots stop here; everything else is let through (see CanAllowThrough()). + density = TRUE + /// The hunt it's part of. + var/datum/serverblight_chase/chase + +/obj/effect/serverblight_hitbox/Initialize(mapload, datum/serverblight_chase/chase) + . = ..() + src.chase = chase + +/obj/effect/serverblight_hitbox/Destroy() + chase = null + return ..() + +/obj/effect/serverblight_hitbox/CanAllowThrough(atom/movable/mover, border_dir) + . = ..() + return . || !isprojectile(mover) + +/obj/effect/serverblight_hitbox/bullet_act(obj/projectile/proj, def_zone, piercing_hit = FALSE, blocked = 0) + . = ..() + if(. != BULLET_ACT_HIT) + return + var/mob/living/carbon/host = chase?.host + if(QDELETED(host) || host.stat == DEAD) + return BULLET_ACT_FORCE_PIERCE + return host.bullet_act(proj, def_zone, piercing_hit, host.check_projectile_armor(def_zone, proj)) diff --git a/voidcrew/modules/limb_rig/physics/vcphys.dm b/voidcrew/modules/limb_rig/physics/vcphys.dm new file mode 100644 index 00000000000..c8a283fd8c3 --- /dev/null +++ b/voidcrew/modules/limb_rig/physics/vcphys.dm @@ -0,0 +1,75 @@ +/** + * vcphys: Box2D for the limb rig, in a native library. + * + * The source is in tools/vcphys (Rust, on box2d-rs, a port of Box2D). It builds to vcphys.dll, + * which sits next to the game like rust_g.dll (32-bit, like BYOND); libvcphys.so on Linux. + * + * Box2D's worlds, bodies, fixtures and joints all live in the library. The game only holds their + * handles, as numbers, and talks to it through call_ext: text in, text out. Every reply is a value, + * "OK", or "ERR:". vcphys_call() turns an ERR into a stack trace and a null, so a bad call is + * easy to spot and never crashes anything. + * + * Without the library (it isn't built for the unit tests' Linux, say), vcphys_available() is FALSE + * and nothing physical happens. + */ + +/// Where the library was found, FALSE if it couldn't be, or null before anyone's asked. +GLOBAL_VAR(vcphys_library) +/// Whether every call and reply is kept, for debugging (see the Limb Physics: Inspect verb). +GLOBAL_VAR_INIT(vcphys_logging, FALSE) +/// The last calls and replies, while logging. +GLOBAL_LIST_EMPTY(vcphys_log) + +/// How many calls the log keeps. +#define VCPHYS_LOG_LENGTH 60 + +/proc/vcphys_library_path() + if(world.system_type == UNIX) + return fexists("./libvcphys.so") ? "./libvcphys.so" : "libvcphys.so" + return "vcphys" + +/// Whether the library is there and answering. Checked once, then remembered. +/proc/vcphys_available() + if(isnull(GLOB.vcphys_library)) + var/path = vcphys_library_path() + var/version + try + version = call_ext(path, "vcphys_version")() + catch + version = null + if(istext(version) && findtext(version, "vcphys") == 1) + GLOB.vcphys_library = path + log_world("vcphys: loaded [version] from [path]") + else + GLOB.vcphys_library = FALSE + log_world("vcphys: [path] isn't there or isn't answering. Limb physics is off.") + return !!GLOB.vcphys_library + +/** + * Calls one of the library's commands (see tools/vcphys/src/lib.rs), without its "vcphys_" prefix. + * Numbers are sent with enough digits not to lose anything. Returns the reply as text, or null if + * the library isn't there or said ERR (which leaves a stack trace saying why). + */ +/proc/vcphys_call(command, ...) + if(!vcphys_available()) + return null + var/list/call_args = list() + for(var/i in 2 to length(args)) + var/value = args[i] + call_args += isnum(value) ? num2text(value, 12) : "[value]" + var/reply + try + reply = call_ext(GLOB.vcphys_library, "vcphys_[command]")(arglist(call_args)) + catch(var/exception/error) + stack_trace("vcphys: calling [command] failed: [error]") + return null + if(GLOB.vcphys_logging) + GLOB.vcphys_log += "[command]([jointext(call_args, ", ")]) -> [length(reply) > 200 ? "[copytext(reply, 1, 200)]..." : reply]" + if(length(GLOB.vcphys_log) > VCPHYS_LOG_LENGTH) + GLOB.vcphys_log.Cut(1, 2) + if(!istext(reply) || findtext(reply, "ERR:") == 1) + stack_trace("vcphys: [command]([jointext(call_args, ", ")]) said [reply]") + return null + return reply + +#undef VCPHYS_LOG_LENGTH diff --git a/voidcrew/modules/limb_rig/preference.dm b/voidcrew/modules/limb_rig/preference.dm new file mode 100644 index 00000000000..e65c3c5027b --- /dev/null +++ b/voidcrew/modules/limb_rig/preference.dm @@ -0,0 +1,29 @@ +/// Whether a player's own character is drawn on the generated, animated body, off unless they turn it +/// on. Species that only look right rigged (Experiments, Milkies) are rigged either way, as is anyone +/// with the Overanimated quirk; bodies nobody plays (rhythm battle opponents, the gags' extras) are too. +/datum/preference/toggle/limb_rig + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + savefile_key = "limb_rig" + savefile_identifier = PREFERENCE_PLAYER + default_value = FALSE + +/datum/preference/toggle/limb_rig/apply_to_client(client/client, value) + var/mob/living/carbon/human/body = client?.mob + if(istype(body)) + body.set_limb_rig_wanted(value) + +/mob/living/carbon/human + /// Whether whoever plays this body wants it rigged (see /datum/preference/toggle/limb_rig), and so + /// whether it is until someone does. Kept after they leave it. + var/limb_rig_wanted = TRUE + +/mob/living/carbon/human/Login() + . = ..() + if(. && client?.prefs) + set_limb_rig_wanted(client.prefs.read_preference(/datum/preference/toggle/limb_rig)) + +/mob/living/carbon/human/proc/set_limb_rig_wanted(wanted) + if(limb_rig_wanted == wanted) + return + limb_rig_wanted = wanted + update_limb_rig() diff --git a/voidcrew/modules/limb_rig/quirk.dm b/voidcrew/modules/limb_rig/quirk.dm new file mode 100644 index 00000000000..fcb91a53c28 --- /dev/null +++ b/voidcrew/modules/limb_rig/quirk.dm @@ -0,0 +1,22 @@ +/// Everything you do, your body acts out: see limb_rig.dm. Roundstart species are rigged anyway +/// (species_shapes.dm); this is for anyone else human-shaped. +/datum/quirk/overanimated + name = "Overanimated" + desc = "Your whole body acts out everything you do: walking, running, working with tools, every emote. \ + Far too much. Purely cosmetic." + icon = FA_ICON_PERSON_RUNNING + value = 0 + gain_text = span_notice("You feel extremely expressive.") + lose_text = span_notice("You feel stiff.") + medical_record_text = "Patient cannot stop gesticulating." + +/datum/quirk/overanimated/add(client/client_source) + var/mob/living/carbon/carbon_holder = quirk_holder + if(istype(carbon_holder)) + carbon_holder.update_limb_rig() + +/datum/quirk/overanimated/remove() + var/mob/living/carbon/carbon_holder = quirk_holder + if(!istype(carbon_holder) || QDELETED(carbon_holder)) + return + carbon_holder.update_limb_rig() diff --git a/voidcrew/modules/limb_rig/species_shapes.dm b/voidcrew/modules/limb_rig/species_shapes.dm new file mode 100644 index 00000000000..c3f866f5d71 --- /dev/null +++ b/voidcrew/modules/limb_rig/species_shapes.dm @@ -0,0 +1,61 @@ +/** + * Every roundstart species is animated like the Experiment: always rigged, on a generated body a + * quarter bigger than a human with their own head on top (see humanoid_rig.dm). The Overanimated + * quirk is left for anyone else human-shaped. + * + * Tails (lizard, felinid) come off the torso onto the tail bone and swing from the small of the + * back, with every breath and pose. See limb_rig.dm for the keys. + */ + +/// The generated body: its sprites, bones, and how clothes are stretched onto it. +#define HUMANOID_RIG_BODY \ + "always" = TRUE, \ + "optional" = TRUE, \ + "rig_type" = /datum/limb_rig/sprites/humanoid, \ + "sprites" = 'voidcrew/modules/limb_rig/icons/humanoid.dmi', \ + "cloth_masks" = 'voidcrew/modules/limb_rig/icons/humanoid_cloth_masks.dmi', \ + "skeleton" = list( \ + "south" = list("l_arm" = list(list(22.85, 24.9), list(22.98, 19.4), list(22.85, 14.1)), "r_arm" = list(list(10.15, 24.9), list(10.02, 19.4), list(10.15, 14.1)), "l_leg" = list(list(19.05, 15.1), list(19.05, 8.7), list(19.05, 2.7), list(19.05, 0.5)), "r_leg" = list(list(13.95, 15.1), list(13.95, 8.7), list(13.95, 2.7), list(13.95, 0.5)), "head" = list(16.5, 26.7), "crown" = list(16.5, 35.3), "chest" = list(16.5, 14.5), "tail" = list(16.5, 15.7)), \ + "north" = list("l_arm" = list(list(10.15, 24.9), list(10.02, 19.4), list(10.15, 14.1)), "r_arm" = list(list(22.85, 24.9), list(22.98, 19.4), list(22.85, 14.1)), "l_leg" = list(list(13.95, 15.1), list(13.95, 8.7), list(13.95, 2.7), list(13.95, 0.5)), "r_leg" = list(list(19.05, 15.1), list(19.05, 8.7), list(19.05, 2.7), list(19.05, 0.5)), "head" = list(16.5, 26.7), "crown" = list(16.5, 35.3), "chest" = list(16.5, 14.5), "tail" = list(16.5, 15.7)), \ + "east" = list("l_arm" = list(list(15.7, 24.9), list(15.7, 19.4), list(15.9, 14.1)), "r_arm" = list(list(15.7, 24.9), list(15.7, 19.4), list(15.9, 14.1)), "l_leg" = list(list(16.5, 15.1), list(16.65, 8.7), list(16.4, 2.7), list(16.4, 0.5)), "r_leg" = list(list(16.5, 15.1), list(16.65, 8.7), list(16.4, 2.7), list(16.4, 0.5)), "head" = list(16.8, 26.7), "crown" = list(16.8, 35.3), "chest" = list(16.5, 14.5), "tail" = list(14.3, 15.7)), \ + "west" = list("l_arm" = list(list(17.3, 24.9), list(17.3, 19.4), list(17.1, 14.1)), "r_arm" = list(list(17.3, 24.9), list(17.3, 19.4), list(17.1, 14.1)), "l_leg" = list(list(16.5, 15.1), list(16.35, 8.7), list(16.6, 2.7), list(16.6, 0.5)), "r_leg" = list(list(16.5, 15.1), list(16.35, 8.7), list(16.6, 2.7), list(16.6, 0.5)), "head" = list(16.2, 26.7), "crown" = list(16.2, 35.3), "chest" = list(16.5, 14.5), "tail" = list(18.7, 15.7)), \ + ), \ + "leg_rest" = list(2, 4, 2), \ + "hat_scale" = 1.25, \ + "item_scale" = 1.25, \ + "paw_height" = 2.8, \ + "torso_width" = 1.12, \ + "torso_depth" = 0.95, \ + "cloth_widths" = list("thigh" = 1.3, "shin" = 1.05, "arm" = 1.4, "forearm" = 1.05) + +/datum/species/human + limb_rig_shape = list(HUMANOID_RIG_BODY) + +/datum/species/human/felinid + limb_rig_shape = list( + HUMANOID_RIG_BODY, + // Tail carried a touch high. + "posture" = list(RIG_TAIL = list("lift" = 8)), + ) + +/datum/species/lizard + limb_rig_shape = list( + HUMANOID_RIG_BODY, + // A slight forward lean, chin up to make up for it, the tail carried up behind. + "posture" = list( + RIG_CHEST = list("bend" = 6), + RIG_HEAD = list("nod" = -5), + RIG_TAIL = list("lift" = 12), + ), + ) + +/datum/species/moth + limb_rig_shape = list(HUMANOID_RIG_BODY) + +/datum/species/plasmaman + limb_rig_shape = list(HUMANOID_RIG_BODY) + +/datum/species/ethereal + limb_rig_shape = list(HUMANOID_RIG_BODY) + +#undef HUMANOID_RIG_BODY diff --git a/voidcrew/modules/limb_rig/sprite_rig.dm b/voidcrew/modules/limb_rig/sprite_rig.dm new file mode 100644 index 00000000000..a27844b0193 --- /dev/null +++ b/voidcrew/modules/limb_rig/sprite_rig.dm @@ -0,0 +1,413 @@ +/** + * A rig for a body drawn piece by piece. + * + * The human rig cuts one flat sprite into pieces with masks. A species can instead hand the rig a + * sprite for every piece (limb_rig_shape["sprites"]) and the skeleton they hang off. Each piece then + * shows exactly its own bit of body, so nothing is left stuck to the torso when a leg swings, and + * the body can be any size or shape: bigger than a tile, with digitigrade legs in three segments. + * + * Worn clothes are still drawn for a human. They're cut with human-shaped masks (the "cloth_masks" + * icon) onto cloth pieces, and each cloth piece is stretched from the human bone onto this body's + * bone: a human thigh's worth of trousers onto this thigh, a hat onto the head. Shoes get pieces of + * their own, taking the whole shoe onto the paw, so no boot top ends up halfway up the leg. + * + * Piece sprites are 64x64, drawn 16 pixels left, so they can reach past the tile on every side. + * Skeleton points are in pixels from the bottom left of the tile. + * + * Poses work as usual, and can also give whole angles: "thigh", "shin" and "foot" on a leg, and + * "upper" and "lower" on an arm, each forward from straight down. + */ + +/// Human bone heights the clothes are drawn around (see icons/rig_masks.dmi and the cloth masks). +#define HUMAN_HIPS_Y 11 +#define HUMAN_ELBOW_Y 16.5 +#define HUMAN_HAND_Y 12.5 +#define HUMAN_KNEE_Y 4.5 +#define HUMAN_ANKLE_Y 2.5 +#define HUMAN_CROWN_Y 29 + +/datum/limb_rig/sprites + /// One state per piece, 64x64. + var/sprite_icon + /// Rest joints by dir2text(): "l_arm" = list(shoulder, elbow, wrist), "l_leg" = list(hip, knee, hock, sole), + /// "head" = neck, "crown" = top of the head, "chest" = hips, "tail" = root. Limbs hang straight down in the sprites. + var/list/skeleton + /// How the legs bend at rest: list(thigh forward, knee back, ankle forward), in degrees. + var/list/leg_rest + /// Human-shaped masks cutting worn clothes into the pieces they get stretched onto. + var/cloth_masks + /// How much bigger hats and the like are drawn, to fit the head. They sit on its crown. + var/hat_scale = 1 + /// How much of the bottom of the foot shoes cover, in pixels. + var/paw_height = 4 + /// How much bigger held items are drawn, to fit the hand. + var/item_scale = 1 + /// How much wider (or narrower) clothes are drawn on the torso. + var/torso_width = 1 + /// How much wider clothes are drawn on each kind of limb segment ("arm", "forearm", "thigh", "shin"), + /// so sleeves and trouser legs fit round this body's limbs rather than a human's. + var/list/cloth_widths + /// Pieces showing worn clothing, by piece id. + var/list/obj/effect/abstract/limb_rig_part/cloth_parts = list() + /// Pieces showing each foot's shoe, "l" and "r". + var/list/obj/effect/abstract/limb_rig_part/shoe_parts = list() + /// Per-piece brute and burn marks ("_brute_<1-3>", "_burn_<1-3>"), or null to leave damage alone. + var/damage_icon + /// The marks currently on each piece, by piece id. + var/list/damage_marks = list() + /// The body sprite on each piece, by piece id. + var/list/sprite_images = list() + /// A piece over the head for anything drawn on the face (a rhythm battle's faces), moving with the + /// head but not coloured with it: nothing that tints the head's piece reaches it. + var/obj/effect/abstract/limb_rig_part/face_part + /// How much colour the body sprites keep, 0 to 1 (see set_sprite_saturation()). + var/sprite_saturation = 1 + +/datum/limb_rig/sprites/refresh_shape() + . = ..() + var/mob/living/carbon/human/human_owner = owner + var/list/shape = human_owner.dna.species.limb_rig_shape + sprite_icon = shape["sprites"] + skeleton = shape["skeleton"] + leg_rest = shape["leg_rest"] + cloth_masks = shape["cloth_masks"] + hat_scale = shape["hat_scale"] || 1 + item_scale = shape["item_scale"] || 1 + paw_height = shape["paw_height"] || 4 + torso_width = shape["torso_width"] || 1 + cloth_widths = shape["cloth_widths"] + damage_icon = shape["damage"] + +/datum/limb_rig/sprites/build_pieces() + pivot = new_part(RIG_CHEST) + hang_on_owner(pivot) + for(var/part_id in list(RIG_CHEST, RIG_HEAD, "l_arm", "l_forearm", "r_arm", "r_forearm")) + add_piece(part_id, pivot) + face_part = new_part(RIG_HEAD) + pivot.vis_contents += face_part + // The pivot draws as one flat group over the legs, so the tail hangs off the mob instead: + // it has to go under the legs, or over everything seen from behind. + add_piece(RIG_TAIL, null) + for(var/side in list("l", "r")) + item_parts[side] = new_part(side == "l" ? RIG_L_ARM : RIG_R_ARM) + pivot.vis_contents += item_parts[side] + for(var/segment in list("thigh", "shin", "foot")) + add_piece("[side]_[segment]", null) + shoe_parts[side] = new_part("[side]_foot") + hang_on_owner(shoe_parts[side]) + +/// Makes a body piece showing its sprite, and a cloth piece to go with it (the tail wears nothing). +/datum/limb_rig/sprites/proc/add_piece(part_id, obj/effect/abstract/limb_rig_part/container) + var/obj/effect/abstract/limb_rig_part/part = new_part(part_id) + parts[part_id] = part + refresh_sprite_image(part_id) + var/obj/effect/abstract/limb_rig_part/cloth + if(part_id != RIG_TAIL) + cloth = new_part(part_id) + cloth_parts[part_id] = cloth + if(container) + container.vis_contents += part + if(cloth) + container.vis_contents += cloth + else + hang_on_owner(part) + if(cloth) + hang_on_owner(cloth) + +/// Puts a piece's body sprite on, as drained of colour as the body is. +/datum/limb_rig/sprites/proc/refresh_sprite_image(part_id) + var/obj/effect/abstract/limb_rig_part/part = parts[part_id] + part.cut_overlay(sprite_images[part_id]) + var/image/sprite = image(sprite_icon, part_id) + sprite.pixel_w = -16 + if(sprite_saturation < 1) + sprite.color = color_matrix_saturation(sprite_saturation) + part.add_overlay(sprite) + sprite_images[part_id] = sprite + +/datum/limb_rig/sprites/set_sprite_saturation(value) + if(sprite_saturation == value) + return + sprite_saturation = value + for(var/part_id in sprite_images) + refresh_sprite_image(part_id) + +/datum/limb_rig/sprites/Destroy() + QDEL_LIST_ASSOC_VAL(cloth_parts) + QDEL_LIST_ASSOC_VAL(shoe_parts) + QDEL_NULL(face_part) + sprite_images = null + return ..() + +/datum/limb_rig/sprites/set_layer(cache_index, standing) + if(cache_index == HANDS_LAYER) + refresh_held_items() + return + if(cache_index == BODYPARTS_LAYER) + // The body is the pieces' own sprites. The bodyparts only say which limbs are there. + refresh_limbs() + return + if(cache_index == DAMAGE_LAYER && damage_icon) + return // Drawn on the pieces themselves, by refresh_damage_marks(). + // Shoes go on the shoe pieces, everything else on the cloth pieces. + var/list/targets = cache_index == SHOES_LAYER ? shoe_parts : cloth_parts + var/key = "[cache_index]" + var/old = mirrored_layers[key] + if(old) + for(var/part_id in targets) + var/obj/effect/abstract/limb_rig_part/cloth = targets[part_id] + cloth.cut_overlay(old) + mirrored_layers -= key + if(!standing) + return + for(var/part_id in targets) + var/obj/effect/abstract/limb_rig_part/cloth = targets[part_id] + cloth.add_overlay(standing) + mirrored_layers[key] = standing + +/// Which bodypart each piece belongs to. +/proc/get_rig_piece_zones() + var/static/list/zones = list( + RIG_HEAD = BODY_ZONE_HEAD, + RIG_CHEST = BODY_ZONE_CHEST, + "l_arm" = BODY_ZONE_L_ARM, "l_forearm" = BODY_ZONE_L_ARM, + "r_arm" = BODY_ZONE_R_ARM, "r_forearm" = BODY_ZONE_R_ARM, + "l_thigh" = BODY_ZONE_L_LEG, "l_shin" = BODY_ZONE_L_LEG, "l_foot" = BODY_ZONE_L_LEG, + "r_thigh" = BODY_ZONE_R_LEG, "r_shin" = BODY_ZONE_R_LEG, "r_foot" = BODY_ZONE_R_LEG, + ) + return zones + +/// Hides the pieces of any limb that's gone. +/datum/limb_rig/sprites/proc/refresh_limbs() + var/list/zones = get_rig_piece_zones() + for(var/part_id in zones) + var/obj/effect/abstract/limb_rig_part/part = parts[part_id] + part.alpha = owner.get_bodypart(zones[part_id]) ? 255 : 0 + +/datum/limb_rig/sprites/refresh_damage_marks() + if(!damage_icon) + return + var/blood_color = owner.get_bloodtype()?.get_damage_color(owner) + var/list/zones = get_rig_piece_zones() + for(var/part_id in zones) + var/obj/effect/abstract/limb_rig_part/part = parts[part_id] + part.cut_overlay(damage_marks[part_id]) + var/obj/item/bodypart/limb = owner.get_bodypart(zones[part_id]) + var/list/marks = list() + if(limb?.brutestate) + var/image/brute = image(damage_icon, "[part_id]_brute_[limb.brutestate]") + brute.pixel_w = -16 + brute.color = blood_color + marks += brute + if(limb?.burnstate) + var/image/burn = image(damage_icon, "[part_id]_burn_[limb.burnstate]") + burn.pixel_w = -16 + marks += burn + part.add_overlay(marks) + damage_marks[part_id] = marks + +/datum/limb_rig/sprites/refresh_facing() + var/facing = get_facing() + for(var/part_id in cloth_parts) + var/obj/effect/abstract/limb_rig_part/cloth = cloth_parts[part_id] + var/mask_state = part_id + var/flags = NONE + if(part_id == RIG_HEAD) + mask_state = "head_cut" + flags = MASK_INVERSE + else if(part_id == RIG_CHEST) + mask_state = "torso_cut" + flags = MASK_INVERSE + cloth.add_filter("limb_rig_mask", 1, alpha_mask_filter(icon = get_rig_mask(mask_state, facing, cloth_masks), flags = flags)) + for(var/side in shoe_parts) + shoe_parts[side].add_filter("limb_rig_mask", 1, alpha_mask_filter(icon = get_rig_mask("[side]_shoe", facing, cloth_masks))) + + // Side-on, the far limbs go behind the body. From behind, the arms do too, and the tail, + // pointing at the viewer, goes over everything; otherwise it's under everything, legs included. + // (Everything on the pivot draws together, at the pivot's layer.) + var/far_side = facing == EAST ? "l" : (facing == WEST ? "r" : null) + pivot.layer = -2 + parts[RIG_CHEST].layer = -5 + parts[RIG_HEAD].layer = -4 + parts[RIG_TAIL].layer = facing == NORTH ? -1 : -9 + for(var/side in list("l", "r")) + for(var/segment in list("thigh", "shin", "foot")) + parts["[side]_[segment]"].layer = side == far_side ? -6 : -3 + var/behind = (side == far_side || facing == NORTH) + parts["[side]_arm"].layer = behind ? -7 : -3 + parts["[side]_forearm"].layer = parts["[side]_arm"].layer + 0.2 + item_parts[side].layer = behind ? -6.5 : -2 + for(var/part_id in cloth_parts) + cloth_parts[part_id].layer = parts[part_id].layer + 0.1 + for(var/side in shoe_parts) + shoe_parts[side].layer = parts["[side]_foot"].layer + 0.2 + face_part.layer = parts[RIG_HEAD].layer + 0.15 + sort_pieces() + +/** + * Poses a limb hanging straight down from points[1] through the rest of points. + * + * * angles - how far each segment swings forward from straight down, in degrees + * * raise - how far the whole limb swings out to the side + * * right - whether it's on the mob's right, which decides which way raising turns it from the front + * + * Returns list(a matrix per segment..., end x, end y, the last segment's turn). + */ +/proc/rig_chain(list/points, list/angles, facing, raise, right) + . = list() + var/front = (facing == NORTH || facing == SOUTH) + var/side_turn = front ? ((right == (facing == SOUTH)) ? raise : -raise) : 0 + var/list/start = points[1] + var/posed_x = start[1] + var/posed_y = start[2] + var/turn = 0 + for(var/i in 1 to length(points) - 1) + var/list/top = points[i] + var/list/bottom = points[i + 1] + var/length = top[2] - bottom[2] + var/angle = angles[i] + if(front) + // Swinging toward or away from the viewer just shortens the segment. + var/foreshorten = cos(angle) + turn = side_turn + . += rig_joint_matrix(top[1], top[2], foreshorten, turn, posed_x, posed_y) + posed_x -= length * foreshorten * sin(turn) + posed_y -= length * foreshorten * cos(turn) + else + turn = facing == EAST ? -angle : angle + . += rig_joint_matrix(top[1], top[2], 1, turn, posed_x, posed_y, cos(raise)) + posed_x += length * sin(angle) * (facing == EAST ? 1 : -1) + posed_y -= length * cos(angle) + . += posed_x + . += posed_y + . += turn + +/// The piece the face is drawn to fit, whose every move face_part copies. +/datum/limb_rig/sprites/proc/get_face_anchor() + return parts[RIG_HEAD] + +/// Puts face_part wherever its anchor is in a set of matrices. +/datum/limb_rig/sprites/proc/add_face_matrix(list/matrices) + var/matrix/anchor = matrices[get_face_anchor()] + if(anchor && face_part) + matrices[face_part] = anchor + +/// Maps a human-shaped piece of clothing onto this body's piece at rest. +/datum/limb_rig/sprites/proc/get_cloth_map(part_id, facing) + var/list/bones = skeleton[dir2text(facing)] + switch(part_id) + if(RIG_HEAD) + var/list/crown = bones["crown"] + return rig_joint_matrix(16, HUMAN_CROWN_Y, hat_scale, 0, crown[1], crown[2], hat_scale) + if(RIG_CHEST) + var/list/hips = bones[RIG_CHEST] + var/list/shoulders = bones["l_arm"][1] + var/list/human_shoulders = get_rig_joint(RIG_L_ARM, facing) + var/torso_scale = (shoulders[2] - hips[2]) / (human_shoulders[2] - HUMAN_HIPS_Y) + return rig_joint_matrix(16, HUMAN_HIPS_Y, torso_scale, 0, hips[1], hips[2], torso_width) + var/side = copytext(part_id, 1, 2) + var/segment = copytext(part_id, 3) + var/list/human_root = get_rig_joint(side == "l" ? (findtext(segment, "arm") ? RIG_L_ARM : RIG_L_LEG) : (findtext(segment, "arm") ? RIG_R_ARM : RIG_R_LEG), facing) + var/list/points + var/index + var/list/human_heights + switch(segment) + if("arm", "forearm") + points = bones["[side]_arm"] + index = segment == "arm" ? 1 : 2 + human_heights = list(human_root[2], HUMAN_ELBOW_Y, HUMAN_HAND_Y) + else + points = bones["[side]_leg"] + index = segment == "thigh" ? 1 : (segment == "shin" ? 2 : 3) + human_heights = list(human_root[2], HUMAN_KNEE_Y, HUMAN_ANKLE_Y, 0) + if(segment == "foot") + // Shoes cover the paw at the bottom of a long foot, not the whole thing. + var/list/sole = points[4] + return rig_joint_matrix(human_root[1], HUMAN_ANKLE_Y, paw_height / HUMAN_ANKLE_Y, 0, sole[1], sole[2] + paw_height) + var/list/top = points[index] + var/list/bottom = points[index + 1] + var/stretch = (top[2] - bottom[2]) / (human_heights[index] - human_heights[index + 1]) + return rig_joint_matrix(human_root[1], human_heights[index], stretch, 0, top[1], top[2], cloth_widths?[segment] || 1) + +/datum/limb_rig/sprites/get_pose_matrices(list/pose, facing) + . = list() + pose = rig_merge_pose(posture, rig_resolve_aim(posture, pose)) + var/list/bones = skeleton[dir2text(facing)] + + // Legs: thigh, shin and foot, bent into their rest zigzag and then posed. + var/list/leg_chains = list() + var/lowest_foot = INFINITY + for(var/side in list("l", "r")) + var/list/entry = pose?["[side]_leg"] + // Either whole angles ("thigh", "shin", "foot", forward from straight down), or the usual + // swing and knee on top of the rest bend. + var/thigh = isnull(entry?["thigh"]) ? leg_rest[1] + (entry?["swing"] || 0) : entry["thigh"] + var/shin = isnull(entry?["shin"]) ? thigh - leg_rest[2] - (entry?["knee"] || 0) : entry["shin"] + var/foot = isnull(entry?["foot"]) ? shin + leg_rest[3] + (entry?["ankle"] || 0) : entry["foot"] + var/list/chain = rig_chain(bones["[side]_leg"], list(thigh, shin, foot), facing, entry?["raise"] || 0, side == "r") + leg_chains[side] = chain + lowest_foot = min(lowest_foot, chain[5]) + var/list/chest_entry = pose?[RIG_CHEST] + var/lift = -lowest_foot + (chest_entry?["air"] || 0) + for(var/side in list("l", "r")) + var/list/chain = leg_chains[side] + var/list/segments = list("thigh", "shin", "foot") + for(var/i in 1 to 3) + var/matrix/segment_matrix = chain[i] + segment_matrix.Translate(0, lift) + var/part_id = "[side]_[segments[i]]" + .[parts[part_id]] = segment_matrix + .[cloth_parts[part_id]] = get_cloth_map(part_id, facing) * segment_matrix + if(i == 3) + .[shoe_parts[side]] = get_cloth_map(part_id, facing) * segment_matrix + + // (The face goes wherever the piece it's drawn for does: see get_face_anchor().) + // The upper body hangs off the hips. + var/matrix/torso = rig_pose_matrix(RIG_CHEST, chest_entry, facing, 1, bones[RIG_CHEST]) + torso.Translate(0, lift) + .[pivot] = torso + .[cloth_parts[RIG_CHEST]] = get_cloth_map(RIG_CHEST, facing) + var/matrix/head = rig_pose_matrix(RIG_HEAD, pose?[RIG_HEAD], facing, 1, bones[RIG_HEAD]) + if(facing == NORTH || facing == SOUTH) + // From the front, bending over squashes everything on the torso. The head undoes that. + var/list/neck = bones[RIG_HEAD] + head = rig_joint_matrix(neck[1], neck[2], 1 / max(cos(chest_entry?["bend"] || 0), 0.25), 0, neck[1], neck[2]) * head + .[parts[RIG_HEAD]] = head + .[cloth_parts[RIG_HEAD]] = get_cloth_map(RIG_HEAD, facing) * head + .[parts[RIG_TAIL]] = rig_tail_matrix(bones[RIG_TAIL], pose?[RIG_TAIL], facing) * torso + + for(var/side in list("l", "r")) + var/list/entry = pose?["[side]_arm"] + var/list/points = bones["[side]_arm"] + // Whole angles ("upper", "lower"), or swing and elbow. + var/swing = isnull(entry?["upper"]) ? (entry?["swing"] || 0) : entry["upper"] + var/raise = entry?["raise"] || 0 + var/lower = isnull(entry?["lower"]) ? swing + (entry?["elbow"] || 0) : entry["lower"] + var/hand_y = entry?["hand_y"] + if(!isnull(hand_y)) + // Poses give hand heights on a human; carry them over, measured from the hips to the neck. + var/list/hips = bones[RIG_CHEST] + var/list/neck = bones[RIG_HEAD] + var/target = hips[2] + (hand_y - HUMAN_HIPS_Y) * (neck[2] - hips[2]) / (get_rig_joint(RIG_HEAD, facing)[2] - HUMAN_HIPS_Y) + var/list/shoulder = points[1] + var/list/elbow = points[2] + var/list/wrist = points[3] + var/elbow_height = shoulder[2] - (shoulder[2] - elbow[2]) * cos(swing) * cos(raise) + var/forearm_drop = (elbow[2] - wrist[2]) * max(cos(raise), 0.1) + lower = arccos(clamp((elbow_height - target) / forearm_drop, -1, 1)) + var/list/chain = rig_chain(points, list(swing, lower), facing, raise, side == "r") + .[parts["[side]_arm"]] = chain[1] + .[parts["[side]_forearm"]] = chain[2] + .[cloth_parts["[side]_arm"]] = get_cloth_map("[side]_arm", facing) * chain[1] + .[cloth_parts["[side]_forearm"]] = get_cloth_map("[side]_forearm", facing) * chain[2] + // Held items are drawn for a human hand; move that hand to this wrist. + var/list/human_hand = get_rig_joint(side == "l" ? RIG_L_ARM : RIG_R_ARM, facing) + .[item_parts[side]] = rig_joint_matrix(human_hand[1], HUMAN_HAND_Y, item_scale, chain[5], chain[3], chain[4], item_scale) + add_face_matrix(.) + +#undef HUMAN_HIPS_Y +#undef HUMAN_ELBOW_Y +#undef HUMAN_HAND_Y +#undef HUMAN_KNEE_Y +#undef HUMAN_ANKLE_Y +#undef HUMAN_CROWN_Y diff --git a/voidcrew/modules/mod/ai_hands.dm b/voidcrew/modules/mod/ai_hands.dm new file mode 100644 index 00000000000..e86ffad1948 --- /dev/null +++ b/voidcrew/modules/mod/ai_hands.dm @@ -0,0 +1,251 @@ +/** + * MOD AIs (and pAIs) working the wearer's body once the wearer can't. + * + * While the wearer's out cold, in hard crit or dead, and the suit's running with its gauntlets + * sealed, the AI's clicks are the wearer's hands: whatever's in the active hand used on what it + * clicks, or a bare hand (a punch, in the AI's combat mode). It's slower at it than the wearer + * (AI_HANDS_COOLDOWN), and picking things up or dropping them takes it a while (AI_HANDS_GRAB_TIME). + * The AI's own keys for hands work on the wearer's: drop, swap hands, use in hand. It sees the + * wearer's hands on its screen, with what's in them, while it has them. + * + * Whatever state the wearer's in, the AI's middle or alt clicks use the suit's selected module at + * what it clicks, as the wearer's do: tethers, kinesis and the like, which an AI couldn't aim before. + */ + +#define AI_HANDS_COOLDOWN (2 SECONDS) +#define AI_HANDS_GRAB_TIME (1.5 SECONDS) +#define AI_HANDS_CHARGE (DEFAULT_CHARGE_DRAIN * 2.5) + +/obj/item/mod/control + /// Whether the suit's holding the wearer's hands up for its AI (see ai_take_hands()). + var/ai_holding_hands = FALSE + /// Whether the AI's in the middle of something with them. + var/ai_hands_busy = FALSE + /// The wearer's hands on the AI's screen, while it can use them. + var/list/atom/movable/screen/mod_ai_hand/ai_hand_slots = list() + COOLDOWN_DECLARE(cooldown_ai_hands) + +/// One of the wearer's hands on the AI's screen, showing what's in it. Clicking it uses the item in +/// it if it's the hand in use, or switches to it. +/atom/movable/screen/mod_ai_hand + icon = 'icons/hud/screen_midnight.dmi' + mouse_over_pointer = MOUSE_HAND_POINTER + var/obj/item/mod/control/mod + var/held_index + +/atom/movable/screen/mod_ai_hand/Click(location, control, params) + if(usr != mod?.ai_assistant || !mod.ai_can_use_hands()) + return + if(mod.wearer.active_hand_index == held_index) + INVOKE_ASYNC(mod, TYPE_PROC_REF(/obj/item/mod/control, ai_use_inhand)) + else + mod.wearer.swap_hand(held_index) + +/atom/movable/screen/mod_ai_hand/Destroy() + vis_contents.Cut() + mod = null + return ..() + +/obj/item/mod/control/on_gained_assistant(mob/living/silicon/new_helper) + . = ..() + RegisterSignal(new_helper, COMSIG_MOB_CLICKON, PROC_REF(on_assistant_click)) + RegisterSignal(new_helper, COMSIG_KB_MOB_DROPITEM_DOWN, PROC_REF(on_assistant_drop)) + RegisterSignal(new_helper, COMSIG_KB_MOB_SWAPHANDS_DOWN, PROC_REF(on_assistant_swap)) + RegisterSignal(new_helper, COMSIG_KB_MOB_ACTIVATEINHAND_DOWN, PROC_REF(on_assistant_use_inhand)) + RegisterSignal(new_helper, COMSIG_MOB_LOGIN, PROC_REF(on_suit_changed)) + RegisterSignals(src, list(COMSIG_MOD_TOGGLED, COMSIG_MOD_PART_SEALED), PROC_REF(on_suit_changed)) + RegisterSignal(src, COMSIG_MOD_WEARER_SET, PROC_REF(on_wearer_set)) + RegisterSignal(src, COMSIG_MOD_WEARER_UNSET, PROC_REF(on_wearer_unset)) + if(wearer) + on_wearer_set() + +/obj/item/mod/control/on_removed_assistant() + UnregisterSignal(ai_assistant, list(COMSIG_MOB_CLICKON, COMSIG_KB_MOB_DROPITEM_DOWN, COMSIG_KB_MOB_SWAPHANDS_DOWN, COMSIG_KB_MOB_ACTIVATEINHAND_DOWN, COMSIG_MOB_LOGIN)) + UnregisterSignal(src, list(COMSIG_MOD_TOGGLED, COMSIG_MOD_PART_SEALED, COMSIG_MOD_WEARER_SET, COMSIG_MOD_WEARER_UNSET)) + if(wearer) + on_wearer_unset() + ai_assistant.client?.screen -= ai_hand_slots + QDEL_LIST(ai_hand_slots) + return ..() + +/obj/item/mod/control/Destroy() + ai_assistant?.client?.screen -= ai_hand_slots + QDEL_LIST(ai_hand_slots) + return ..() + +/obj/item/mod/control/proc/on_wearer_set(datum/source) + SIGNAL_HANDLER + RegisterSignals(wearer, list(COMSIG_MOB_STATCHANGE, COMSIG_MOB_UPDATE_HELD_ITEMS, COMSIG_MOB_SWAP_HANDS), PROC_REF(on_suit_changed)) + ai_update_hands_hud() + +/obj/item/mod/control/proc/on_wearer_unset(datum/source) + SIGNAL_HANDLER + UnregisterSignal(wearer, list(COMSIG_MOB_STATCHANGE, COMSIG_MOB_UPDATE_HELD_ITEMS, COMSIG_MOB_SWAP_HANDS)) + ai_let_go_of_hands() + ai_assistant?.client?.screen -= ai_hand_slots + QDEL_LIST(ai_hand_slots) + +/// Whether the AI can work the wearer's hands: they're out, and the suit's running on them with its gauntlets sealed. +/obj/item/mod/control/proc/ai_can_use_hands() + if(!ai_assistant || !wearer || wearer.stat < UNCONSCIOUS || !active || activating) + return FALSE + var/datum/mod_part/gauntlets = get_part_datum_from_slot(ITEM_SLOT_GLOVES) + return gauntlets?.sealed + +/// Holds the wearer's hands up for the AI: being out doesn't block them while it does, until it lets go (see ai_let_go_of_hands()). +/obj/item/mod/control/proc/ai_take_hands() + ai_holding_hands = TRUE + REMOVE_TRAIT(wearer, TRAIT_HANDS_BLOCKED, STAT_TRAIT) + +/// Lets go of the wearer's hands. Out as they are, they go limp again, dropping whatever they hold. +/obj/item/mod/control/proc/ai_let_go_of_hands(datum/source) + SIGNAL_HANDLER + if(!ai_holding_hands) + return + ai_holding_hands = FALSE + if(wearer?.stat >= UNCONSCIOUS) + ADD_TRAIT(wearer, TRAIT_HANDS_BLOCKED, STAT_TRAIT) + +/// Something about the suit, its wearer or what they hold changed: lets go of their hands if the AI can't use them now, and shows it them as they are. +/obj/item/mod/control/proc/on_suit_changed(datum/source) + SIGNAL_HANDLER + if(!ai_can_use_hands()) + ai_let_go_of_hands() + ai_update_hands_hud() + +/// Shows the AI the wearer's hands and what's in them, while it can use them. +/obj/item/mod/control/proc/ai_update_hands_hud() + var/client/viewer = ai_assistant?.client + if(!ai_can_use_hands()) + viewer?.screen -= ai_hand_slots + QDEL_LIST(ai_hand_slots) + return + if(length(ai_hand_slots) != length(wearer.held_items)) + viewer?.screen -= ai_hand_slots + QDEL_LIST(ai_hand_slots) + for(var/index in 1 to length(wearer.held_items)) + var/atom/movable/screen/mod_ai_hand/slot = new(null, null) + slot.mod = src + slot.held_index = index + slot.name = wearer.get_held_index_name(index) + slot.icon_state = "hand_[wearer.held_index_to_dir(index)]" + // Where the wearer's own would be, a row up, over the AI's buttons. + slot.screen_loc = "CENTER+[IS_LEFT_INDEX(index) ? 0 : -1]:16,SOUTH+[1 + round((index - 1) / 2)]:5" + ai_hand_slots += slot + for(var/atom/movable/screen/mod_ai_hand/slot as anything in ai_hand_slots) + slot.vis_contents.Cut() + var/obj/item/held = wearer.held_items[slot.held_index] + if(held) + slot.vis_contents += held + slot.cut_overlays() + if(slot.held_index == wearer.active_hand_index) + slot.add_overlay(IS_LEFT_INDEX(slot.held_index) ? "lhandactive" : "rhandactive") + viewer?.screen |= ai_hand_slots + +/// Starts something the AI does with the wearer's hands, if it can now: takes them, the time and the charge. +/obj/item/mod/control/proc/ai_ready_hands() + if(!ai_can_use_hands() || ai_hands_busy || !COOLDOWN_FINISHED(src, cooldown_ai_hands)) + return FALSE + if(get_charge() < AI_HANDS_CHARGE) + balloon_alert(ai_assistant, "not enough charge!") + return FALSE + COOLDOWN_START(src, cooldown_ai_hands, AI_HANDS_COOLDOWN) + subtract_charge(AI_HANDS_CHARGE) + ai_take_hands() + return TRUE + +/// Does something as the wearer, with what being out puts on them lifted for it. +/obj/item/mod/control/proc/as_wearer(datum/callback/what) + var/mob/living/carbon/human/body = wearer + ai_hands_busy = TRUE + REMOVE_TRAIT(body, TRAIT_INCAPACITATED, STAT_TRAIT) + what.Invoke() + if(!QDELETED(body) && body.stat != CONSCIOUS) + ADD_TRAIT(body, TRAIT_INCAPACITATED, STAT_TRAIT) + ai_hands_busy = FALSE + +/// Picking something up or putting it down takes the AI a while. +/obj/item/mod/control/proc/ai_grab_wait(atom/target) + ai_hands_busy = TRUE + . = do_after(ai_assistant, AI_HANDS_GRAB_TIME, target, extra_checks = CALLBACK(src, PROC_REF(ai_can_use_hands))) + ai_hands_busy = FALSE + +/obj/item/mod/control/proc/on_assistant_click(mob/living/silicon/source, atom/target, list/modifiers) + SIGNAL_HANDLER + if(istype(target, /atom/movable/screen) || LAZYACCESS(modifiers, SHIFT_CLICK) || LAZYACCESS(modifiers, CTRL_CLICK)) + return + if(LAZYACCESS(modifiers, MIDDLE_CLICK) || LAZYACCESS(modifiers, ALT_CLICK)) + if(!wearer || !selected_module?.used_signal) + return + INVOKE_ASYNC(src, PROC_REF(ai_use_module), target) + return COMSIG_MOB_CANCEL_CLICKON + // Out of reach of an empty hand, it's the AI's own click. + if(!ai_can_use_hands() || (!wearer.get_active_held_item() && !wearer.CanReach(target))) + return + INVOKE_ASYNC(src, PROC_REF(ai_hand_click), target, modifiers) + return COMSIG_MOB_CANCEL_CLICKON + +/// The AI's click, as the wearer's. +/obj/item/mod/control/proc/ai_hand_click(atom/target, list/modifiers) + if(!ai_ready_hands()) + return + var/mob/living/silicon/assistant = ai_assistant + var/obj/item/held = wearer.get_active_held_item() + if(!held && isitem(target) && target.loc != wearer && !ai_grab_wait(target)) + return + if(!ai_can_use_hands()) + return + if(isliving(target)) + log_combat(assistant, target, "used [key_name(wearer)]'s hands on", held) + wearer.set_combat_mode(assistant.combat_mode) + as_wearer(CALLBACK(wearer, TYPE_PROC_REF(/mob, ClickOn), target, list2params(modifiers))) + +/obj/item/mod/control/proc/on_assistant_drop(mob/living/silicon/source) + SIGNAL_HANDLER + if(!ai_can_use_hands()) + return + INVOKE_ASYNC(src, PROC_REF(ai_drop)) + return COMSIG_KB_ACTIVATED + +/obj/item/mod/control/proc/ai_drop() + var/obj/item/held = wearer.get_active_held_item() + if(!held) + balloon_alert(ai_assistant, "nothing in hand!") + return + if(!ai_ready_hands() || !ai_grab_wait(wearer)) + return + if(held == wearer?.get_active_held_item()) + wearer.dropItemToGround(held) + +/obj/item/mod/control/proc/on_assistant_swap(mob/living/silicon/source) + SIGNAL_HANDLER + if(!ai_can_use_hands()) + return + wearer.swap_hand() + return COMSIG_KB_ACTIVATED + +/obj/item/mod/control/proc/on_assistant_use_inhand(mob/living/silicon/source) + SIGNAL_HANDLER + if(!ai_can_use_hands()) + return + INVOKE_ASYNC(src, PROC_REF(ai_use_inhand)) + return COMSIG_KB_ACTIVATED + +/obj/item/mod/control/proc/ai_use_inhand() + if(!wearer.get_active_held_item() || !ai_ready_hands()) + return + as_wearer(CALLBACK(wearer, TYPE_PROC_REF(/mob, execute_mode))) + +/// The suit's selected module used at what the AI clicked, as the wearer's click would. +/obj/item/mod/control/proc/ai_use_module(atom/target) + var/obj/item/mod/module/module = selected_module + if(ai_hands_busy || !wearer || !module?.used_signal) + return + if(wearer.stat < UNCONSCIOUS) + module.on_select_use(target) + return + as_wearer(CALLBACK(module, TYPE_PROC_REF(/obj/item/mod/module, on_select_use), target)) + +#undef AI_HANDS_COOLDOWN +#undef AI_HANDS_GRAB_TIME +#undef AI_HANDS_CHARGE diff --git a/voidcrew/modules/mod/corpse_exoskeleton.dm b/voidcrew/modules/mod/corpse_exoskeleton.dm new file mode 100644 index 00000000000..4ad80a30d37 --- /dev/null +++ b/voidcrew/modules/mod/corpse_exoskeleton.dm @@ -0,0 +1,86 @@ +// Ported from Monkestation (Monkestation/Monkestation2.0#9889). + +///Corpse Exoskeleton - allows your MODsuit to stand up right whilst the person inside is dead. Great for MOD AIs, I guess! +/obj/item/mod/module/magboot/corpse_exoskeleton + name = "MOD corpse exoskeleton module" + desc = "An exosuit that goes around the whole body. Upon an internal health sensor detecting the user getting fatally injured, \ + or on a manual toggle, activates servos around the full body to ensure the user stays upright, come stun or death, the user remains vertical." + icon_state = "bulwark" + complexity = 2 + active_power_cost = DEFAULT_CHARGE_DRAIN * 0.4 + slowdown_active = 0.3 + incompatible_modules = list(/obj/item/mod/module/magboot/corpse_exoskeleton) + active_traits = list(TRAIT_FORCED_STANDING) + /// Whether it turns itself on when the wearer goes into crit. + var/autotrigger = TRUE + +/obj/item/mod/module/magboot/corpse_exoskeleton/get_configuration(mob/user) + . = ..() + .["autotrigger"] = add_ui_configuration("Autotrigger", "bool", autotrigger) + +/obj/item/mod/module/magboot/corpse_exoskeleton/configure_edit(key, value) + switch(key) + if("autotrigger") + autotrigger = text2num(value) + +/obj/item/mod/module/magboot/corpse_exoskeleton/on_part_activation() + RegisterSignal(mod.wearer, COMSIG_LIVING_HEALTH_UPDATE, PROC_REF(health_check)) + +/obj/item/mod/module/magboot/corpse_exoskeleton/on_part_deactivation(deleting = FALSE) + UnregisterSignal(mod.wearer, COMSIG_LIVING_HEALTH_UPDATE) + +/obj/item/mod/module/magboot/corpse_exoskeleton/proc/health_check(datum/source) + SIGNAL_HANDLER + if(active || !autotrigger || mod.wearer.health > mod.wearer.crit_threshold) + return + INVOKE_ASYNC(src, PROC_REF(dead_reckoning)) + +/// They're going down: catches them. +/obj/item/mod/module/magboot/corpse_exoskeleton/proc/dead_reckoning() + if(active || !mod?.wearer) + return + if(activate()) + mod.wearer.visible_message( + span_danger("[src] inside [mod.wearer]'s [mod.name] activates!"), + span_danger("Your body is quickly caught by your suit."), + ) + playsound(src, 'sound/vehicles/mecha/mechmove04.ogg', 50, TRUE) + else + mod.wearer.visible_message(span_danger("[src] inside [mod.wearer]'s [mod.name] fails to actuate.")) + playsound(src, 'sound/vehicles/mecha/mechmove04.ogg', 25, TRUE) + +/datum/design/module/mod_springlock + name = "Springlock Module" + id = "mod_springlock" + materials = list(/datum/material/iron = SMALL_MATERIAL_AMOUNT * 2.5, /datum/material/titanium = HALF_SHEET_MATERIAL_AMOUNT, /datum/material/glass = HALF_SHEET_MATERIAL_AMOUNT * 1.5) + build_path = /obj/item/mod/module/springlock + category = list( + RND_CATEGORY_MODSUIT_MODULES + RND_SUBCATEGORY_MODSUIT_MODULES_SERVICE + ) + departmental_flags = DEPARTMENT_BITFLAG_SERVICE + +/datum/design/module/mod_corpse + name = "Corpse Exoskeleton Module" + id = "mod_corpse" + materials = list(/datum/material/iron = SMALL_MATERIAL_AMOUNT * 2.5, /datum/material/titanium = HALF_SHEET_MATERIAL_AMOUNT, /datum/material/glass = HALF_SHEET_MATERIAL_AMOUNT * 1.5) + build_path = /obj/item/mod/module/magboot/corpse_exoskeleton + category = list( + RND_CATEGORY_MODSUIT_MODULES + RND_SUBCATEGORY_MODSUIT_MODULES_SERVICE + ) + +/// Unlocked by analysing either module, found in maintenance. +/datum/techweb_node/mod_mortality + id = TECHWEB_NODE_MOD_MORTALITY + display_name = "MOD Mortality Modules" + description = "Obsolete modules involving the users body, for worse or even worse. Pulled from the market for being too dangerous to users, or for being utterly useless." + prereq_ids = list(TECHWEB_NODE_MOD_EQUIP) + design_ids = list( + "mod_springlock", + "mod_corpse", + ) + required_items_to_unlock = list( + /obj/item/mod/module/springlock, + /obj/item/mod/module/magboot/corpse_exoskeleton, + ) + research_costs = list(TECHWEB_POINT_TYPE_GENERIC = TECHWEB_TIER_1_POINTS / 4) + hidden = TRUE diff --git a/voidcrew/modules/unit_tests/_unit_tests.dm b/voidcrew/modules/unit_tests/_unit_tests.dm index 3a829f945d8..414891e7558 100644 --- a/voidcrew/modules/unit_tests/_unit_tests.dm +++ b/voidcrew/modules/unit_tests/_unit_tests.dm @@ -4,10 +4,14 @@ #include "armor_partial_modifiers.dm" #include "atmos_component_pipeline.dm" #include "drift_vectors.dm" +#include "fnf.dm" +#include "gags.dm" #include "gas_connector_lifecycle.dm" #include "glass_floor_baseturfs.dm" #include "hyperspace_jetpack.dm" #include "late_initialization_reentry.dm" +#include "limb_physics.dm" +#include "limb_rig.dm" #include "map_parser_attributes.dm" #include "mod_night_vision.dm" #include "overmap_spawn_density.dm" @@ -225,4 +229,5 @@ #include "voidcrew_zone_logging.dm" #include "voidcrew_zone_teleport.dm" #include "weather_mob_targeting.dm" +#include "weird_route.dm" // END_INCLUDE diff --git a/voidcrew/modules/unit_tests/fnf.dm b/voidcrew/modules/unit_tests/fnf.dm new file mode 100644 index 00000000000..4b31a492e3c --- /dev/null +++ b/voidcrew/modules/unit_tests/fnf.dm @@ -0,0 +1,234 @@ +/// Where the tests put the song they make (see make_fnf_test_song()): no song ships with the game. +#define FNF_TEST_SONGS "data/unit_test_fnf/" + +/** + * Makes a song to test with, in Funkin's format, and adds its folder to the song list: an + * Experiment against an Experiment, the two taking turns every 0.4 seconds for 26 seconds, every + * eighth note held. Its audio is the countdown's. Returns its name; take_fnf_test_song() clears it up. + */ +/proc/make_fnf_test_song() + var/folder = FNF_TEST_SONGS + "testsong/" + fdel(FNF_TEST_SONGS) + var/list/notes = list() + for(var/index in 0 to 63) + var/hold = index % 8 == 0 ? 300 : 0 + notes += list(list("t" = 1000 + index * 400, "d" = 4 + index % 4, "l" = hold)) + notes += list(list("t" = 1200 + index * 400, "d" = index % 4, "l" = hold)) + text2file(json_encode(list("version" = "2.0.0", "scrollSpeed" = list("normal" = 1.4), "events" = list(), "notes" = list("normal" = notes))), folder + "testsong-chart.json") + text2file(json_encode(list( + "songName" = "Test Song", + "artist" = "the unit tests", + "timeChanges" = list(list("t" = 0, "bpm" = 150)), + "playData" = list( + "difficulties" = list("normal"), + "characters" = list("player" = "expie", "opponent" = "expie", "playerVocals" = list("player"), "opponentVocals" = list("opponent")), + ), + )), folder + "testsong-metadata.json") + for(var/audio in list("Inst.ogg", "Voices-player.ogg", "Voices-opponent.ogg")) + fcopy('voidcrew/modules/fnf/sound/count1.ogg', folder + audio) + GLOB.fnf_song_dirs |= FNF_TEST_SONGS + return "Test Song" + +/proc/take_fnf_test_song() + GLOB.fnf_song_dirs -= FNF_TEST_SONGS + fdel(FNF_TEST_SONGS) + get_fnf_songs(refresh = TRUE) + +/// A song loads, with both singers' vocals and its long notes. +/datum/unit_test/fnf_song + +/datum/unit_test/fnf_song/Run() + var/name = make_fnf_test_song() + var/list/songs = get_fnf_songs(refresh = TRUE) + var/datum/fnf_song/song = songs[name] + TEST_ASSERT_NOTNULL(song, "The test song did not load.") + TEST_ASSERT_NOTNULL(song.player_voice_file, "The test song has no player vocals.") + TEST_ASSERT_NOTNULL(song.opponent_voice_file, "The test song has no opponent vocals.") + var/list/chart = song.load_chart("normal") + TEST_ASSERT_EQUAL(length(chart["player"]), 64, "The test chart's player notes didn't all load.") + TEST_ASSERT_EQUAL(length(chart["opponent"]), 64, "The test chart's opponent notes didn't all load.") + var/holds = 0 + for(var/list/note as anything in chart["player"]) + if(note[3] > 0) + holds++ + TEST_ASSERT_EQUAL(holds, 8, "The test chart's long notes didn't load.") + +/datum/unit_test/fnf_song/Destroy() + take_fnf_test_song() + return ..() + +/// A lone challenger gets an Experiment to sing against, the game plays both sides when nobody's +/// at the keys, and the summoned singer goes when the battle does. +/datum/unit_test/fnf_battle + +/datum/unit_test/fnf_battle/Run() + var/name = make_fnf_test_song() + var/list/songs = get_fnf_songs(refresh = TRUE) + var/datum/fnf_song/song = songs[name] + TEST_ASSERT_NOTNULL(song, "The test song did not load.") + var/mob/living/carbon/human/consistent/challenger = allocate(/mob/living/carbon/human/consistent) + var/datum/fnf_battle/battle = new(song, "normal") + battle.start(challenger, null) + var/mob/living/carbon/human/npc = battle.npc + TEST_ASSERT_NOTNULL(npc, "No opponent was summoned for a lone challenger.") + TEST_ASSERT(is_species(npc, /datum/species/experiment), "The summoned opponent is not an Experiment.") + TEST_ASSERT(battle.left.is_cpu && battle.right.is_cpu, "Singers without players are not played by the game.") + + // Twenty-five seconds in, past both singers' first verses: everything due so far gets sung. + battle.start_time = world.time - 250 + battle.state = 2 + battle.tick() + TEST_ASSERT(battle.right.score > 0, "The challenger's side sang nothing.") + TEST_ASSERT(battle.left.score > 0, "The opponent's side sang nothing.") + TEST_ASSERT_EQUAL(battle.right.misses, 0, "The game missed notes it was playing itself.") + TEST_ASSERT_EQUAL(battle.health, 50, "The game playing itself moved the health bar.") + + qdel(battle) + TEST_ASSERT(QDELETED(npc), "The summoned opponent stayed after the battle.") + +/datum/unit_test/fnf_battle/Destroy() + take_fnf_test_song() + return ..() + +/// Songs are picked by week, in Funkin's order, and lyrics are read from SubRip timings. +/datum/unit_test/fnf_weeks + +/datum/unit_test/fnf_weeks/Run() + TEST_ASSERT_EQUAL(fnf_week_label("week1"), "Week 1", "week1 is not labelled Week 1.") + TEST_ASSERT_EQUAL(fnf_week_label("weekend1"), "Weekend 1", "weekend1 is not labelled Weekend 1.") + TEST_ASSERT_EQUAL(fnf_week_label("tutorial"), "Tutorial", "tutorial is not labelled Tutorial.") + var/list/weeks = list("weekend1", "week7", "sserafim", "week1", "tutorial") + sortTim(weeks, GLOBAL_PROC_REF(cmp_fnf_week)) + TEST_ASSERT_EQUAL(jointext(weeks, ","), "tutorial,week1,week7,weekend1,sserafim", "Weeks are not in Funkin's order.") + TEST_ASSERT_EQUAL(fnf_srt_time("00:01:02,500"), 62500, "A SubRip time was read wrong.") + TEST_ASSERT_NULL(fnf_srt_time("not a time"), "A broken SubRip time was read as a time.") + +/// Every head that pulls faces has every face, and the head's own eye cover; a singer's face goes +/// on with a note, back to idle after, and off when the battle's over. +/datum/unit_test/fnf_faces + +/datum/unit_test/fnf_faces/Run() + var/list/states = icon_states('voidcrew/modules/fnf/icons/fnf_faces.dmi') + var/list/expressions = list("idle", "left", "down", "up", "right", "miss", "hey", "dead") + for(var/face_set in list("human", "human_white", "human_demon", "lizard", "moth", "ethereal", "skeleton")) + for(var/expression in expressions) + TEST_ASSERT("[face_set]_[expression]" in states, "The [face_set] faces have no [expression] face.") + for(var/face_set in list("human", "lizard", "moth", "ethereal", "skeleton")) + TEST_ASSERT("[face_set]_cover" in states, "The [face_set] faces don't cover the head's own eyes.") + TEST_ASSERT("[face_set]_windowcover" in states, "The [face_set] faces have no patch for a half-freed eye.") + for(var/expression in expressions) + TEST_ASSERT("[face_set]_window_[expression]" in states, "The [face_set] faces have no half-freed [expression] eye.") + TEST_ASSERT("[face_set]_mouth_[expression]" in states, "The [face_set] faces have no [expression] mouth for covered eyes.") + var/list/experiment_states = icon_states('voidcrew/modules/fnf/icons/fnf_faces_experiment.dmi') + for(var/expression in expressions) + TEST_ASSERT("experiment_[expression]" in experiment_states, "The Experiment has no [expression] face.") + TEST_ASSERT_NULL(get_fnf_face_set("plasmaman"), "Plasmamen pull faces.") + + var/mob/living/carbon/human/consistent/singer = allocate(/mob/living/carbon/human/consistent) + singer.update_limb_rig() + var/datum/limb_rig/sprites/humanoid/rig = singer.limb_rig + TEST_ASSERT(istype(rig), "A human has no humanoid rig to pull faces with.") + singer.fnf_sing(2, 3, "r_arm", EAST, null) + TEST_ASSERT_EQUAL(rig.fnf_face, "up", "Singing an up note didn't pull the up face.") + TEST_ASSERT(rig.is_pulling_fnf_face(), "The up face isn't held for the note.") + TEST_ASSERT_EQUAL(length(rig.fnf_face_images), 2, "The face isn't drawn over the head.") + singer.fnf_rest() + TEST_ASSERT_NULL(rig.fnf_face, "The face stayed on after the battle.") + TEST_ASSERT_NULL(rig.fnf_face_images, "The face is still drawn after the battle.") + +/// Corruption+'s cast all have looks, a body corrupts piece by piece and comes back clean, and a +/// song's character changes are read from its chart and its events file, in order, once each. +/datum/unit_test/fnf_corruption + +/datum/unit_test/fnf_corruption/Run() + for(var/character in GLOB.fnf_corruption_cast) + var/list/cast = GLOB.fnf_corruption_cast[character] + if(cast["look"]) + TEST_ASSERT(GLOB.fnf_opponents[cast["look"]], "Corruption+'s [character] has no look: [cast["look"]].") + + var/mob/living/carbon/human/consistent/singer = allocate(/mob/living/carbon/human/consistent) + singer.update_limb_rig() + singer.setDir(EAST) + var/datum/limb_rig/rig = singer.limb_rig + rig.set_corruption(0.1) + // The far side goes first: facing east, the left arm's taken before the right. + TEST_ASSERT(rig.get_corruption_of("l_arm", "l") > rig.get_corruption_of("r_arm", "l"), "Corruption didn't start on the far side.") + TEST_ASSERT(!rig.is_face_corrupted(), "The face was taken with barely any corruption.") + rig.set_corruption(1, "red") + TEST_ASSERT(length(rig.corruption_images), "Corruption drew nothing on the body.") + TEST_ASSERT(rig.is_face_corrupted(), "Full corruption didn't take the face.") + // The glowing face has a piece of its own, or the colour on the head would go over it too. + var/atom/movable/face_holder = rig.get_fnf_face_holder() + TEST_ASSERT(face_holder, "There's nothing for the face to be drawn on.") + TEST_ASSERT_NULL(face_holder.color, "Corruption coloured the face along with the head.") + TEST_ASSERT_EQUAL(length(fnf_tint_then_colour("#808080", list(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0))), 20, "A tint and a colour matrix don't make one matrix.") + var/datum/limb_rig/sprites/sprite_rig = rig + if(istype(sprite_rig)) + TEST_ASSERT(sprite_rig.sprite_saturation < 1, "Corruption didn't drain the body's own sprites.") + TEST_ASSERT("human_laugh" in icon_states('voidcrew/modules/fnf/icons/fnf_faces.dmi'), "There's no manic grin for Carol's face.") + // The cast's traced moves: corrupted Pico's, turned round for whichever way he faces. + TEST_ASSERT(length(fnf_traced_keyframes("cpico", "idle", WEST)), "Corrupted Pico's traced idle is missing.") + var/list/left_west = fnf_traced_keyframes("cpico", "singLEFT", WEST) + var/list/right_east = fnf_traced_keyframes("cpico", "singRIGHT", EAST) + TEST_ASSERT(length(left_west) && length(left_west) == length(right_east), "A traced move isn't swapped for a singer facing the other way from the sprite.") + TEST_ASSERT_NULL(fnf_traced_keyframes("bf", "idle", WEST), "Someone nobody traced got a traced move.") + // A long note stutters on a move's first two frames before playing it through; a short one just plays it. + var/list/move = list(list(list("a" = 1), 0.4), list(list("b" = 1), 0.4), list(list("c" = 1), 0.4)) + TEST_ASSERT_EQUAL(length(fnf_traced_hold(move, 1, list())), 4, "A short note's traced move didn't just play through.") + var/list/held = fnf_traced_hold(move, 3, list()) + TEST_ASSERT(length(held) > 4 && held[1][1] == move[1][1] && held[2][1] == move[2][1] && held[3][1] == move[1][1], "A long note's traced move doesn't repeat its first frames.") + var/list/frame_states = icon_states('voidcrew/modules/fnf/icons/fnf_healthbar_tainted.dmi') + TEST_ASSERT(("hole" in frame_states) && ("block" in frame_states), "The tainted health bar has no opening for its colours to fill.") + var/list/prop_states = icon_states('voidcrew/modules/fnf/icons/fnf_props.dmi') + var/list/seat_states = icon_states('voidcrew/modules/fnf/icons/fnf_speakers.dmi') + for(var/character in GLOB.fnf_corruption_cast) + var/list/cast = GLOB.fnf_corruption_cast[character] + TEST_ASSERT(cast["bar"], "Corruption+'s [character] has no colour on the health bar.") + TEST_ASSERT(!cast["seat"] || (cast["seat"] in seat_states), "Corruption+'s [character] sits on something that isn't drawn: [cast["seat"]].") + TEST_ASSERT(!cast["prop"] || (cast["prop"] in prop_states), "Corruption+'s [character] has a prop that isn't drawn: [cast["prop"]].") + TEST_ASSERT(("speakers" in seat_states) && ("abot" in seat_states), "Girlfriend's speakers or Nene's A-Bot aren't drawn.") + for(var/lane in list("left", "down", "up", "right")) + TEST_ASSERT("dance_pad_lit_[lane]" in prop_states, "Kapi's dance pad has no [lane] panel to light up.") + var/list/coats = icon_states('voidcrew/modules/fnf/icons/corruption_coats.dmi') + for(var/coat in list("coat_humanoid_chest_8", "coat_humanoid_chest_f_1", "coat_experiment_tail_4", "coat_milkie_head_8")) + TEST_ASSERT(coat in coats, "Corruption's coat isn't baked for [coat].") + TEST_ASSERT_EQUAL(length(fnf_blend_colour_matrices(color_matrix_saturation(0.5), list(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0), 0.5)), 20, "Colour matrices don't blend.") + // Fighting it off frees the head first, while the far side stays taken. + rig.set_corruption(0.7) + TEST_ASSERT(rig.get_corruption_of("head", "l") < rig.get_corruption_of("l_arm", "l"), "Fighting corruption off didn't free the head before the far side.") + TEST_ASSERT_EQUAL(rig.get_corruption_of("l_arm", "l"), 1, "Fighting corruption off freed the far arm early.") + TEST_ASSERT(rig.is_face_half_freed(), "Fighting corruption off didn't give half the face back.") + rig.set_corruption(0.5) + TEST_ASSERT(!rig.is_face_corrupted(), "Fighting corruption well off didn't give the whole face back.") + rig.set_corruption(0) + TEST_ASSERT_NULL(rig.corruption_images, "Corruption stayed drawn on the body after it was cleared.") + if(istype(sprite_rig)) + TEST_ASSERT_EQUAL(sprite_rig.sprite_saturation, 1, "The body's sprites stayed drained after corruption was cleared.") + + var/datum/fnf_song/song = new("data/fnf/tests/", "none") + song.path = "data/fnf/tests/" + var/list/changes = song.read_character_changes(list("events" = list( + list(2000, list(list("Change Character", "1", "kapi1"))), + list(1000, list(list("Change Character", "dad", "kapi"), list("Change Character", "2", "speakers"))), + list(2000, list(list("Change Character", "1", "kapi1"))), + list(3000, list(list("Play Animation", "meow", "Dad"))), + ))) + TEST_ASSERT_EQUAL(length(changes), 3, "A chart's character changes weren't read once each.") + var/list/acted = song.read_overlay_events(list("events" = list( + list(95620, list(list("Play Animation", "scream", "BF"), list("Screen Shake", "0.3, 0.005", "0.3, 0.005"))), + ))) + TEST_ASSERT_EQUAL(length(acted), 2, "A chart's animation and shake events weren't read.") + var/list/scream = acted[1] + TEST_ASSERT(scream[2] == "anim" && scream[3] == "scream" && scream[4] == "bf", "A chart's scream wasn't read as the player's.") + var/list/screens = song.read_overlay_events(list("events" = list( + list(1000, list(list("Lightr", "0.5", ""), list("badapplelol", "a", "1"), list("flashBom", "1", "2"))), + ))) + TEST_ASSERT_EQUAL(length(screens), 2, "A chart's flash and silhouette events weren't read, or its glow was.") + var/list/flash = screens[1] + TEST_ASSERT(flash[2] == "light" && flash[3] == "#960030", "A chart's crimson flash wasn't read as one.") + var/list/overlay_states = icon_states('voidcrew/modules/fnf/icons/fnf_corruption_overlays.dmi') + TEST_ASSERT(("scary_top" in overlay_states) && ("scary_bottom" in overlay_states), "The overlays have no edges to fill a taller view with.") + var/list/first = changes[1] + TEST_ASSERT(first[1] == 1000 && first[2] == "opponent" && first[3] == "kapi", "A chart's character changes are out of order, or their roles are wrong.") + +#undef FNF_TEST_SONGS diff --git a/voidcrew/modules/unit_tests/gags.dm b/voidcrew/modules/unit_tests/gags.dm new file mode 100644 index 00000000000..cdd19e9a71f --- /dev/null +++ b/voidcrew/modules/unit_tests/gags.dm @@ -0,0 +1,27 @@ +/// The gags have what they need: their maps, the icon states they draw, and timings in order. +/datum/unit_test/gags + +/datum/unit_test/gags/Run() + for(var/map_name in list("stairs.dmm", "trains.dmm", "tram.dmm")) + TEST_ASSERT(fexists("_maps/voidcrew/templates/gags/[map_name]"), "The gags' [map_name] is missing.") + TEST_ASSERT("skibidi" in icon_states('voidcrew/modules/gags/icons/gags_face.dmi'), "The skibidi face is missing.") + TEST_ASSERT("head_only" in icon_states('voidcrew/modules/gags/icons/gags_mask.dmi'), "The skibidi mask is missing.") + TEST_ASSERT("toilet10" in icon_states('icons/obj/watercloset.dmi'), "The skibidi toilet is missing.") + // What the maps draw as scenery, in place of the things that run the tram. + TEST_ASSERT("subfloor" in icon_states('icons/obj/tram/tram_structure.dmi'), "The tram's subfloor is missing.") + TEST_ASSERT("closed" in icon_states('icons/obj/doors/airlocks/tram/tram.dmi'), "The tram's doors are missing.") + TEST_ASSERT("desto_blank" in icon_states('icons/obj/tram/tram_display.dmi'), "The tram's displays are missing.") + TEST_ASSERT("tram_controls" in icon_states('icons/obj/machines/computer.dmi'), "The tram's controls are missing.") + TEST_ASSERT("stairs_wood" in icon_states('icons/obj/stairs.dmi'), "The stairs are missing.") + // The trip, seventeen steps, and the floor: one for each of the eighteen steps and one more. + TEST_ASSERT_EQUAL(length(GLOB.gag_stairs_hits), 19, "The fall down the stairs hits the wrong number of things.") + TEST_ASSERT(sorted(GLOB.gag_stairs_hits), "The fall down the stairs' hits are out of order.") + TEST_ASSERT(sorted(GLOB.gag_springlock_crunches), "The springlocks' crunches are out of order.") + for(var/spill in GLOB.gag_springlock_spills) + TEST_ASSERT(spill in GLOB.gag_springlock_crunches, "The springlocks spill an organ at [spill], which isn't a crunch.") + +/datum/unit_test/gags/proc/sorted(list/times) + for(var/index in 2 to length(times)) + if(times[index] <= times[index - 1]) + return FALSE + return TRUE diff --git a/voidcrew/modules/unit_tests/limb_physics.dm b/voidcrew/modules/unit_tests/limb_physics.dm new file mode 100644 index 00000000000..64a9ba7d83f --- /dev/null +++ b/voidcrew/modules/unit_tests/limb_physics.dm @@ -0,0 +1,130 @@ +/// A physics ragdoll's bones come from the rig's skeleton, every piece of the rig follows them, and +/// joints bend the right way facing either side. With the physics library present, a ragdoll +/// steps, stays in one piece, and hands the body back to its animations after. Serverblight grows +/// on it, holds together, and comes off again cleanly. +/datum/unit_test/limb_physics + +/datum/unit_test/limb_physics/Run() + for(var/species_type in list(/datum/species/experiment, /datum/species/human, /datum/species/lizard)) + var/mob/living/carbon/human/consistent/body = allocate(/mob/living/carbon/human/consistent) + body.set_species(species_type) + body.update_limb_rig() + var/datum/limb_rig/sprites/rig = body.limb_rig + TEST_ASSERT(istype(rig), "[species_type] has no sprite-built rig to simulate.") + for(var/facing in list(EAST, SOUTH)) + var/list/segments = rig.get_physics_segments(facing) + TEST_ASSERT_EQUAL(length(segments), 12, "[species_type] facing [dir2text(facing)] has the wrong number of physics segments.") + var/list/identity = list() + for(var/part_id in segments) + var/list/segment = segments[part_id] + TEST_ASSERT(segment["width"] > 0 && segment["density"] > 0, "[part_id] on [species_type] has no size or weight.") + if(part_id != "chest") + TEST_ASSERT(segments[segment["parent"]], "[part_id] on [species_type] hangs off a segment that isn't there.") + identity[part_id] = matrix() + // Every piece of the rig gets put somewhere: body, clothes, shoes, held items, the pivot. + var/list/matrices = rig.get_physics_matrices(identity, facing) + for(var/part_id in rig.parts) + TEST_ASSERT(matrices[rig.parts[part_id]], "[species_type]'s [part_id] piece isn't placed by physics.") + for(var/part_id in rig.cloth_parts) + TEST_ASSERT(matrices[rig.cloth_parts[part_id]], "[species_type]'s [part_id] clothes aren't placed by physics.") + for(var/side in list("l", "r")) + TEST_ASSERT(matrices[rig.shoe_parts[side]] && matrices[rig.item_parts[side]], "[species_type]'s [side] shoe or held item isn't placed by physics.") + TEST_ASSERT(matrices[rig.pivot], "[species_type]'s torso pivot isn't reset by physics.") + + // Elbows and knees bend one way facing east, and the other facing west. + var/list/east_elbow = limb_physics_joint_limits("elbow", EAST) + var/list/west_elbow = limb_physics_joint_limits("elbow", WEST) + TEST_ASSERT(east_elbow[1] >= 0 && east_elbow[2] > 0, "An elbow facing east bends backwards.") + TEST_ASSERT(west_elbow[1] < 0 && west_elbow[2] <= 0, "An elbow facing west isn't mirrored.") + + // A point pushed through a turn and back comes out where it started. + var/matrix/turned = rig_joint_matrix(10, 20, 1, 30, 14, 5) + var/list/at = rig_apply_matrix(turned, 10, 20) + TEST_ASSERT(abs(at[1] - 14) < 0.001 && abs(at[2] - 5) < 0.001, "rig_apply_matrix() doesn't put a joint where rig_joint_matrix() moved it.") + TEST_ASSERT(abs(rig_matrix_angle(turned) + 30) < 0.001, "rig_matrix_angle() doesn't read a turn back.") + + if(!vcphys_available()) + return + var/mob/living/carbon/human/consistent/ragdoll = allocate(/mob/living/carbon/human/consistent) + ragdoll.set_species(/datum/species/experiment) + ragdoll.update_limb_rig() + TEST_ASSERT(ragdoll.set_limb_physics(TRUE), "A ragdoll couldn't go physical with the library loaded.") + var/datum/limb_physics/physics = ragdoll.limb_rig.physics + TEST_ASSERT_EQUAL(length(physics.body_by_part), 12, "The ragdoll is missing bodies.") + TEST_ASSERT_EQUAL(length(physics.joint_by_part), 11, "The ragdoll is missing joints.") + physics.push("chest", 30, 5) + for(var/i in 1 to 20) + physics.process(0.1) + TEST_ASSERT(!QDELETED(physics), "The ragdoll's simulation blew up.") + TEST_ASSERT_EQUAL(physics.steps, 20 * 6, "The ragdoll didn't take a fixed number of steps.") + ragdoll.set_limb_physics(FALSE) + TEST_ASSERT_NULL(ragdoll.limb_rig.physics, "Turning physics off left it on.") + // The torso moves with its pivot in a pose; left where the ragdoll had it, it'd float off on + // its own. (animate() with no time sets the transform at once.) + var/matrix/torso = ragdoll.limb_rig.parts["chest"].transform + TEST_ASSERT(torso.a == 1 && torso.b == 0 && torso.c == 0 && torso.d == 0 && torso.e == 1 && torso.f == 0, "The torso stayed where the ragdoll left it after physics let go.") + + // Serverblight: long arms with six fingers each, a second pair of arms, a hand out of the mouth + // and two mirrored heads (28 growths, 17 fingertips on 5 hands), shaking itself apart without + // blowing up, hunting without walking tile to tile, taking someone in, and letting it all go. + var/mob/living/carbon/human/consistent/victim = allocate(/mob/living/carbon/human/consistent) + victim.set_species(/datum/species/experiment) + victim.update_limb_rig() + victim.set_limb_physics(TRUE) + var/datum/limb_physics/blight = victim.limb_rig.physics + TEST_ASSERT(blight.blight(), "Serverblight couldn't take a physical body.") + TEST_ASSERT(!blight.blight(), "Serverblight took the same body twice.") + TEST_ASSERT_EQUAL(length(blight.growths), 28, "Serverblight grew the wrong number of pieces.") + TEST_ASSERT_EQUAL(length(blight.tips), 17, "Serverblight has the wrong number of fingertips.") + TEST_ASSERT_EQUAL(length(blight.hands), 5, "Serverblight has the wrong number of hands.") + TEST_ASSERT(HAS_TRAIT(victim, TRAIT_IMMOBILIZED), "Serverblight's victim can still walk.") + var/datum/serverblight_chase/chase = new(blight) + TEST_ASSERT(!QDELETED(chase) && blight.chase == chase, "Serverblight couldn't start hunting.") + var/obj/effect/abstract/limb_rig_part/growth = blight.growths[1][1] + for(var/i in 1 to 30) + blight.process(0.1) + chase.process(0.1) + TEST_ASSERT(!QDELETED(blight) && !QDELETED(chase), "Serverblight's simulation blew up.") + // Killed, it lies slack; brought back, it's whole again. + victim.death() + chase.process(0.1) + TEST_ASSERT(blight.dormant, "Serverblight kept going after it died.") + chase.rise() + TEST_ASSERT(victim.stat != DEAD, "Serverblight didn't get back up.") + TEST_ASSERT(!blight.dormant && blight.blighted && length(blight.growths) == 28, "Serverblight got back up without its body.") + var/mob/living/carbon/human/consistent/prey = allocate(/mob/living/carbon/human/consistent) + prey.set_species(/datum/species/human) + prey.update_limb_rig() + // Slower for every hand on them, and back to normal when they're let go. + chase.hold(prey, 2) + var/datum/movespeed_modifier/grip = prey.has_movespeed_modifier(/datum/movespeed_modifier/serverblight_grip) + TEST_ASSERT(grip?.multiplicative_slowdown > 0, "Serverblight's hands don't slow anyone down.") + chase.hold(prey, 4) + TEST_ASSERT(grip.multiplicative_slowdown > 2 * 0.8 * 0.99, "More of Serverblight's hands don't slow anyone down more.") + chase.hold(prey, 100) + TEST_ASSERT(grip.multiplicative_slowdown <= 6, "Serverblight's hands slow people down without limit.") + chase.hold(null, 0) + TEST_ASSERT(!prey.has_movespeed_modifier(/datum/movespeed_modifier/serverblight_grip), "Serverblight's hands didn't let go.") + var/health_before = victim.maxHealth + chase.absorb(prey) + TEST_ASSERT_EQUAL(victim.maxHealth, health_before + 100, "Taking someone in didn't make Serverblight tougher.") + chase.update_hitboxes() + TEST_ASSERT_EQUAL(length(chase.hitboxes), 2, "Taking someone in didn't make Serverblight bigger to hit.") + var/obj/effect/serverblight_hitbox/hitbox = chase.hitboxes[1] + TEST_ASSERT_EQUAL(prey.loc, victim, "Serverblight didn't take its prey in.") + // Their body (12), their forearms two more hands. + TEST_ASSERT_EQUAL(length(blight.growths), 28 + 12, "Serverblight didn't grow its prey's body on.") + TEST_ASSERT_EQUAL(length(blight.hands), 5 + 2, "Serverblight didn't get more hands from its prey.") + TEST_ASSERT(length(blight.glued), "Serverblight didn't glue its prey into itself.") + for(var/i in 1 to 10) + blight.process(0.1) + chase.process(0.1) + TEST_ASSERT(!QDELETED(blight), "Serverblight blew up with someone grown on.") + victim.set_limb_physics(FALSE) + TEST_ASSERT(QDELETED(chase), "Serverblight kept hunting after physics let go.") + TEST_ASSERT(QDELETED(hitbox), "Serverblight's hitboxes stayed after physics let go.") + TEST_ASSERT_EQUAL(victim.maxHealth, health_before, "Serverblight's victim kept its extra health after physics let go.") + TEST_ASSERT(isturf(prey.loc), "Serverblight kept its prey after physics let go.") + TEST_ASSERT(!HAS_TRAIT(victim, TRAIT_IMMOBILIZED) && !HAS_TRAIT(prey, TRAIT_IMMOBILIZED), "Serverblight's victims stayed held after physics let go.") + TEST_ASSERT(!(growth in victim.vis_contents), "Serverblight's growths stayed on after physics let go.") + TEST_ASSERT(victim.set_dir_on_move, "Serverblight's victim can't turn to walk any more.") diff --git a/voidcrew/modules/unit_tests/limb_rig.dm b/voidcrew/modules/unit_tests/limb_rig.dm new file mode 100644 index 00000000000..ff1e5a1e5be --- /dev/null +++ b/voidcrew/modules/unit_tests/limb_rig.dm @@ -0,0 +1,92 @@ +/// Living human-shaped mobs outside the roundstart species get cut into moving pieces with the +/// Overanimated quirk, and get their sprite back when they die. +/datum/unit_test/limb_rig + +/datum/unit_test/limb_rig/Run() + var/mob/living/carbon/human/consistent/dummy = allocate(/mob/living/carbon/human/consistent) + dummy.set_species(/datum/species/skeleton) + dummy.update_limb_rig() + TEST_ASSERT_NULL(dummy.limb_rig, "A skeleton without the Overanimated quirk got a limb rig.") + dummy.add_quirk(/datum/quirk/overanimated) + TEST_ASSERT_NOTNULL(dummy.limb_rig, "A living skeleton with the quirk did not get a limb rig.") + var/body = dummy.overlays_standing[BODYPARTS_LAYER] + TEST_ASSERT_NOTNULL(body, "The human has no body overlays to rig.") + TEST_ASSERT(!(body in dummy.overlays), "The rigged human still draws its body on the mob.") + var/obj/effect/abstract/limb_rig_part/torso = dummy.limb_rig.parts["chest"] + TEST_ASSERT(length(torso.overlays), "The rig's torso piece has nothing on it.") + + // Redraws while rigged go to the pieces, not the mob. + dummy.update_body_parts(update_limb_data = TRUE) + TEST_ASSERT(!(dummy.overlays_standing[BODYPARTS_LAYER] in dummy.overlays), "A redraw put the body back on the rigged mob.") + + dummy.death() + TEST_ASSERT_NULL(dummy.limb_rig, "A dead human kept its limb rig.") + TEST_ASSERT(length(dummy.overlays), "A human that lost its rig didn't get its sprite back.") + +/// Every roundstart species is rigged without the quirk, and a tail comes out onto its own piece. +/datum/unit_test/limb_rig_species + +/datum/unit_test/limb_rig_species/Run() + for(var/species_type in list(/datum/species/human, /datum/species/human/felinid, /datum/species/lizard, /datum/species/moth, /datum/species/plasmaman, /datum/species/ethereal)) + var/mob/living/carbon/human/consistent/person = allocate(/mob/living/carbon/human/consistent) + person.set_species(species_type) + person.update_limb_rig() + TEST_ASSERT_NOTNULL(person.limb_rig, "[species_type] is not rigged without the Overanimated quirk.") + TEST_ASSERT(istype(person.limb_rig, /datum/limb_rig/sprites/humanoid), "[species_type] is not on the generated body.") + var/datum/limb_rig/sprites/humanoid/rig = person.limb_rig + TEST_ASSERT(length(rig.head_images), "[species_type]'s own head is not on its generated body.") + TEST_ASSERT(length(rig.skin_images) == 12, "[species_type]'s generated body is missing pieces of skin.") + var/mob/living/carbon/human/consistent/lizard = allocate(/mob/living/carbon/human/consistent) + lizard.set_species(/datum/species/lizard) + lizard.update_limb_rig() + if(lizard.get_organ_slot(ORGAN_SLOT_EXTERNAL_TAIL)) + TEST_ASSERT(length(lizard.limb_rig.tail_part.overlays), "A lizard's tail did not come out onto its tail piece.") + +/// Experiments are always rigged, quirk or not, built from their own piece sprites, with worn +/// clothes going on the cloth pieces rather than the body ones. +/datum/unit_test/limb_rig_experiment + +/datum/unit_test/limb_rig_experiment/Run() + var/mob/living/carbon/human/consistent/expie = allocate(/mob/living/carbon/human/consistent) + expie.set_species(/datum/species/experiment) + expie.update_limb_rig() + TEST_ASSERT(istype(expie.limb_rig, /datum/limb_rig/sprites), "An Experiment without the Overanimated quirk has no sprite-built limb rig.") + var/datum/limb_rig/sprites/rig = expie.limb_rig + var/uniform = allocate(/obj/item/clothing/under/color/grey) + TEST_ASSERT(expie.equip_to_slot_if_possible(uniform, ITEM_SLOT_ICLOTHING), "The Experiment could not put on a jumpsuit.") + var/obj/effect/abstract/limb_rig_part/torso = rig.parts["chest"] + var/obj/effect/abstract/limb_rig_part/torso_cloth = rig.cloth_parts["chest"] + TEST_ASSERT_EQUAL(length(torso.overlays), 1, "An Experiment's torso piece shows more than its own sprite.") + TEST_ASSERT(length(torso_cloth.overlays), "An Experiment's jumpsuit is not on its torso's cloth piece.") + TEST_ASSERT_EQUAL(expie.get_bloodtype()?.id, "EXP", "An Experiment does not have its yellow blood.") + expie.apply_damage(40, BRUTE, BODY_ZONE_CHEST) + expie.update_damage_overlays() + TEST_ASSERT(length(torso.overlays) > 1, "A wounded Experiment's torso piece shows no wounds.") + expie.death() + TEST_ASSERT_NULL(expie.limb_rig, "A dead Experiment kept its limb rig.") + +/// Milkies are Experiments on their own sprites: white, short-legged, with faces of their own. +/datum/unit_test/limb_rig_milkie + +/datum/unit_test/limb_rig_milkie/Run() + var/mob/living/carbon/human/consistent/milkie = allocate(/mob/living/carbon/human/consistent) + milkie.set_species(/datum/species/experiment/milkie) + milkie.update_limb_rig() + TEST_ASSERT(istype(milkie.limb_rig, /datum/limb_rig/sprites/experiment/milkie), "A Milkie has no Milkie limb rig.") + var/datum/limb_rig/sprites/rig = milkie.limb_rig + TEST_ASSERT_EQUAL(rig.sprite_icon, 'voidcrew/modules/expie/icons/milkie_rig.dmi', "A Milkie is drawn with the wrong sprites.") + var/list/leg = rig.skeleton["east"]["l_leg"] + var/list/hips = leg[1] + TEST_ASSERT(hips[2] < 20, "A Milkie's legs are as long as an Experiment's.") + TEST_ASSERT_EQUAL(milkie.get_bloodtype()?.id, "EXP", "A Milkie does not have an Experiment's blood.") + var/list/body_states = icon_states('voidcrew/modules/expie/icons/milkie_bodyparts.dmi') + for(var/obj/item/bodypart/limb as anything in milkie.bodyparts) + TEST_ASSERT("[limb.limb_id]_[limb.body_zone]" in body_states, "A Milkie's [limb.body_zone] has no sprite.") + var/list/face_states = icon_states('voidcrew/modules/fnf/icons/fnf_faces_experiment.dmi') + var/list/corrupt_states = icon_states('voidcrew/modules/fnf/icons/fnf_faces_experiment_corrupt.dmi') + for(var/expression in list("idle", "left", "down", "up", "right", "miss", "hey", "dead")) + TEST_ASSERT("milkie_[expression]" in face_states, "A Milkie has no [expression] face.") + TEST_ASSERT("milkie_corrupt_[expression]" in corrupt_states, "A Milkie has no corrupted [expression] face.") + TEST_ASSERT("milkie_corrupthalf_[expression]" in corrupt_states, "A Milkie has no half-freed [expression] grin.") + TEST_ASSERT("milkie_window_[expression]" in face_states, "A Milkie has no half-freed [expression] eye.") + TEST_ASSERT("hands_milkie_l_forearm" in icon_states('voidcrew/modules/fnf/icons/corruption_64.dmi'), "A Milkie's hands can't be corrupted.") diff --git a/voidcrew/modules/unit_tests/weird_route.dm b/voidcrew/modules/unit_tests/weird_route.dm new file mode 100644 index 00000000000..ca0f6df876b --- /dev/null +++ b/voidcrew/modules/unit_tests/weird_route.dm @@ -0,0 +1,22 @@ +/// The Weird Route's pieces are all there: its icons, every sheet the Meat Factory lays out, and all of +/// Moffer's lines for the asking. +/datum/unit_test/weird_route + +/datum/unit_test/weird_route/Run() + var/list/ground = icon_states('voidcrew/modules/weird_route/icons/weird_route.dmi') + for(var/state in list("path", "grass", "shore", "water", "water_far", "white", "leaves1", "shadow", "solid")) + TEST_ASSERT(state in ground, "The lake has no \"[state]\" to draw.") + var/list/box = icon_states('voidcrew/modules/weird_route/icons/weird_route_box.dmi') + TEST_ASSERT(("fill" in box) && ("border" in box) && ("moffer" in box), "The text box or Moffer's face is missing.") + TEST_ASSERT_EQUAL(length(icon_states('voidcrew/modules/weird_route/icons/weird_route_pinwheel.dmi')), 4, "The pinwheel's memories or its wedge are missing.") + TEST_ASSERT("fade" in icon_states('voidcrew/modules/weird_route/icons/weird_route_fade.dmi'), "The ominous fade is missing.") + var/list/sheet_states = icon_states('voidcrew/modules/weird_route/icons/weird_route_sheets.dmi') + for(var/list/sheet as anything in GLOB.weird_route_sheets) + for(var/column in 0 to sheet[2] - 1) + for(var/row in 0 to sheet[3] - 1) + TEST_ASSERT("[sheet[1]]_[column]_[row]" in sheet_states, "The Meat Factory's sheet [sheet[1]] has no square [column], [row].") + // The game asks 36 different ways, the last over and over; and has five answers to "Stop". + TEST_ASSERT_EQUAL(length(GLOB.weird_route_proceed_lines), 36, "Moffer's lines for the asking are off.") + TEST_ASSERT_EQUAL(length(GLOB.weird_route_stop_lines), 5, "Moffer's answers to \"Stop\" are off.") + TEST_ASSERT_EQUAL(weird_route_blend("#ffffff", "#ff0000", 0.4), "#ff9999", "Colours don't blend partway.") + TEST_ASSERT_EQUAL(weird_route_blend("#000000", "#ffffff", 2), "#ffffff", "Blending goes past the second colour.") diff --git a/voidcrew/modules/weird_route/icons/weird_route.dmi b/voidcrew/modules/weird_route/icons/weird_route.dmi new file mode 100644 index 00000000000..01737374e1f Binary files /dev/null and b/voidcrew/modules/weird_route/icons/weird_route.dmi differ diff --git a/voidcrew/modules/weird_route/icons/weird_route_box.dmi b/voidcrew/modules/weird_route/icons/weird_route_box.dmi new file mode 100644 index 00000000000..4f6e4aa4200 Binary files /dev/null and b/voidcrew/modules/weird_route/icons/weird_route_box.dmi differ diff --git a/voidcrew/modules/weird_route/icons/weird_route_fade.dmi b/voidcrew/modules/weird_route/icons/weird_route_fade.dmi new file mode 100644 index 00000000000..db4129e8bb8 Binary files /dev/null and b/voidcrew/modules/weird_route/icons/weird_route_fade.dmi differ diff --git a/voidcrew/modules/weird_route/icons/weird_route_mask.dmi b/voidcrew/modules/weird_route/icons/weird_route_mask.dmi new file mode 100644 index 00000000000..c5b697ed6f6 Binary files /dev/null and b/voidcrew/modules/weird_route/icons/weird_route_mask.dmi differ diff --git a/voidcrew/modules/weird_route/icons/weird_route_pinwheel.dmi b/voidcrew/modules/weird_route/icons/weird_route_pinwheel.dmi new file mode 100644 index 00000000000..c380cf41104 Binary files /dev/null and b/voidcrew/modules/weird_route/icons/weird_route_pinwheel.dmi differ diff --git a/voidcrew/modules/weird_route/icons/weird_route_sheets.dmi b/voidcrew/modules/weird_route/icons/weird_route_sheets.dmi new file mode 100644 index 00000000000..05181d4952b Binary files /dev/null and b/voidcrew/modules/weird_route/icons/weird_route_sheets.dmi differ diff --git a/voidcrew/modules/weird_route/places.dm b/voidcrew/modules/weird_route/places.dm new file mode 100644 index 00000000000..3ef04361b6e --- /dev/null +++ b/voidcrew/modules/weird_route/places.dm @@ -0,0 +1,305 @@ +/** + * The Weird Route: Deltarune Chapter 5's evening lake (obj_ch5_LW20W and its handoff), with Moffer + * in Noelle's place and whoever's sent there in Kris's. He talks them into the lake, a "Proceed" at + * a time. Walk all the way in and they come out in the Meat Factory: a white plane strewn with sprite + * sheets. Hesitate too long once he's under and it's over. + * + * Admins send someone (Admin.Fun: "Weird Route"). The music and sounds are the game's own, so they + * aren't shipped: put them in data/weird_route/ (see WEIRD_ROUTE_SOUNDS). Without them it plays silent. + * + * Both places are built on reserved ground when someone's sent, before they're taken (the Meat + * Factory takes a minute or two), and given back when they leave. Each kind shares one area, however + * many are down there at once. + */ + +/// Where the Weird Route's sounds are looked for. +#define WEIRD_ROUTE_SOUNDS "data/weird_route/" +/// Spelled out where the game's ending spells out " SIDE B". +#define WEIRD_ROUTE_DESTINATION " MEAT FACTORY" +/// Deltarune runs at 30 frames a second: this many deciseconds to a frame. +#define WEIRD_ROUTE_FRAME (1 / 3) +/// Our pixels to one of the game's (its units are its own pixels: Noelle's 46 tall, a person here 32). +#define WEIRD_ROUTE_SCALE 0.7 +/// The lake, in tiles: room enough that the camera never sees past it. +#define WEIRD_ROUTE_LAKE_WIDTH 72 +#define WEIRD_ROUTE_LAKE_HEIGHT 37 +/// The row everyone walks along. +#define WEIRD_ROUTE_LAKE_ROW 19 +/// Tiles of path to the west of where the scene's own room starts (the game's x of 0). +#define WEIRD_ROUTE_LAKE_LEFT 18 +/// The tile the shore's on: where they first get their feet wet, 400 of the game's units in. +#define WEIRD_ROUTE_LAKE_SHORE (WEIRD_ROUTE_LAKE_LEFT + 9) +/// The Meat Factory, in tiles. +#define WEIRD_ROUTE_FACTORY_WIDTH 160 +#define WEIRD_ROUTE_FACTORY_HEIGHT 130 + +/area/weird_route + name = "the lake" + icon = 'icons/area/areas_misc.dmi' + icon_state = "unknown" + area_flags = HIDDEN_AREA | EVENT_PROTECTED + default_gravity = STANDARD_GRAVITY + requires_power = FALSE + static_lighting = FALSE + // The sun going down over the water. + base_lighting_alpha = 255 + base_lighting_color = "#ffb486" + ambience_index = null + ambient_buzz = null + +/area/weird_route/meat_factory + name = "MEAT FACTORY" + // Nothing casts a shadow here but the two who arrive. + base_lighting_color = COLOR_WHITE + +/turf/open/indestructible/weird_route + name = "dirt path" + icon = 'voidcrew/modules/weird_route/icons/weird_route.dmi' + icon_state = "path" + footstep = FOOTSTEP_SAND + barefootstep = FOOTSTEP_SAND + clawfootstep = FOOTSTEP_SAND + heavyfootstep = FOOTSTEP_GENERIC_HEAVY + tiled_dirt = FALSE + +/turf/open/indestructible/weird_route/grass + name = "grass" + icon_state = "grass" + footstep = FOOTSTEP_GRASS + barefootstep = FOOTSTEP_GRASS + clawfootstep = FOOTSTEP_GRASS + +/turf/open/indestructible/weird_route/shore + name = "shore" + icon_state = "shore" + +/turf/open/indestructible/weird_route/water + name = "lake" + desc = "It's colder than it looks." + icon_state = "water" + footstep = FOOTSTEP_WATER + barefootstep = FOOTSTEP_WATER + clawfootstep = FOOTSTEP_WATER + heavyfootstep = FOOTSTEP_WATER + +/turf/open/indestructible/weird_route/water/far + icon_state = "water_far" + +/turf/open/indestructible/weird_route/white + name = "floor" + desc = "There's nothing here. It goes on forever." + icon_state = "white" + footstep = FOOTSTEP_FLOOR + barefootstep = FOOTSTEP_HARD_BAREFOOT + clawfootstep = FOOTSTEP_HARD_CLAW + +/// Scenery: trees and bushes turned for autumn, leaves, and the Meat Factory's sheets. Just looks. +/obj/effect/weird_route_scenery + name = "" + anchored = TRUE + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + layer = TURF_DECAL_LAYER + plane = FLOOR_PLANE + +/obj/effect/weird_route_scenery/Initialize(mapload, icon/look_icon, look_state, colour, offset_x = 0, offset_y = 0, standing = FALSE) + . = ..() + icon = look_icon + icon_state = look_state + color = colour + pixel_w = offset_x + pixel_z = offset_y + // Trees and bushes stand up, below people, so the white can take them with the rest. + if(standing) + plane = GAME_PLANE + layer = OBJ_LAYER + +/// A colour matrix turning something green the colour of autumn (tint, as "#rrggbb"). +/proc/weird_route_autumn(tint) + var/list/rgb = rgb2num(tint) + var/list/weights = list(0.25, 0.65, 0.1) + . = list() + for(var/channel in 1 to 3) + for(var/out in 1 to 3) + . += rgb[out] / 255 * weights[channel] * 1.5 + . += list(0, 0, 0) + +/** + * Builds the evening lake on reserved ground: an orange dirt path between rows of autumn trees, + * running east into the water: olive by the shore, then orange with the sky. Returns the reservation. + */ +/proc/weird_route_build_lake() + var/datum/turf_reservation/reservation = SSmapping.request_turf_block_reservation(WEIRD_ROUTE_LAKE_WIDTH, WEIRD_ROUTE_LAKE_HEIGHT, requester = "the Weird Route's lake") + if(!reservation) + return null + var/static/area/weird_route/lake + if(QDELETED(lake)) + lake = new + var/turf/origin = reservation.bottom_left_turfs[1] + for(var/turf/spot as anything in reservation.reserved_turfs) + var/x = spot.x - origin.x + 1 + var/y = spot.y - origin.y + 1 + var/turf_type = /turf/open/indestructible/weird_route/grass + if(x == WEIRD_ROUTE_LAKE_SHORE) + turf_type = /turf/open/indestructible/weird_route/shore + else if(x > WEIRD_ROUTE_LAKE_SHORE + 6) + turf_type = /turf/open/indestructible/weird_route/water/far + else if(x > WEIRD_ROUTE_LAKE_SHORE) + turf_type = /turf/open/indestructible/weird_route/water + else if(y >= WEIRD_ROUTE_LAKE_ROW - 2 && y <= WEIRD_ROUTE_LAKE_ROW + 2) + turf_type = /turf/open/indestructible/weird_route + var/area/old_area = spot.loc + spot.ChangeTurf(turf_type) + spot.change_area(old_area, lake) + if(x >= WEIRD_ROUTE_LAKE_SHORE) + continue + // Fallen leaves, and the trees above and below the path. + if(prob(30)) + new /obj/effect/weird_route_scenery(spot, 'voidcrew/modules/weird_route/icons/weird_route.dmi', "leaves[rand(1, 3)]") + var/from_path = abs(y - WEIRD_ROUTE_LAKE_ROW) + if((from_path == 5 || from_path == 9 || from_path == 13 || from_path == 17) && x % 2 == 1) + new /obj/effect/weird_route_scenery(spot, 'icons/obj/fluff/flora/jungletreesmall.dmi', "tree[rand(1, 6)]", weird_route_autumn(pick("#ffc21a", "#ff8a1c", "#ffd23a")), -32, 0, TRUE) + else if((y == WEIRD_ROUTE_LAKE_ROW - 3 || y == WEIRD_ROUTE_LAKE_ROW + 3) && prob(35)) + new /obj/effect/weird_route_scenery(spot, 'icons/obj/fluff/flora/ausflora.dmi', "leafybush_[rand(1, 3)]", weird_route_autumn(pick("#ff2a1a", "#e01810")), 0, 0, TRUE) + // Red bushes along the bank. + for(var/y in list(WEIRD_ROUTE_LAKE_ROW - 6, WEIRD_ROUTE_LAKE_ROW - 5, WEIRD_ROUTE_LAKE_ROW + 5, WEIRD_ROUTE_LAKE_ROW + 6)) + var/turf/bank = locate(origin.x + WEIRD_ROUTE_LAKE_SHORE - 1, origin.y + y - 1, origin.z) + new /obj/effect/weird_route_scenery(bank, 'icons/obj/fluff/flora/ausflora.dmi', "leafybush_[rand(1, 3)]", weird_route_autumn("#ff2a1a"), 0, 0, TRUE) + return reservation + +/// Our renders and tg's test screenshots, at their sprites' own size: list(index, columns, rows) of +/// 256px squares in weird_route_sheets.dmi. +GLOBAL_LIST_INIT(weird_route_sheets, list( + list(0, 2, 1), list(1, 3, 1), list(2, 2, 1), list(3, 2, 1), list(4, 1, 1), list(5, 2, 1), list(6, 1, 1), list(7, 1, 1), list(8, 2, 1), list(9, 1, 1), list(10, 2, 1), list(11, 2, 1), list(12, 3, 2), list(13, 3, 1), list(14, 3, 1), list(15, 2, 1), list(16, 2, 1), list(17, 3, 1), list(18, 2, 1), list(19, 2, 3), list(20, 3, 3), list(21, 1, 1), list(22, 1, 1), list(23, 2, 1), list(24, 2, 1), list(25, 1, 1), list(26, 2, 1), list(27, 2, 1), list(28, 2, 1), list(29, 1, 1), list(30, 2, 3), list(31, 1, 1), list(32, 3, 3), +)) + +/// Icon files laid out on the Meat Factory's floor as the sprite sheets they are, every state in a +/// grid: list(file, directions to show each state in). +GLOBAL_LIST_INIT(weird_route_icon_sheets, list( + list('icons/mob/human/bodyparts_greyscale.dmi', 4), + list('icons/mob/human/species/moth/bodyparts.dmi', 4), + list('icons/mob/human/species/lizard/bodyparts.dmi', 4), + list('icons/mob/human/human_face.dmi', 4), + list('icons/mob/human/species/lizard/lizard_tails.dmi', 4), + list('icons/mob/clothing/under/color.dmi', 4), + list('icons/mob/clothing/suits/armor.dmi', 4), + list('icons/mob/clothing/head/hats.dmi', 4), + list('voidcrew/modules/expie/icons/bodyparts.dmi', 4), + list('voidcrew/modules/expie/icons/milkie_bodyparts.dmi', 4), + list('voidcrew/modules/fnf/icons/fnf_faces.dmi', 4), + list('voidcrew/modules/fnf/icons/fnf_faces_corrupt.dmi', 4), + list('voidcrew/modules/limb_rig/icons/humanoid.dmi', 1), + list('icons/mob/simple/animal.dmi', 4), + list('icons/mob/simple/pets.dmi', 4), + list('icons/mob/silicon/robots.dmi', 4), + list('icons/mob/nonhuman-player/alien.dmi', 4), + list('icons/obj/medical/organs/organs.dmi', 1), + list('icons/obj/clothing/under/color.dmi', 1), + list('icons/obj/food/food.dmi', 1), + list('icons/obj/weapons/guns/ballistic.dmi', 1), + list('icons/obj/tools.dmi', 1), + list('icons/obj/drinks/drinks.dmi', 1), + list('icons/obj/toys/toy.dmi', 1), + list('icons/obj/fluff/flora/plants.dmi', 1), +)) + +/// The most states shown from any one icon file. +#define WEIRD_ROUTE_SHEET_STATES 48 + +/** + * Builds the Meat Factory on reserved ground: white as far as anyone can see, fully lit, the floor + * strewn with sprite sheets at their sprites' own size, in rows with white between: our renders, + * tg's test screenshots, and icon files laid out state by state. Returns the reservation. + */ +/proc/weird_route_build_factory() + var/datum/turf_reservation/reservation = SSmapping.request_turf_block_reservation(WEIRD_ROUTE_FACTORY_WIDTH, WEIRD_ROUTE_FACTORY_HEIGHT, requester = "the Weird Route's Meat Factory") + if(!reservation) + return null + var/static/area/weird_route/meat_factory/factory + if(QDELETED(factory)) + factory = new + var/turf/origin = reservation.bottom_left_turfs[1] + for(var/turf/spot as anything in reservation.reserved_turfs) + var/area/old_area = spot.loc + spot.ChangeTurf(/turf/open/indestructible/weird_route/white) + spot.change_area(old_area, factory) + CHECK_TICK + // Shelved in rows from the top, left to right, three tiles of white between each. + var/list/shelf = list(4, WEIRD_ROUTE_FACTORY_HEIGHT - 4, 0) + for(var/list/sheet as anything in GLOB.weird_route_sheets) + var/list/corner = weird_route_shelve(shelf, sheet[2] * 8, sheet[3] * 8) + if(!corner) + break + for(var/column in 0 to sheet[2] - 1) + for(var/row in 0 to sheet[3] - 1) + new /obj/effect/weird_route_scenery(locate(origin.x + corner[1] + column * 8, origin.y + corner[2] + row * 8, origin.z), 'voidcrew/modules/weird_route/icons/weird_route_sheets.dmi', "[sheet[1]]_[column]_[row]") + for(var/list/sheet as anything in GLOB.weird_route_icon_sheets) + var/icon/sheet_icon = sheet[1] + var/directions = sheet[2] + var/list/states = icon_states(sheet_icon) + if(!length(states)) + continue + states.Cut(min(length(states), WEIRD_ROUTE_SHEET_STATES) + 1) + var/icon/probe = icon(sheet_icon, states[1]) + var/cell_width = max(1, ceil(probe.Width() / 32)) + var/cell_height = max(1, ceil(probe.Height() / 32)) + // A state to a row of its directions; as many of those across as fit in 16 tiles. + var/across = max(1, floor(16 / (cell_width * directions))) + var/rows = ceil(length(states) / across) + var/list/corner = weird_route_shelve(shelf, across * cell_width * directions, rows * cell_height) + if(!corner) + break + for(var/index in 1 to length(states)) + var/column = (index - 1) % across + var/row = rows - 1 - floor((index - 1) / across) + for(var/facing in 1 to directions) + var/turf/spot = locate(origin.x + corner[1] + (column * directions + facing - 1) * cell_width, origin.y + corner[2] + row * cell_height, origin.z) + var/obj/effect/weird_route_scenery/sprite = new(spot, sheet_icon, states[index]) + sprite.dir = list(SOUTH, NORTH, EAST, WEST)[facing] + CHECK_TICK + return reservation + +/// Finds room on the Meat Factory's shelves (list(next x, top y, tallest so far)) for something this +/// many tiles across and high: its bottom left corner, or null once it's full. +/proc/weird_route_shelve(list/shelf, width, height) + if(shelf[1] + width > WEIRD_ROUTE_FACTORY_WIDTH - 4) + shelf[1] = 4 + shelf[2] -= shelf[3] + 3 + shelf[3] = 0 + if(shelf[2] - height < 4) + return null + . = list(shelf[1], shelf[2] - height) + shelf[1] += width + 3 + shelf[3] = max(shelf[3], height) + +ADMIN_VERB(weird_route_send, R_FUN, "Weird Route", "Send someone to the evening lake, where Moffer's waiting for them.", ADMIN_CATEGORY_FUN) + var/list/candidates = list() + for(var/mob/living/carbon/human/candidate in GLOB.player_list) + if(candidate.client && !candidate.weird_route) + candidates[key_name(candidate)] = candidate + if(!length(candidates)) + to_chat(user, span_warning("Nobody's free to go to the lake.")) + return + var/choice = tgui_input_list(user, "Who goes to the lake?", "Weird Route", sort_list(candidates)) + var/mob/living/carbon/human/chosen = candidates[choice] + if(!chosen || chosen.weird_route) + return + var/datum/weird_route/route = new(chosen) + if(QDELETED(route)) + return + message_admins("[key_name_admin(user)] sent [ADMIN_LOOKUPFLW(chosen)] down the Weird Route. It's being built: they're taken once it's ready.") + log_admin("[key_name(user)] sent [key_name(chosen)] down the Weird Route.") + BLACKBOX_LOG_ADMIN_VERB("Weird Route") + +ADMIN_VERB(weird_route_end, R_FUN, "Weird Route: Bring Back", "Bring someone back from the lake or the Meat Factory.", ADMIN_CATEGORY_FUN) + var/list/candidates = list() + for(var/datum/weird_route/route as anything in GLOB.weird_routes) + candidates[key_name(route.player)] = route + if(!length(candidates)) + to_chat(user, span_warning("Nobody's down the Weird Route.")) + return + var/choice = tgui_input_list(user, "Who comes back?", "Weird Route", sort_list(candidates)) + var/datum/weird_route/route = candidates[choice] + if(QDELETED(route)) + return + log_admin("[key_name(user)] brought [key_name(route.player)] back from the Weird Route.") + qdel(route) diff --git a/voidcrew/modules/weird_route/route.dm b/voidcrew/modules/weird_route/route.dm new file mode 100644 index 00000000000..8022cfb68f5 --- /dev/null +++ b/voidcrew/modules/weird_route/route.dm @@ -0,0 +1,734 @@ +/// Where the choice's options are centred and the heart starts, in pixels across the text box (the +/// game's choicer: 77.5, 243.5 and 156 of its 320, the box being 16 to 304). +#define WEIRD_ROUTE_LEFT_OPTION 96 +#define WEIRD_ROUTE_RIGHT_OPTION 354 +#define WEIRD_ROUTE_HEART_START 218 +/// How high the middle of the options is in the box: halfway up, as the game has them. +#define WEIRD_ROUTE_OPTION_Y 56 +/// How much bigger than its icon the heart's drawn, and where in its icon the heart itself is. +#define WEIRD_ROUTE_HEART_SCALE 2.5 +#define WEIRD_ROUTE_HEART_MIDDLE_X 14 +#define WEIRD_ROUTE_HEART_MIDDLE_Y 18.5 +/// How many pixels of them the sun lights, on its side. +#define WEIRD_ROUTE_SUNLIT 3 + +/// Trait source for everything the Weird Route holds its player and Moffer to. +#define WEIRD_ROUTE_TRAIT "weird_route" +/// How many times they're asked: the game's endind. +#define WEIRD_ROUTE_END_INDEX 74 +/// From which ask Moffer's under: the game's drownindex. +#define WEIRD_ROUTE_DROWN_INDEX 14 +/// Where the pair stand when the asking starts, in the game's units (64 to a tile). +#define WEIRD_ROUTE_HANDOFF_X 370 + +/// Sleeps this many of the game's frames, and gives up if the route ended meanwhile. +#define WEIRD_ROUTE_WAIT(frames) sleep((frames) * WEIRD_ROUTE_FRAME); if(QDELETED(src)) { return } + +/// Everyone down the Weird Route right now. +GLOBAL_LIST_EMPTY(weird_routes) + +/mob/living/carbon/human + /// The Weird Route this one's down, if any. + var/datum/weird_route/weird_route + +/** + * One trip down the Weird Route: its player, Moffer, the lake and (if they make it) the Meat + * Factory. The scene runs as the game's own does, frame for frame (30 to a second), with Moffer's + * lines in Noelle's place and the player's name in Kris's. + */ +/datum/weird_route + var/mob/living/carbon/human/player + var/mob/living/carbon/human/moffer + /// Where the player was, to go back to. + var/turf/came_from + var/datum/turf_reservation/lake + var/datum/turf_reservation/factory + /// The lake's bottom left turf (the game's x of 0 is WEIRD_ROUTE_LAKE_LEFT tiles east of it). + var/turf/origin + /// Where each of them is along the path, in the game's units, by mob. + var/list/positions = list() + /// Each of them against the sun, by mob (see /obj/effect/abstract/weird_route_silo). + var/list/silos = list() + /// Where the camera's centred, in the game's units, or null to follow the player. + var/camera_x + var/old_hud_version + + // The screen. + var/atom/movable/screen/weird_route/box/box + var/atom/movable/screen/weird_route/box/border/border + var/atom/movable/screen/weird_route/box/portrait/portrait + var/atom/movable/screen/weird_route/text/writing + var/atom/movable/screen/weird_route/text/option/left_option + var/atom/movable/screen/weird_route/text/option/right_option + var/atom/movable/screen/weird_route/heart/heart + /// All black, over everything but the box. + var/atom/movable/screen/weird_route/fill/blackout + /// The ominous fade while he remembers yesterday: the world going dark behind them but for a soft + /// patch up top, three times over, breathing. + var/list/atom/movable/screen/weird_route/pinwheel/clouds = list() + /// Whether the clouds are out (the game's fadecon: 1 while they are, 2 once they've gone). + var/clouds_state = 0 + /// White over everything when they've hesitated too long. + var/atom/movable/screen/weird_route/fill/whiteout + /// White behind the two of them, taking the lake away. + var/atom/movable/screen/weird_route/fill/behind/whiteness + /// The pinwheel (the game's _rotate_vfx): three places from life aboard, each showing through its + /// own third of a turning wheel, over black, a silhouette of Moffer kneeling in the middle. + var/list/atom/movable/screen/weird_route/pinwheel/memories = list() + /// The wedges the memories show through, by memory: each one's mask (a render target). + var/list/atom/movable/screen/weird_route/pinwheel/wedges = list() + /// Black behind it all, under the two of them. + var/atom/movable/screen/weird_route/fill/behind/void + /// Moffer kneeling, a silhouette in the middle of it: a Moffer of its own, kept nowhere. + var/atom/movable/screen/weird_route/pinwheel/silhouette + var/mob/living/carbon/human/vision + /// Whether it's going, and turning. + var/pinwheel_on = FALSE + var/pinwheel_turning = FALSE + var/pinwheel_slowing = FALSE + var/pinwheel_angle = 270 + /// How many degrees a frame it turns by, and how fast its ripples grow. + var/pinwheel_turn_speed = 0 + var/pinwheel_wave_speed = 0 + var/pinwheel_wave = 0 + /// How many of the memories are fading in. + var/pinwheel_segments = 0 + /// The ending's letters and their coloured ghosts. + var/list/atom/movable/screen/weird_route/text/chapter_letters = list() + + // The writer. + /// Whether a message is being typed out, waited on, or chosen from. + var/writer_busy = FALSE + /// Bumped to stop whatever's being typed. + var/writer_token = 0 + var/confirm_pressed = FALSE + var/list/choices + /// The option the heart's beside, 1 or 2, or 0 before either's been picked (it starts between them). + var/selected = 0 + /// Where the heart is across the box, and where it's gliding to. + var/heart_at = WEIRD_ROUTE_HEART_START + var/heart_target = WEIRD_ROUTE_HEART_START + /// The option chosen, or 0 while it's not. + var/chosen = 0 + /// How far the text jitters, in pixels. + var/text_shake = 0 + /// Whether the voice wavers. + var/voice_waver = FALSE + var/border_colour = COLOR_WHITE + /// How far the box's inside has gone from black to white, 0 to 1. + var/inner_white = 0 + var/highlight_colour = COLOR_YELLOW + + // The sound. + var/music_channel + var/chant_channel + var/static_channel + +/datum/weird_route/New(mob/living/carbon/human/player) + . = ..() + if(!player?.client) + qdel(src) + return + src.player = player + player.weird_route = src + GLOB.weird_routes += src + RegisterSignals(player, list(COMSIG_QDELETING, COMSIG_MOB_LOGOUT, COMSIG_LIVING_DEATH), PROC_REF(on_lost)) + INVOKE_ASYNC(src, PROC_REF(prepare)) + +/// Builds both places first (the Meat Factory takes a while), and only then takes them to the lake. +/datum/weird_route/proc/prepare() + var/datum/turf_reservation/built_factory = weird_route_build_factory() + var/datum/turf_reservation/built_lake = built_factory && weird_route_build_lake() + if(QDELETED(src)) + qdel(built_factory) + qdel(built_lake) + return + factory = built_factory + lake = built_lake + if(!lake || !player?.client) + message_admins("The Weird Route couldn't take [key_name_admin(player)]: [lake ? "they've gone" : "there's no room for it"].") + qdel(src) + return + message_admins("The Weird Route's ready: [key_name_admin(player)]'s at the lake.") + origin = lake.bottom_left_turfs[1] + came_from = get_turf(player) + music_channel = SSsounds.reserve_sound_channel(src) + chant_channel = SSsounds.reserve_sound_channel(src) + static_channel = SSsounds.reserve_sound_channel(src) + moffer = make_moffer() + hold(player) + RegisterSignal(player, COMSIG_MOB_KEYDOWN, PROC_REF(on_key)) + if(player.hud_used) + old_hud_version = player.hud_used.hud_version + player.hud_used.show_hud(HUD_STYLE_NOHUD) + make_screen() + play_scene() + +/datum/weird_route/Destroy() + GLOB.weird_routes -= src + writer_token++ + QDEL_LIST_ASSOC_VAL(silos) + sun_whitens(0) + if(player) + UnregisterSignal(player, list(COMSIG_QDELETING, COMSIG_MOB_LOGOUT, COMSIG_LIVING_DEATH)) + // Only once they've been taken (until then it's still building). + if(player && came_from) + UnregisterSignal(player, COMSIG_MOB_KEYDOWN) + let_go(player) + for(var/channel in list(music_channel, chant_channel, static_channel)) + SEND_SOUND(player, sound(null, channel = channel)) + if(player.client) + player.client.screen -= get_screen() + player.client.pixel_x = 0 + player.client.pixel_y = 0 + if(old_hud_version) + player.hud_used?.show_hud(old_hud_version) + player.hud_used?.plane_master_controllers[PLANE_MASTERS_GAME]?.remove_filter(list("weird_route_blur", "weird_route_soften")) + if(!QDELETED(player)) + player.forceMove(came_from) + if(player) + player.weird_route = null + player = null + if(moffer) + walk(moffer, 0) + QDEL_NULL(moffer) + QDEL_NULL(vision) + for(var/atom/movable/screen/part as anything in get_screen()) + qdel(part) + SSsounds.free_datum_channels(src) + // Giving the ground back deletes whatever's on it: anyone who followed them in goes back too. + var/turf/way_out = came_from || get_safe_random_station_turf() + for(var/datum/turf_reservation/place as anything in list(lake, factory)) + for(var/turf/spot as anything in place?.reserved_turfs) + for(var/mob/left in spot) + left.forceMove(way_out) + QDEL_NULL(lake) + QDEL_NULL(factory) + return ..() + +/datum/weird_route/proc/on_lost(datum/source) + SIGNAL_HANDLER + qdel(src) + +/// Holds someone to the scene: nowhere to go, nothing in their hands, nothing to hurt them. +/datum/weird_route/proc/hold(mob/living/carbon/human/who) + who.add_traits(list(TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED, TRAIT_GODMODE), WEIRD_ROUTE_TRAIT) + +/datum/weird_route/proc/let_go(mob/living/carbon/human/who) + who.remove_traits(list(TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED, TRAIT_GODMODE), WEIRD_ROUTE_TRAIT) + who.remove_filter("weird_route_sink") + who.underlays.Cut() + who.pixel_w = who.base_pixel_w + who.pixel_z = who.base_pixel_z + who.limb_rig?.set_seated(null) + +/** + * Someone against the sun, low over the far side of the lake (the game's "silo" sprites): dark red + * all over but for the few pixels on the sun's side, which it lights as they are. Drawn over them, + * as a copy of them with a dark copy of them laid in on top, shifted away from the sun. + */ +/obj/effect/abstract/weird_route_silo + appearance_flags = KEEP_TOGETHER | PIXEL_SCALE + layer = MOB_LAYER + 0.01 + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + var/obj/effect/abstract/weird_route_silo/shade/shade + +/obj/effect/abstract/weird_route_silo/Initialize(mapload, mob/living/who) + . = ..() + if(!who) + return + shade = new(null) + shade.vis_contents += who + vis_contents += list(who, shade) + +/obj/effect/abstract/weird_route_silo/Destroy() + vis_contents.Cut() + QDEL_NULL(shade) + return ..() + +/// The dark copy: only drawn where they are. +/obj/effect/abstract/weird_route_silo/shade + blend_mode = BLEND_INSET_OVERLAY + vis_flags = VIS_INHERIT_PLANE + layer = MOB_LAYER + 0.02 + pixel_w = -WEIRD_ROUTE_SUNLIT + +/obj/effect/abstract/weird_route_silo/shade/Initialize(mapload, mob/living/who) + . = ..() + // The game's silhouettes' dark red, a little of what's underneath showing through. + add_filter("weird_route_silo", 1, color_matrix_filter(list(0.06, 0.02, 0.01, 0.04, 0.02, 0.01, 0.02, 0.01, 0.01, 0.24, 0.05, 0.03))) + +/// Puts someone against the sun, or (dark FALSE) back in the light. +/datum/weird_route/proc/against_sun(mob/living/who, dark) + qdel(silos[who]) + silos -= who + if(dark) + silos[who] = new /obj/effect/abstract/weird_route_silo(get_turf(who), who) + +/// The lake's light going from the sunset's to white with the white behind them, 0 to 1. +/datum/weird_route/proc/sun_whitens(amount) + var/area/lake_area = origin && get_area(origin) + lake_area?.set_base_lighting(weird_route_blend("#ffb486", COLOR_WHITE, round(amount, 1 / 32))) + +/// Moffer, in Noelle's place: a bald moth, as he is aboard. +/datum/weird_route/proc/make_moffer() + var/mob/living/carbon/human/npc = new(origin) + npc.dna.features[FEATURE_MOTH_WINGS] = "Deathshead" + npc.dna.features[FEATURE_MOTH_ANTENNAE] = "Deathshead" + npc.dna.features[FEATURE_MOTH_MARKINGS] = "Deathshead" + npc.set_species(/datum/species/moth) + npc.gender = MALE + npc.physique = MALE + npc.age = 27 + npc.fully_replace_character_name(npc.real_name, "Moffer") + npc.set_hairstyle("Bald", update = FALSE) + npc.set_facial_hairstyle("Shaved", update = FALSE) + npc.set_eye_color("#554422") + npc.update_body(is_creating = TRUE) + npc.equip_to_slot_or_del(new /obj/item/clothing/under/color/grey(npc), ITEM_SLOT_ICLOTHING) + npc.equip_to_slot_or_del(new /obj/item/clothing/shoes/sneakers/black(npc), ITEM_SLOT_FEET) + if(!npc.dna.species.limb_rig_shape?["always"]) + npc.add_quirk(/datum/quirk/overanimated) + hold(npc) + return npc + +/datum/weird_route/proc/get_screen() + . = list() + for(var/atom/movable/screen/part as anything in list(box, border, portrait, writing, left_option, right_option, heart, blackout, whiteout, whiteness, void, silhouette) + clouds + memories + wedges + chapter_letters) + if(part) + . += part + +/datum/weird_route/proc/make_screen() + box = new(null, null, src) + border = new(null, null, src) + portrait = new(null, null, src) + writing = new(null, null, src) + left_option = new(null, null, src) + left_option.index = 1 + left_option.maptext_x = WEIRD_ROUTE_LEFT_OPTION - 70 + right_option = new(null, null, src) + right_option.index = 2 + right_option.maptext_x = WEIRD_ROUTE_RIGHT_OPTION - 70 + heart = new(null, null, src) + blackout = new(null, null, src) + blackout.color = COLOR_BLACK + whiteout = new(null, null, src) + // Over the box as well: hesitating whites all of it out. + whiteout.plane = ABOVE_HUD_PLANE + whiteness = new(null, null, src) + for(var/number in 1 to 3) + var/atom/movable/screen/weird_route/pinwheel/cloud = new(null, null, src) + cloud.icon = 'voidcrew/modules/weird_route/icons/weird_route_fade.dmi' + cloud.icon_state = "fade" + // Small, inside the view, and scaled up (see /datum/weird_route/proc/shape_clouds()). + cloud.screen_loc = "CENTER,CENTER" + clouds += cloud + var/atom/movable/screen/weird_route/pinwheel/wedge = new(null, null, src) + wedge.icon_state = "wedge" + wedge.alpha = 255 + wedge.render_target = "*weird_route_wedge_[number]" + wedges += wedge + var/atom/movable/screen/weird_route/pinwheel/place = new(null, null, src) + place.icon_state = "memory[number]" + place.add_filter("weird_route_wedge", 1, alpha_mask_filter(render_source = "*weird_route_wedge_[number]")) + memories += place + void = new(null, null, src) + void.color = COLOR_BLACK + void.layer = MOB_LAYER - 0.35 + silhouette = new(null, null, src) + silhouette.icon = null + silhouette.screen_loc = "CENTER,CENTER:28" + silhouette.layer = MOB_LAYER - 0.25 + silhouette.appearance_flags |= KEEP_TOGETHER + silhouette.add_filter("weird_route_silhouette", 1, color_matrix_filter(list(0, 0, 0, 0, 0, 0, 0, 0, 0, 0.35, 0.9, 1))) + show_box(FALSE) + show_choices(FALSE) + player.client.screen += get_screen() + +// Input. + +/datum/weird_route/proc/on_key(mob/source, key, client/user, full_key) + SIGNAL_HANDLER + var/direction = user?.movement_keys[key] + if(choices && (direction == WEST || direction == EAST)) + var/now = direction == WEST ? 1 : 2 + if(now != selected) + selected = now + play_sound("snd_menumove.wav") + update_choices() + return + if(uppertext(key) in list("Z", "SPACE", "RETURN", "ENTER")) + confirm() + +/// A confirm press: moves the text on, or takes the picked option. +/datum/weird_route/proc/confirm() + if(choices) + if(selected) + choose(selected) + return + confirm_pressed = TRUE + +/datum/weird_route/proc/choose(index) + if(!choices || chosen) + return + selected = index + chosen = index + play_sound("snd_select.wav") + +// Sound. + +/// Plays one of the route's sounds to its player, if it's there (see WEIRD_ROUTE_SOUNDS). +/datum/weird_route/proc/play_sound(name, volume = 100, frequency = 0, channel = 0, repeat = FALSE) + if(!player || !fexists(WEIRD_ROUTE_SOUNDS + name)) + return + var/sound/played = sound(file(WEIRD_ROUTE_SOUNDS + name), repeat = repeat, channel = channel, volume = volume) + played.frequency = frequency + SEND_SOUND(player, played) + +/// Changes how loud and how fast a looping sound plays. Volume's the game's (1 is as made, 3 the loudest). +/datum/weird_route/proc/tune(channel, volume, pitch) + // Only the looping sounds are tuned: an update without repeat would stop them looping. + var/sound/update = sound(null, repeat = TRUE, channel = channel, volume = clamp(volume * 40, 0, 100)) + update.frequency = max(pitch, 0.05) + update.status = SOUND_UPDATE + SEND_SOUND(player, update) + +/datum/weird_route/proc/stop_sounds() + for(var/channel in list(music_channel, chant_channel, static_channel)) + SEND_SOUND(player, sound(null, channel = channel)) + +// Where they stand, and the camera. + +/// Puts someone at x along the path, in the game's units (see WEIRD_ROUTE_SCALE). +/datum/weird_route/proc/put(mob/living/who, x, row = WEIRD_ROUTE_LAKE_ROW) + positions[who] = x + var/pixels = x * WEIRD_ROUTE_SCALE + var/turf/spot = locate(origin.x + WEIRD_ROUTE_LAKE_LEFT + floor(pixels / 32), origin.y + row - 1, origin.z) + if(spot && who.loc != spot) + who.forceMove(spot) + var/obj/effect/abstract/weird_route_silo/silo = silos[who] + if(spot && silo && silo.loc != spot) + silo.forceMove(spot) + who.pixel_w = who.base_pixel_w + pixels - floor(pixels / 32) * 32 - 16 + if(who == player) + update_camera() + +/// Walks someone to x over this many frames (without waiting, unless wait). +/datum/weird_route/proc/walk_along(mob/living/who, to_x, frames, wait = FALSE) + if(!wait) + INVOKE_ASYNC(src, PROC_REF(walk_along), who, to_x, frames, TRUE) + return + var/from_x = positions[who] + var/started = world.time + var/took = frames * WEIRD_ROUTE_FRAME + while(world.time < started + took) + put(who, from_x + (to_x - from_x) * (world.time - started) / took) + sleep(world.tick_lag) + if(QDELETED(src) || QDELETED(who)) + return + put(who, to_x) + +/// Pans the camera to centre on x over this many frames. +/datum/weird_route/proc/pan(to_x, frames) + set waitfor = FALSE + var/from_x = isnull(camera_x) ? positions[player] : camera_x + var/started = world.time + var/took = max(frames * WEIRD_ROUTE_FRAME, world.tick_lag) + while(world.time < started + took) + camera_x = from_x + (to_x - from_x) * (world.time - started) / took + update_camera() + sleep(world.tick_lag) + if(QDELETED(src)) + return + camera_x = to_x + update_camera() + +/datum/weird_route/proc/update_camera() + var/client/viewer = player?.client + if(!viewer || !origin) + return + var/centre = isnull(camera_x) ? positions[player] : camera_x + viewer.pixel_x = round(centre * WEIRD_ROUTE_SCALE - ((player.x - origin.x - WEIRD_ROUTE_LAKE_LEFT) * 32 + 16)) + +/// The screen shaking, up and down (the pans have the sideways). +/datum/weird_route/proc/shake_screen() + var/client/viewer = player?.client + if(!viewer) + return + animate(viewer, pixel_y = rand(-4, 4), time = 0.5) + animate(pixel_y = rand(-3, 3), time = 0.5) + animate(pixel_y = 0, time = 0.5) + +/// A long shadow behind someone, the sun low over the water (the game's sunset shadows: 62 of its +/// pixels, dark red-brown), or in the Meat Factory off the white. Length in the game's pixels. +/datum/weird_route/proc/give_shadow(mob/living/who, toward = WEST, colour = "#490b01", shadow_length = 62) + who.underlays.Cut() + if(shadow_length <= 0) + return + var/image/shadow = image('voidcrew/modules/weird_route/icons/weird_route.dmi', "shadow") + var/pixels = shadow_length * WEIRD_ROUTE_SCALE + var/matrix/stretch = matrix() + stretch.Scale(pixels / 32, 1) + // From their back foot, out the way the sun throws it. + stretch.Translate((toward == WEST ? -1 : 1) * (pixels / 2 - 4), 0) + shadow.transform = stretch + shadow.pixel_z = 1 + shadow.color = colour + shadow.appearance_flags = RESET_COLOR | RESET_TRANSFORM | KEEP_APART + who.underlays += shadow + +/// A pose for Moffer or the player (see /proc/weird_route_pose()), eased into over this many frames. +/datum/weird_route/proc/pose(mob/living/carbon/who, kind, frames = 8) + if(!who?.limb_rig) + return + who.limb_rig.set_seated(kind == "kneel" || kind == "kneel_head_down" || kind == "kneel_head_up" || kind == "fall" ? "crouch" : null) + who.limb_rig.play(list(list(weird_route_pose(kind), max(frames * WEIRD_ROUTE_FRAME, 0.5), SINE_EASING)), settle_after = FALSE) + +/// Shakes someone where they stand, a few times. +/datum/weird_route/proc/shake(mob/living/who) + var/home = who.pixel_w + animate(who, pixel_w = home + 2, time = 0.5) + for(var/i in 1 to 3) + animate(pixel_w = home - 2, time = 0.5) + animate(pixel_w = home + 2, time = 0.5) + animate(pixel_w = home, time = 0.5) + +/** + * The poses standing in for Noelle's and Kris's sprites in this scene ("spr_noelle_silo_..."): the + * names are theirs, without the prefix. + */ +/proc/weird_route_pose(kind) + . = list() + switch(kind) + if("head_down") + .[RIG_HEAD] = list("nod" = 28) + if("up_head_tilt") + .[RIG_HEAD] = list("nod" = -18, "tilt" = 12) + if("up") + .[RIG_HEAD] = list("nod" = -25) + if("exasperated") + .[RIG_CHEST] = list("bend" = -8) + .[RIG_HEAD] = list("nod" = -10) + .[RIG_L_ARM] = list("swing" = 50, "raise" = 45, "elbow" = 25) + .[RIG_R_ARM] = list("swing" = 50, "raise" = 45, "elbow" = 25) + if("hands_to_chest", "hands_to_chest_head_down", "hands_to_chest_head_down_more", "hands_to_chest_turn_head", "hands_to_chest_turn_up") + .[RIG_L_ARM] = list("swing" = 35, "raise" = 15, "elbow" = 120) + .[RIG_R_ARM] = list("swing" = 35, "raise" = 15, "elbow" = 120) + switch(kind) + if("hands_to_chest_head_down") + .[RIG_HEAD] = list("nod" = 22) + if("hands_to_chest_head_down_more") + .[RIG_HEAD] = list("nod" = 34) + .[RIG_CHEST] = list("bend" = 10) + if("hands_to_chest_turn_head") + .[RIG_HEAD] = list("nod" = 10, "tilt" = 10) + if("hands_to_chest_turn_up") + .[RIG_HEAD] = list("nod" = -20) + if("hands_to_side") + .[RIG_L_ARM] = list("swing" = -10, "raise" = 35) + .[RIG_R_ARM] = list("swing" = -10, "raise" = 35) + if("walk_left_hands_up") + .[RIG_L_ARM] = list("swing" = 120, "raise" = 15, "elbow" = 20) + .[RIG_R_ARM] = list("swing" = 120, "raise" = 15, "elbow" = 20) + if("fall") + .[RIG_CHEST] = list("bend" = 30) + .[RIG_HEAD] = list("nod" = 30) + .[RIG_L_ARM] = list("swing" = 20, "raise" = 10) + .[RIG_R_ARM] = list("swing" = 20, "raise" = 10) + if("kneel", "kneel_head_down") + .[RIG_HEAD] = list("nod" = 26) + .[RIG_L_ARM] = list("swing" = 15, "raise" = 10) + .[RIG_R_ARM] = list("swing" = 15, "raise" = 10) + if("kneel_head_up") + .[RIG_HEAD] = list("nod" = -12) + if("take_hands") + .[RIG_L_ARM] = list("swing" = 70, "raise" = 5, "elbow" = 10) + .[RIG_R_ARM] = list("swing" = 70, "raise" = 5, "elbow" = 10) + // Leading, a hand held out behind for the one following. + if("lead") + .[RIG_L_ARM] = list("swing" = -45, "raise" = 10) + .[RIG_R_ARM] = list("swing" = -45, "raise" = 10) + // Following, a hand held out ahead. + if("follow") + .[RIG_L_ARM] = list("swing" = 45, "raise" = 10) + .[RIG_R_ARM] = list("swing" = 45, "raise" = 10) + +// The writer. + +/// The player's first name, for Kris's. +/datum/weird_route/proc/get_kris() + return first_name(player?.real_name) || "Kris" + +/datum/weird_route/proc/show_box(shown, with_portrait = FALSE) + box.alpha = shown ? 255 : 0 + border.alpha = shown ? 255 : 0 + portrait.alpha = shown && with_portrait ? 255 : 0 + writing.maptext_x = with_portrait ? 112 : 18 + writing.maptext_width = with_portrait ? 322 : 410 + if(!shown) + writing.maptext = null + +/datum/weird_route/proc/show_choices(shown) + left_option.alpha = shown ? 255 : 0 + right_option.alpha = shown ? 255 : 0 + heart.alpha = shown ? 255 : 0 + if(shown) + // Each time, between the two. + heart_at = WEIRD_ROUTE_HEART_START + heart_target = WEIRD_ROUTE_HEART_START + place_heart() + update_choices() + +/datum/weird_route/proc/update_choices() + if(!choices) + return + left_option.maptext = weird_route_text(choices[1], selected == 1 ? highlight_colour : COLOR_WHITE, TRUE) + right_option.maptext = weird_route_text(choices[2], selected == 2 ? highlight_colour : COLOR_WHITE, TRUE) + // Beside the picked option's text, or between the two until one is; gliding over. + var/target = WEIRD_ROUTE_HEART_START + if(selected) + var/centre = selected == 1 ? WEIRD_ROUTE_LEFT_OPTION : WEIRD_ROUTE_RIGHT_OPTION + target = centre - length(choices[selected]) * 5 - 24 + if(target == heart_target && heart_at == target) + return + var/already_gliding = heart_at != heart_target + heart_target = target + if(!already_gliding) + INVOKE_ASYNC(src, PROC_REF(glide_heart)) + +/// Moves the heart most of the way to where it's going each frame, until it's there. +/datum/weird_route/proc/glide_heart() + while(heart_at != heart_target && !QDELETED(src)) + heart_at += (heart_target - heart_at) * 0.5 + if(abs(heart_target - heart_at) < 1) + heart_at = heart_target + place_heart() + sleep(WEIRD_ROUTE_FRAME) + +/datum/weird_route/proc/place_heart() + // Scaled about its icon's middle, which isn't the heart's. + var/offset_x = round(heart_at - 16 - (WEIRD_ROUTE_HEART_MIDDLE_X - 16) * WEIRD_ROUTE_HEART_SCALE) + var/offset_y = round(WEIRD_ROUTE_OPTION_Y - 16 - (WEIRD_ROUTE_HEART_MIDDLE_Y - 16) * WEIRD_ROUTE_HEART_SCALE) + heart.screen_loc = "CENTER-7:[16 + offset_x],SOUTH:[6 + offset_y]" + +/// The text's colour. White, always, as the game's text style 63 is: even once the box has gone white. +/datum/weird_route/proc/text_colour() + return COLOR_WHITE + +/// One colour going over to another, 0 to 1 of the way: the game's merge_color(). +/proc/weird_route_blend(from, target, amount) + var/list/a = rgb2num(from) + var/list/b = rgb2num(target) + amount = clamp(amount, 0, 1) + return rgb(a[1] + (b[1] - a[1]) * amount, a[2] + (b[2] - a[2]) * amount, a[3] + (b[3] - a[3]) * amount) + +/// Some text in the box's font. +/proc/weird_route_text(message, colour, centred = FALSE) + return "[message]" + +/** + * Types out messages in the box, one at a time, each waiting on a press: lines are the game's own, + * with "^1" a pause, "&" a new line, and a trailing "/" or "/%". A callback among them runs when the + * box gets to it (the game's c_wait_box()). With choices (two options), the last message waits on a + * choice instead, and chosen is set. Out of the box (in_box FALSE), the text's wherever it's been put. + * + * * voice - "snd_txtnoe.wav" for Moffer (his own snd_moffer.ogg if it's there), "snd_text.wav" for + * nobody in particular + * * rate - frames a letter: this scene's is 2 (its slow text, and text style 63), 4 slower still + */ +/datum/weird_route/proc/write(list/lines, voice = "snd_txtnoe.wav", with_portrait = FALSE, rate = 2, list/choice_options, in_box = TRUE) + var/token = ++writer_token + writer_busy = TRUE + // Moffer's own voice, if it's there (the Minecraft port's proceed_text), else Noelle's. + if(voice == "snd_txtnoe.wav" && fexists(WEIRD_ROUTE_SOUNDS + "snd_moffer.ogg")) + voice = "snd_moffer.ogg" + chosen = 0 + selected = 0 + if(in_box) + show_box(TRUE, with_portrait) + var/list/entries = lines.Copy() + var/messages = 0 + for(var/entry in entries) + if(!istype(entry, /datum/callback)) + messages++ + if(!messages && choice_options) + entries += "" + messages = 1 + var/typed = 0 + for(var/entry in entries) + if(istype(entry, /datum/callback)) + var/datum/callback/at_box = entry + at_box.Invoke() + continue + typed++ + type_message(entry, voice, rate, token) + if(token != writer_token) + return + if(choice_options && typed == messages) + // Once the choice is up, it's all there is in the box: the two options and the heart. + writing.maptext = null + portrait.alpha = 0 + choices = choice_options + show_choices(TRUE) + while(!chosen) + sleep(world.tick_lag) + if(token != writer_token) + return + choices = null + show_choices(FALSE) + else + confirm_pressed = FALSE + while(!confirm_pressed) + sleep(world.tick_lag) + if(token != writer_token) + return + if(in_box) + show_box(FALSE) + writer_busy = FALSE + +/// Types one message out, a letter at a time with the voice. +/datum/weird_route/proc/type_message(message, voice, rate, token) + message = replacetext(message, "/%", "") + message = replacetext(message, "/", "") + message = replacetext(message, "%", "") + message = replacetext(message, "Kris", get_kris()) + message = replacetext(message, "Noelle", "Moffer") + var/shown = "" + var/owed = 0 + var/last_voice = 0 + var/message_length = length(message) + var/index = 1 + while(index <= message_length) + var/letter = message[index] + index++ + if(letter == "^" && index <= message_length && text2num(message[index])) + owed += text2num(message[index]) * 10 * WEIRD_ROUTE_FRAME + index++ + continue + if(letter == "&") + shown += "
" + continue + shown += html_encode(letter) + owed += rate * WEIRD_ROUTE_FRAME + if(!(letter in list(" ", ".", ",", "!", "?", "*", "(", ")", "\"")) && world.time > last_voice) + last_voice = world.time + play_sound(voice, 60, voice_waver ? 0.8 + rand() * 0.4 : 0) + if(owed >= world.tick_lag) + writing.maptext = weird_route_text(shown, text_colour()) + sleep(owed) + owed = 0 + if(token != writer_token) + return + writing.maptext = weird_route_text(shown, text_colour()) + +/// Stops whatever's being typed or chosen, and puts the box away. +/datum/weird_route/proc/stop_writing() + writer_token++ + writer_busy = FALSE + choices = null + show_choices(FALSE) + show_box(FALSE) + +/// Writes and waits for it to finish: the game's c_talk_wait(). +/datum/weird_route/proc/say(list/lines, voice = "snd_txtnoe.wav", rate = 2, face = FALSE) + write(lines, voice, face, rate) diff --git a/voidcrew/modules/weird_route/scene.dm b/voidcrew/modules/weird_route/scene.dm new file mode 100644 index 00000000000..0f347e0f0bb --- /dev/null +++ b/voidcrew/modules/weird_route/scene.dm @@ -0,0 +1,956 @@ +// The scene itself: obj_ch5_LW20W's cutscene, then obj_ch5_LW20W_handoff's asking, then where it ends. +// Times are the game's frames (30 to a second), positions its units (64 to a tile). Where the game's +// own code wasn't to hand (its effects objects, and what it does once they're all the way in), what's +// here stands in for it and says so. + +/datum/weird_route/proc/play_scene() + // Moffer's at the water's edge, looking out over it. They come up the path behind him. + put(player, 40) + player.setDir(EAST) + put(moffer, 384) + moffer.setDir(EAST) + against_sun(player, TRUE) + against_sun(moffer, TRUE) + give_shadow(player) + give_shadow(moffer) + play_sound("ocean.ogg", 0, 0, music_channel, TRUE) + fade(music_channel, 0, 0.5, 30, 1) + var/walk_time = (288 - 40) / 4 + pan(156 + 320, walk_time) + walk_along(player, 288, walk_time, TRUE) + if(QDELETED(src)) + return + WEIRD_ROUTE_WAIT(60) + say(list( + "* (Kris.)/", + "* (I know.)/", + "* (I know you wanted to deny it.)/", + "* (I did too^1, at first.)/", + "* (But..^1. eventually...)/", + "* (We're going to have to face the truth.)/%", + ), "snd_text.wav") + // Remembering yesterday: the ominous fade. + clouds_in() + WEIRD_ROUTE_WAIT(200) + say(list( + "* (Yesterday^1, when..^1. everything happened...)/", + "* (Every cell in my body was screaming at me to run away.)/", + "* (..^1. that if I stayed by your side any longer,)/", + "* (You were going to drag me down with you.)/", + "* (Drag me down somewhere no one is supposed to go.)/", + "* (...)/", + "* (And you know what terrified me the most about that?)/%", + ), "snd_text.wav") + clouds_out() + // He looks back at them, his shadow shortening as he turns. + WEIRD_ROUTE_WAIT(24) + moffer.setDir(WEST) + give_shadow(moffer) + WEIRD_ROUTE_WAIT(31) + while(clouds_state != 2) + WEIRD_ROUTE_WAIT(1) + clouds_hide() + WEIRD_ROUTE_WAIT(30) + pose(moffer, "up_head_tilt") + say(list("* (I think...)/%"), "snd_text.wav") + WEIRD_ROUTE_WAIT(15) + pose(moffer, "up") + WEIRD_ROUTE_WAIT(15) + fade(music_channel, 0.5, 0, 30, 1) + say(list("* I want that./%"), rate = 4) + WEIRD_ROUTE_WAIT(90) + animate(blackout, alpha = 255, time = 120 * WEIRD_ROUTE_FRAME) + WEIRD_ROUTE_WAIT(180) + blackout.alpha = 0 + pinwheel_start() + play_sound("ch5_weird_monologue_deep.ogg", 0, 0.95, music_channel, TRUE) + fade(music_channel, 0, 0.7, 30, 0.95) + say(list( + "* Since Dess left.../", + CALLBACK(src, PROC_REF(pinwheel_segment)), + "* All I've been doing has just been.../%", + )) + pinwheel_segment() + WEIRD_ROUTE_WAIT(30) + say(list("* Marching down my path./%")) + pinwheel_segment() + WEIRD_ROUTE_WAIT(30) + say(list("* Staying quiet^1. Getting good grades./%")) + pinwheel_turn(TRUE) + pinwheel_ripple() + WEIRD_ROUTE_WAIT(30) + say(list("* It feels like..^1. I've just been watching myself through glass./%")) + WEIRD_ROUTE_WAIT(30) + pinwheel_turn() + pinwheel_ripple() + say(list("* Watching someone else move me through my life./%")) + WEIRD_ROUTE_WAIT(30) + pinwheel_turn() + pinwheel_ripple() + say(list("* That's why I want..^1. to do something crazy./%")) + WEIRD_ROUTE_WAIT(30) + pinwheel_turn() + pinwheel_ripple() + say(list("* That's why I want..^1. to break free./%")) + WEIRD_ROUTE_WAIT(30) + play_sound("snd_noise.wav") + pinwheel_fade() + WEIRD_ROUTE_WAIT(30) + pinwheel_kneel() + say(list( + "* But^1, I can't^1. I couldn't./", + "* Because I'm just Noelle./", + "* A Noelle that has to do Noelle things./%", + )) + pinwheel_silhouette_out() + pose(player, "head_down") + pose(moffer, "head_down") + put(moffer, 320) + WEIRD_ROUTE_WAIT(90) + blackout.alpha = 255 + pinwheel_clean_up() + say(list("* Just like Kris has to do Kris things.../%")) + WEIRD_ROUTE_WAIT(60) + say(list("* But Kris..^1. you changed./%")) + animate(blackout, alpha = 0, time = 60 * WEIRD_ROUTE_FRAME) + WEIRD_ROUTE_WAIT(90) + pose(moffer, "exasperated") + say(list( + CALLBACK(src, PROC_REF(set_waver), TRUE), + "* Kris^1, YOU actually changed!/", + CALLBACK(src, PROC_REF(shake), moffer), + CALLBACK(src, PROC_REF(set_waver), FALSE), + "* You found out how to stop being Kris!/", + CALLBACK(src, PROC_REF(pose), moffer, "head_down", 8), + "* And no one else has noticed.../%", + ), face = TRUE) + WEIRD_ROUTE_WAIT(30) + say(list( + CALLBACK(src, PROC_REF(set_waver), TRUE), + "* Because they don't want to!/", + CALLBACK(src, PROC_REF(pose), moffer, "exasperated", 8), + CALLBACK(src, PROC_REF(shake), moffer), + "* They don't want to see how INTERESTING you are now!/", + CALLBACK(src, PROC_REF(shake_head), moffer), + CALLBACK(src, PROC_REF(set_waver), FALSE), + "* They'll just keep marching down the same path.../", + CALLBACK(src, PROC_REF(pose), moffer, "exasperated", 8), + CALLBACK(src, PROC_REF(shake), moffer), + CALLBACK(src, PROC_REF(set_waver), TRUE), + "* Without even thinking where it goes!/%", + ), face = TRUE) + WEIRD_ROUTE_WAIT(30) + pose(moffer, "head_down") + say(list( + CALLBACK(src, PROC_REF(set_waver), FALSE), + "* Kris.../", + CALLBACK(src, PROC_REF(pose), moffer, "hands_to_chest", 30), + "* Being around you.../%", + ), face = TRUE) + WEIRD_ROUTE_WAIT(60) + pose(moffer, "hands_to_chest_head_down") + say(list("* Is changing me^1, too./%"), face = TRUE) + WEIRD_ROUTE_WAIT(15) + say(list( + "* K..^1. Kris.../", + "* Kris^1, when I cast \"Snowgrave...\"/", + CALLBACK(src, PROC_REF(pose), moffer, "hands_to_chest_turn_head", 8), + "* I.../", + CALLBACK(src, PROC_REF(pose), moffer, "hands_to_chest_head_down_more", 8), + "* I felt stronger than I have in my entire life./%", + ), face = TRUE) + WEIRD_ROUTE_WAIT(30) + // They look up; he drops to the ground. + pose(player, "rest", 15) + pose(moffer, "fall", 30) + WEIRD_ROUTE_WAIT(20) + shake(moffer) + play_sound("snd_wing.wav") + WEIRD_ROUTE_WAIT(30) + shake_head(moffer) + WEIRD_ROUTE_WAIT(56) + say(list( + CALLBACK(src, PROC_REF(set_waver), TRUE), + "* I know..^1. it's horrible^1. It's horrible to say that^1, but.../%", + )) + pose(moffer, "kneel", 30) + WEIRD_ROUTE_WAIT(60) + pose(moffer, "kneel_head_down") + say(list( + CALLBACK(src, PROC_REF(set_waver), FALSE), + "* Kris./", + CALLBACK(src, PROC_REF(pose), moffer, "kneel_head_down", 8), + "* You..^1. if it's you telling me to^1, I can do anything./", + "* If you tell me to^1, I can do things that are impossible./", + CALLBACK(src, PROC_REF(pose), moffer, "kneel", 8), + "* Things no one else can do./%", + ), face = TRUE) + WEIRD_ROUTE_WAIT(30) + pose(moffer, "kneel_head_up") + say(list("* So^1, why don't we do it?/%"), face = TRUE) + pose(moffer, "kneel_head_down") + WEIRD_ROUTE_WAIT(30) + say(list("* Why don't we do.../%"), face = TRUE) + // He gets up and comes to them. + pose(player, "head_down", 16) + pose(moffer, "rest") + walk_along(moffer, 309, 16, TRUE) + if(QDELETED(src)) + return + say(list( + "* \"Something crazy?\"/", + CALLBACK(src, PROC_REF(pose), moffer, "head_down", 8), + "* Tell me./%", + ), face = TRUE) + pose(moffer, "hands_to_chest", 30) + WEIRD_ROUTE_WAIT(45) + say(list( + "* Tell me to grow wings.../", + CALLBACK(src, PROC_REF(pose), moffer, "hands_to_side", 30), + "* And let's fly out of this stupid town!/", + CALLBACK(src, PROC_REF(pose), moffer, "exasperated", 8), + CALLBACK(src, PROC_REF(shake), moffer), + CALLBACK(src, PROC_REF(set_waver), TRUE), + "* To somewhere no one has ever been before!/%", + ), face = TRUE) + pose(moffer, "walk_left_hands_up") + walk_along(moffer, 320, 30) + WEIRD_ROUTE_WAIT(45) + pose(moffer, "hands_to_chest_turn_up", 20) + WEIRD_ROUTE_WAIT(26) + // He turns back to the water. + WEIRD_ROUTE_WAIT(12) + moffer.setDir(EAST) + WEIRD_ROUTE_WAIT(20) + pose(moffer, "rest") + set_waver(FALSE) + say(list( + "* .../", + "* The other side of the lake./", + "* We always wanted to see what was there^1, didn't we?/", + "* The four of us^1, when we were kids.../%", + )) + WEIRD_ROUTE_WAIT(12) + moffer.setDir(WEST) + WEIRD_ROUTE_WAIT(14) + pose(moffer, "hands_to_chest_turn_up", 20) + WEIRD_ROUTE_WAIT(35) + pose(moffer, "hands_to_chest_head_down") + say(list( + "* Kris^1, let's go there./", + CALLBACK(src, PROC_REF(pose), moffer, "walk_left_hands_up", 8), + "* You'll tell me^1, won't you?/", + CALLBACK(src, PROC_REF(pose), moffer, "hands_to_chest_head_down", 8), + "* If you tell me to^1, I can do anything./%", + ), face = TRUE) + pose(moffer, "walk_left_hands_up") + walk_along(moffer, 305, 30, TRUE) + if(QDELETED(src)) + return + WEIRD_ROUTE_WAIT(30) + // He takes their hands. + pose(moffer, "take_hands", 30) + pose(player, "take_hands", 30) + WEIRD_ROUTE_WAIT(30) + say(list("* So..^1. tell me^1, Kris./%"), face = TRUE) + fade(music_channel, 0.7, 0, 60, 0.95) + WEIRD_ROUTE_WAIT(30) + // And leads them down to the water, hand in hand. + moffer.setDir(EAST) + player.setDir(EAST) + pose(moffer, "lead") + pose(player, "follow") + pan(220 + 320, 120) + walk_along(moffer, WEIRD_ROUTE_HANDOFF_X, 120) + walk_along(player, WEIRD_ROUTE_HANDOFF_X - 17, 120) + WEIRD_ROUTE_WAIT(150) + say(list("* The words..^1. I've been waiting to hear./%"), face = TRUE) + WEIRD_ROUTE_WAIT(60) + ask() + +/datum/weird_route/proc/set_waver(on) + voice_waver = on + text_shake = on ? 1 : 0 + if(on) + INVOKE_ASYNC(src, PROC_REF(shake_text)) + +/// Jitters the text a pixel either way while his voice wavers (the asking shakes it itself). +/datum/weird_route/proc/shake_text() + while(voice_waver && !QDELETED(src)) + writing.pixel_x = rand(-1, 1) + writing.pixel_y = rand(-1, 1) + sleep(2 * WEIRD_ROUTE_FRAME) + if(!QDELETED(src)) + writing.pixel_x = 0 + writing.pixel_y = 0 + +/// Shakes his head, twice. +/datum/weird_route/proc/shake_head(mob/living/carbon/who) + set waitfor = FALSE + for(var/i in 1 to 2) + who.limb_rig?.play(list( + list(list(RIG_HEAD = list("nod" = 20, "tilt" = 14)), 2), + list(list(RIG_HEAD = list("nod" = 20, "tilt" = -14)), 2), + ), settle_after = FALSE) + sleep(16 * WEIRD_ROUTE_FRAME) + if(QDELETED(src)) + return + +/// Fades a looping sound from one volume to another over this many frames (the game's mus_volume()). +/datum/weird_route/proc/fade(channel, from, target, frames, pitch) + set waitfor = FALSE + var/started = world.time + var/took = frames * WEIRD_ROUTE_FRAME + while(world.time < started + took) + tune(channel, from + (target - from) * (world.time - started) / took, pitch) + sleep(world.tick_lag) + if(QDELETED(src)) + return + tune(channel, target, pitch) + if(!target) + SEND_SOUND(player, sound(null, channel = channel)) + +// The ominous fade (the game's obj_ch5_LW20W_vfx): three of its sprite, black all over but for a soft +// patch near the top, behind the two of them, each fading in slower than the last and breathing. + +/datum/weird_route/proc/clouds_in() + clouds_state = 1 + shape_clouds(0) + var/list/fade_times = list(300, 360, 420) + for(var/number in 1 to 3) + var/atom/movable/screen/weird_route/pinwheel/cloud = clouds[number] + cloud.alpha = 0 + animate(cloud, alpha = 255, time = fade_times[number] * WEIRD_ROUTE_FRAME) + INVOKE_ASYNC(src, PROC_REF(clouds_breathe)) + +/datum/weird_route/proc/clouds_breathe() + var/started = world.time + while(clouds_state == 1 && !QDELETED(src)) + shape_clouds((world.time - started) / WEIRD_ROUTE_FRAME) + sleep(world.tick_lag) + +/** + * Sizes the clouds to the player's screen as the game's are to its own 320x240 (the sprite drawn at + * 0.85 to 0.9, about the middle of its soft patch, which sits a third of the way down the screen), + * breathing a frame's worth in. Each axis the game leaves alone stays at 1. + */ +/datum/weird_route/proc/shape_clouds(breath) + var/list/screen_size = view_pixels() + // Our icon is 96px for the sprite's 380. + var/across = screen_size[1] / 320 * 380 / 96 + var/down = screen_size[2] / 240 * 380 / 96 + var/list/breaths = list( + list(1, abs(sin(breath / 60 * 180 / PI) * 0.05) + 0.85), + list(abs(sin(breath / 90 * 180 / PI) * 0.05) + 0.85, 1), + list(abs(sin(breath / 90 * 180 / PI) * 0.05) + 0.85, abs(cos(breath / 90 * 180 / PI) * 0.05) + 0.85), + ) + for(var/number in 1 to 3) + var/list/scale = breaths[number] + var/x_scale = across * scale[1] + var/y_scale = down * scale[2] + var/matrix/shape = matrix() + shape.Scale(x_scale, y_scale) + // Its middle to the screen's (it's placed by its bottom left corner on the middle tile), then up + // so the soft patch (20 of its 96px above its middle) is a third of the way down. + shape.Translate(-32, -32 + screen_size[2] / 6 - (190 - 110) * 96 / 380 * y_scale) + var/atom/movable/screen/weird_route/pinwheel/cloud = clouds[number] + cloud.transform = shape + +/// The player's view, in pixels: list(width, height). +/datum/weird_route/proc/view_pixels() + var/list/size = getviewsize(player?.client?.view || world.view) + return list(size[1] * 32, size[2] * 32) + +/datum/weird_route/proc/clouds_out() + set waitfor = FALSE + var/list/fade_times = list(120, 180, 240) + for(var/number in 1 to 3) + animate(clouds[number], alpha = 0, time = fade_times[number] * WEIRD_ROUTE_FRAME) + sleep(240 * WEIRD_ROUTE_FRAME) + if(!QDELETED(src)) + clouds_state = 2 + +/datum/weird_route/proc/clouds_hide() + clouds_state = 0 + for(var/atom/movable/screen/weird_route/pinwheel/cloud as anything in clouds) + cloud.alpha = 0 + +// The pinwheel (the game's obj_ch5_LW20W_rotate). The game shows Dess's room, Noelle's house and the +// school, caught on the way here; these are a bridge, an engine room and a medbay, off the server. + +/datum/weird_route/proc/pinwheel_start() + pinwheel_on = TRUE + void.alpha = 255 + vision = make_moffer() + vision.moveToNullspace() + // Facing out at whoever's watching. + vision.setDir(SOUTH) + vision.limb_rig?.set_seated("crouch") + // The memories filling the screen, whatever its size. + var/list/screen_size = view_pixels() + for(var/atom/movable/screen/weird_route/pinwheel/place as anything in memories) + place.transform = matrix(screen_size[1] / 480, 0, 0, 0, screen_size[2] / 480, 0) + silhouette.vis_contents += vision + silhouette.alpha = 0 + animate(silhouette, alpha = 255, time = 60 * WEIRD_ROUTE_FRAME) + INVOKE_ASYNC(src, PROC_REF(pinwheel_process)) + +/// Turns it, fades its memories in and ripples them, a frame at a time, as the game's step does. +/datum/weird_route/proc/pinwheel_process() + var/last = world.time + var/list/screen_size = view_pixels() + var/wedge_scale = max(screen_size[1], screen_size[2]) / 480 + while(pinwheel_on && !QDELETED(src)) + var/frames = (world.time - last) / WEIRD_ROUTE_FRAME + last = world.time + if(pinwheel_turning) + if(!pinwheel_slowing || pinwheel_angle > 270 || pinwheel_angle < 180) + pinwheel_angle += pinwheel_turn_speed * frames + else + pinwheel_angle += (270 - pinwheel_angle) * (1 - 0.95 ** frames) + if(pinwheel_angle > 360) + pinwheel_angle -= 360 + for(var/number in 1 to 3) + var/atom/movable/screen/weird_route/pinwheel/wedge = wedges[number] + var/matrix/turned = turn(matrix(), pinwheel_angle - 270 + (number - 1) * 120) + turned.Scale(wedge_scale, wedge_scale) + wedge.transform = turned + var/atom/movable/screen/weird_route/pinwheel/place = memories[number] + if(pinwheel_segments >= number) + place.alpha = min(place.alpha + 0.02 * 255 * frames, 255) + pinwheel_wave = min(pinwheel_wave + pinwheel_wave_speed * frames, 1) + if(pinwheel_wave > 0) + for(var/atom/movable/screen/weird_route/pinwheel/place as anything in memories) + if(!place.get_filter("weird_route_wave")) + place.add_filter("weird_route_wave", 2, wave_filter(x = 16, size = 0)) + animate(place.get_filter("weird_route_wave"), offset = 1, time = 20, loop = -1) + animate(offset = 0, time = 0) + place.modify_filter("weird_route_wave", list("size" = pinwheel_wave * 6)) + sleep(world.tick_lag) + +/datum/weird_route/proc/pinwheel_segment() + pinwheel_segments++ + +/// Sets it turning, or turning faster: a fifth of a degree a frame more each time, eased in over a second. +/datum/weird_route/proc/pinwheel_turn(start = FALSE) + set waitfor = FALSE + pinwheel_turning = TRUE + var/from = pinwheel_turn_speed + for(var/frame in 1 to 30) + pinwheel_turn_speed = from + 0.2 * frame / 30 + sleep(WEIRD_ROUTE_FRAME) + if(QDELETED(src)) + return + +/datum/weird_route/proc/pinwheel_ripple() + set waitfor = FALSE + var/from = pinwheel_wave_speed + for(var/frame in 1 to 30) + pinwheel_wave_speed = from + 0.01 * frame / 30 + sleep(WEIRD_ROUTE_FRAME) + if(QDELETED(src)) + return + +/// The memories gone, all at once. +/datum/weird_route/proc/pinwheel_fade() + pinwheel_segments = 0 + for(var/atom/movable/screen/weird_route/pinwheel/place as anything in memories) + place.alpha = 0 + +/datum/weird_route/proc/pinwheel_kneel() + if(vision?.limb_rig) + vision.limb_rig.play(list(list(weird_route_pose("kneel"), 0.5)), settle_after = FALSE) + +/datum/weird_route/proc/pinwheel_silhouette_out() + animate(silhouette, alpha = 0, time = 60 * WEIRD_ROUTE_FRAME) + +/datum/weird_route/proc/pinwheel_clean_up() + pinwheel_on = FALSE + pinwheel_fade() + void.alpha = 0 + silhouette.alpha = 0 + silhouette.vis_contents.Cut() + QDEL_NULL(vision) + +// The asking: obj_ch5_LW20W_handoff. + +/// What Moffer says with each "Proceed", or null where he says nothing. +GLOBAL_LIST_INIT(weird_route_proceed_lines, list( + null, + null, + "* The water's..^1. nice^1, isn't it^1, Kris?/", + null, + "* Kris^1, don't hesitate./", + null, + "* Haha..^1. it's..^1. colder than I thought./", + null, + list("* Kris^1, don't let go of my hands./", "* Just keep walking./"), + "* Kris^1, just..^1. keep walking./", + "* Kris.../", + "* Kris....../", + "* .../", + "* .../", + null, + null, + "* (Kris^1, you can still hear me^1, can't you!?)/", + null, + "* (Kris..^1. keep..^1. saying it...!)/", + null, + "* (K..^1. Kris...!)/", + null, + null, + "* (KRIS!!!)/", + null, + null, + null, + null, + "* (SAY IT!!!!!!)/", + null, + null, + null, + null, + null, + "* (KRIS!!!!!!!!!)/", + null, +)) + +/// What he says when they say "Stop": the first time, the second, and so on. +GLOBAL_LIST_INIT(weird_route_stop_lines, list( + list("* No.../", "* You're supposed to say \"Proceed,^1\" right?/", "* Just like all the other times./%"), + list("* Stop pretending./", "* I know you remember^1, too!/%"), + list("* Why..^1. are your hands trembling...?/", "* Kris^1, you're the one that wanted this^1, aren't you?/%"), + list("* Stop pretending you're still the old Kris./", "* You can't even say \"stop\" like you used to./%"), + list("* Proceed./%"), +)) + +/// How far along textind is from a to b, 0 to 1: the game's wprog(). +/datum/weird_route/proc/progress(textind, from, target) + if(target == from) + return textind >= from ? 1 : 0 + return clamp((textind - from) / (target - from), 0, 1) + +/// Puts the two of them, hand in hand, at x: Moffer leading, the player a step behind. +/datum/weird_route/proc/put_pair(x) + put(moffer, x) + put(player, x - 17) + camera_x = x + 170 + update_camera() + // Into the water to their knees, then their waists, then their chins, as the game's submerge + // object steps through its 47 frames of them going under between 240 and 928; then the tops of + // their heads, gone a couple of steps on. + var/going = weird_route_submerged(x) + var/depth = going * 22 + clamp((x - 928) / (1024 - 928), 0, 1) * 12 + // Their shadow (one between them, from him) shrinking as there's less of them above the water. + var/shadow_left = going >= 1 ? 22 + round(clamp((x - 928) / (1066 - 928), 0, 1) * 7) : round(going * 47 / 2) + give_shadow(moffer, shadow_length = floor(58 * (1 - clamp(shadow_left / 33, 0, 1)))) + player.underlays.Cut() + for(var/mob/living/who as anything in list(moffer, player)) + var/list/sink = who.get_filter("weird_route_sink") + if(!sink) + who.add_filter("weird_route_sink", 1, alpha_mask_filter(y = 32 + depth, icon = icon('voidcrew/modules/weird_route/icons/weird_route_mask.dmi', "mask"))) + else + who.modify_filter("weird_route_sink", list("y" = 32 + depth)) + +/// How far under they are at x, 0 to 1: the game's mix of an ease-in-back and an ease-in-cubic. +/proc/weird_route_submerged(x) + var/progress = max((x - 240) / (928 - 240), 0) + var/overshoot = 1.70158 + var/back = progress * progress * ((overshoot + 1) * progress - overshoot) + var/cubic = progress ** 3 + return clamp(back + (cubic - back) * 2 / 3, 0, 1) + +/// Plays one of the asking's layered sounds at volume (the game's 0 to 1), if it's loud enough to hear. +/datum/weird_route/proc/play_layer(name, volume, pitch = 1) + if(volume > 0) + play_sound(name, volume * 60, pitch == 1 ? 0 : pitch) + +/datum/weird_route/proc/ask() + var/textind = 0 + var/stops = 0 + var/state = "ask" + var/timer = 0 + var/actor_x = WEIRD_ROUTE_HANDOFF_X + var/start_x = actor_x + var/end_x = actor_x + var/move_time = 1 + var/pause_time = 0 + var/move_started = FALSE + var/noelle_water_sound = FALSE + var/kris_water_sound = FALSE + var/music_started = FALSE + var/music_pitch = 0.6 + var/music_volume = 0 + var/submerged = FALSE + var/fail_timer = 0 + var/fail_limit = 240 + var/whiteout_alpha = 0 + var/whiteness_alpha = 0 + var/blur = 0 + var/blur_flicker = 0 + var/hurt_timer = 0 + var/hurt_timer_2 = 0 + var/static_started = FALSE + var/static_pitch = 0.7 + var/static_volume = 0 + var/last = world.time + put_pair(actor_x) + while(!QDELETED(src)) + var/frames = (world.time - last) / WEIRD_ROUTE_FRAME + last = world.time + switch(state) + if("ask") + var/line = GLOB.weird_route_proceed_lines[min(textind + 1, length(GLOB.weird_route_proceed_lines))] + textind++ + var/list/options = list(textind >= 7 ? "Proceed" : "Stop", "Proceed") + INVOKE_ASYNC(src, PROC_REF(write), line ? (islist(line) ? line : list(line)) : list(), "snd_txtnoe.wav", !!line, 2, options) + state = "choosing" + if("choosing") + if(chosen) + // Under it all, three dooms one over another, the deeper ones taking over. + var/volume_1 = textind > 34 ? 0.7 - progress(textind, 34, 37) * 0.7 : 1 - progress(textind, 19, 34) * 0.3 + var/volume_2 = textind > 34 ? (1 - progress(textind, 34, 58)) * 0.8 : progress(textind, 19, 34) + play_layer("snd_ominous.wav", volume_1) + play_layer("snd_ominous_hell.wav", volume_2) + play_layer("snd_ominous_hell_super.wav", progress(textind, 34, 54), 1 + 0.6 * progress(textind, 54, 74)) + state = chosen == 1 && textind < 7 ? "stopped" : "walk_wait" + timer = 0 + if(textind >= WEIRD_ROUTE_END_INDEX) + state = "end" + if("stopped") + timer += frames + if(timer >= 2) + INVOKE_ASYNC(src, PROC_REF(write), GLOB.weird_route_stop_lines[min(stops + 1, length(GLOB.weird_route_stop_lines))], "snd_txtnoe.wav", TRUE, 2) + stops++ + state = "walk_wait" + timer = 0 + if("walk_wait") + if(!writer_busy) + timer += frames + if(timer >= 2) + timer = 0 + state = "walk" + move_started = FALSE + if("walk") + if(!move_started) + move_started = TRUE + var/amount = max(2, round(LERP(51, 20, progress(textind, 0, 54)))) + if(textind > 28) + amount = round(LERP(15, 4, progress(textind, 28, 54))) + if(textind > WEIRD_ROUTE_DROWN_INDEX) + amount = round(amount / 2) + var/speed = LERP(1.5, 2, clamp(textind / 20, 0, 1)) + start_x = actor_x + end_x = actor_x + amount + move_time = max(1, round(amount * speed)) + var/before = actor_x + timer += frames + actor_x = round(LERP(start_x, end_x, min(1, timer / move_time))) + put_pair(actor_x) + // Wading: with each other step while it's shallow, and every step from when it isn't. + var/splash = textind < 13 && round(before / 16) != round(actor_x / 16) + if(actor_x < 420) + splash = FALSE + if(!noelle_water_sound) + splash = FALSE + if(actor_x >= 400) + noelle_water_sound = TRUE + splash = TRUE + if(!kris_water_sound && before < 419 && actor_x >= 419) + splash = TRUE + kris_water_sound = TRUE + if(splash) + var/volume_1 = textind > 34 ? 0.7 - progress(textind, 34, 37) * 0.7 : 1 - progress(textind, 19, 34) * 0.3 + var/volume_2 = textind > 34 ? (1 - progress(textind, 34, 54)) * 0.8 : progress(textind, 19, 34) + play_layer("snd_wading.wav", volume_1) + play_layer("snd_wading_type2.wav", volume_2) + play_layer("snd_wading_type3_deep.wav", progress(textind, 34, 54)) + if(textind >= 13 && timer <= frames) + play_layer("snd_wading_type2.wav", 1 - progress(textind, 13, 54)) + play_layer("snd_wading_type3_deep.wav", progress(textind, 34, 54)) + if(timer >= move_time) + actor_x = end_x + put_pair(actor_x) + timer = 0 + pause_time = LERP(30, -1, progress(textind, 0, 12)) + state = "pause" + if("pause") + timer += frames + if(timer >= pause_time) + timer = 0 + state = "ask" + if(!music_started) + music_started = TRUE + music_pitch = 0.35 + play_sound("ch5_inversion_lake_chant.ogg", 0, music_pitch, chant_channel, TRUE) + if("end") + timer += frames + if(timer >= 300) + stop_sounds() + INVOKE_ASYNC(src, PROC_REF(insert_chapter)) + return + if(QDELETED(src)) + return + + // The box going red, then back to white; the white over everything takes the rest. + var/border_progress = progress(textind, 22, 23) * 0.5 + if(textind > 23) + border_progress = progress(textind, 23, 28) * 0.5 + 0.5 + if(textind > 28) + border_progress = 1 - progress(textind, 28, 44) + border_colour = weird_route_blend(COLOR_WHITE, COLOR_RED, border_progress) + inner_white = progress(textind, 28, 44) + highlight_colour = weird_route_blend(COLOR_YELLOW, COLOR_WHITE, inner_white) + colour_box() + // The lake going white behind them, its light with it (or the sunset's would tint the white). + whiteness_alpha += (progress(textind, 19, 28) - whiteness_alpha) * (1 - (1 - 1 / 120) ** frames) + whiteness.alpha = whiteness_alpha * 255 + sun_whitens(whiteness_alpha) + + // Once he's under, they can't hesitate long. + var/fail_progress = 0 + if(textind >= WEIRD_ROUTE_DROWN_INDEX) + fail_limit = 450 + if(textind > 22) + fail_limit = LERP(150, 30, progress(textind, 23, 40)) + if(textind in list(35, 29, 24, 17, 15)) + fail_limit += 45 + if(writer_busy && state != "end") + fail_timer += frames + if(fail_timer >= fail_limit) + fail() + return + else + fail_timer = 0 + fail_progress = fail_timer / (fail_limit * 0.4) + whiteout_alpha += (fail_progress - whiteout_alpha) * (1 - (1 - 1 / 30) ** frames) + whiteout.alpha = clamp(whiteout_alpha, 0, 1) * 255 + + // Blurring as they go deeper (the game's blur2): a sideways smear and a soft blur, flickering + // every other frame somewhere between its base and up to twice that. + blur += ((actor_x - 940) / (1453 - 940) - blur) * (1 - (1 - 1 / 60) ** frames) + blur_flicker += frames + var/atom/movable/plane_master_controller/game = player.hud_used?.plane_master_controllers[PLANE_MASTERS_GAME] + if(game && blur > 0 && blur_flicker >= 2) + blur_flicker = 0 + var/base = 3 * (1 - (1 - min(blur, 1)) ** 3) + fail_progress + var/amount = base + rand() * (base * LERP(1, 2, min(blur, 1)) - base) + var/smear = amount / 5 * 2 + var/soft = 2 ** (10 * min(amount / 5, 1) - 10) * 2 + if(!game.get_filter("weird_route_blur")) + game.add_filter("weird_route_blur", 1, motion_blur_filter(smear, 0)) + game.add_filter("weird_route_soften", 2, gauss_blur_filter(soft)) + else + game.modify_filter("weird_route_blur", list("x" = smear)) + game.modify_filter("weird_route_soften", list("size" = soft)) + blur_box(smear, soft) + + // The chant: rising out of nothing, then deep and loud under the water, then sinking and climbing. + if(music_started) + var/base_pitch = 0.4 + var/target_pitch = progress(textind, 0, WEIRD_ROUTE_DROWN_INDEX) * base_pitch + if(submerged && textind < 17) + music_pitch = base_pitch + target_pitch = music_pitch + if(textind >= 17 && textind < 30) + music_pitch = base_pitch * 0.98 ** (textind - 16) + target_pitch = music_pitch + if(textind >= 30) + music_pitch = base_pitch * 0.98 ** (30 - 1 - 16) * 1.05 ** (textind - 29) + music_pitch += (target_pitch - music_pitch) * (1 - (1 - 0.025) ** frames) + var/target_volume = LERP(0, 0.6, progress(textind, 0, WEIRD_ROUTE_DROWN_INDEX)) + if(submerged) + target_volume = 3 + if(textind >= WEIRD_ROUTE_END_INDEX - 20) + target_volume = LERP(3, 0, progress(textind, WEIRD_ROUTE_END_INDEX - 19, WEIRD_ROUTE_END_INDEX)) + music_volume += (target_volume - music_volume) * (1 - (1 - 1 / 30) ** frames) + if(!submerged && weird_route_submerged(actor_x) >= 1) + music_volume = 3 + music_pitch = 0.4 + submerged = TRUE + tune(chant_channel, music_volume, music_pitch) + + // Their breath running out: the screen jolting, faster and faster. + if(textind >= WEIRD_ROUTE_DROWN_INDEX && state != "end") + var/drowning = progress(textind, WEIRD_ROUTE_DROWN_INDEX - 1, WEIRD_ROUTE_END_INDEX - 20) + var/hurt_every = round(LERP(60, 2, drowning)) + var/dampen = 1 - progress(textind, 20, WEIRD_ROUTE_END_INDEX) * 0.9 + hurt_timer += frames + if(textind >= 28) + hurt_timer_2 += frames + if(hurt_timer_2 >= hurt_every) + hurt_timer_2 = 0 + play_layer("snd_hurt_muffled_2x.wav", LERP(0.3, 0.8, progress(textind, 28, 44)) * dampen, LERP(0.9, 0.3, drowning) + rand() * (LERP(1, 1.2, drowning) - LERP(0.9, 0.3, drowning))) + if(hurt_timer >= hurt_every) + hurt_timer = 0 + shake_screen() + play_layer("snd_hurt_muffled_2x.wav", LERP(0.5, 1, progress(textind, WEIRD_ROUTE_DROWN_INDEX - 1, 44)) * dampen) + // And static, louder the longer they wait. + if(!static_started) + static_started = TRUE + play_sound("snd_static_loop.wav", 0, 0.7, static_channel, TRUE) + var/waiting = fail_timer / (fail_limit * 0.4) + static_pitch += (LERP(0.2, 0.7, 1 - (1 - min(waiting, 1)) ** 3) - static_pitch) * (1 - (1 - 0.125) ** frames) + static_volume += (LERP(0, 0.7, 1 - (1 - min(waiting, 1)) ** 5) - static_volume) * (1 - (1 - 0.1) ** frames) + tune(static_channel, static_volume, static_pitch) + + // The text shaking harder. + text_shake = textind >= WEIRD_ROUTE_DROWN_INDEX ? LERP(0.26, 3, progress(textind, WEIRD_ROUTE_DROWN_INDEX, 34)) : 0 + if(text_shake >= 1) + var/most = round(text_shake) + writing.pixel_x = rand(-most, most) + writing.pixel_y = rand(-most, most) + else + writing.pixel_x = 0 + writing.pixel_y = 0 + sleep(world.tick_lag) + +/// Blurs the text box with the rest of the screen (its planes aren't the world's), or stops (0, 0). +/datum/weird_route/proc/blur_box(smear, soft) + for(var/atom/movable/screen/part as anything in list(box, border, portrait, writing, left_option, right_option, heart)) + if(!smear && !soft) + part.remove_filter(list("weird_route_blur", "weird_route_soften")) + else if(!part.get_filter("weird_route_blur")) + part.add_filter("weird_route_blur", 1, motion_blur_filter(smear, 0)) + part.add_filter("weird_route_soften", 2, gauss_blur_filter(soft)) + else + part.modify_filter("weird_route_blur", list("x" = smear)) + part.modify_filter("weird_route_soften", list("size" = soft)) + +/// Colours the box as it is now. +/datum/weird_route/proc/colour_box() + border.color = border_colour + // Its inside stays black: it's the white over the screen and the blur that take it. + box.color = COLOR_BLACK + if(choices) + update_choices() + +/// They hesitated too long: white, going to black, and then he's wrong about it all. +/datum/weird_route/proc/fail() + stop_writing() + blur_box(0, 0) + player.hud_used?.plane_master_controllers[PLANE_MASTERS_GAME]?.remove_filter(list("weird_route_blur", "weird_route_soften")) + whiteout.alpha = 255 + whiteout.color = COLOR_WHITE + animate(whiteout, color = COLOR_BLACK, time = 60 * WEIRD_ROUTE_FRAME, easing = CUBIC_EASING | EASE_IN | EASE_OUT) + WEIRD_ROUTE_WAIT(90) + stop_sounds() + // Up over the black, not in the box. + writing.screen_loc = "WEST:40,NORTH:-170" + writing.maptext_x = 0 + writing.maptext_y = -92 + writing.maptext_width = 560 + writing.layer = 6 + writing.plane = ABOVE_HUD_PLANE + write(list( + "* I was wrong./", + "* The dream never happened./", + "* Kris hadn't changed./", + "* I hadn't changed./", + "* I had simply^2&gotten carried away./%", + ), "snd_txtnoe.wav", FALSE, 2, null, FALSE) + qdel(src) + +/** + * They made it all the way in: the game's ending (obj_ch5_LW20W_end). Black, a sound looping, then + * "INSERT CHAPTER 7" a letter at a time; a pause; and where the game spells out " SIDE B", slower, + * this spells out where they're really going. Then the Meat Factory, rather than the game restarting. + */ +/datum/weird_route/proc/insert_chapter() + stop_writing() + whiteness.alpha = 0 + whiteout.alpha = 0 + blackout.alpha = 255 + blackout.plane = ABOVE_HUD_PLANE + player.hud_used?.plane_master_controllers[PLANE_MASTERS_GAME]?.remove_filter(list("weird_route_blur", "weird_route_soften")) + play_sound("snd_next.wav", 100, 0, music_channel, TRUE) + // The letters, white, with red and blue ghosts of them either side (the game's aberration shader). + for(var/ghost in list("#ff3030", "#30a0ff", COLOR_WHITE)) + var/atom/movable/screen/weird_route/text/letters = new(null, null, src) + letters.screen_loc = "CENTER-7:16,NORTH-3" + letters.plane = ABOVE_HUD_PLANE + letters.layer = 10 + letters.maptext_x = 0 + letters.maptext_width = 448 + letters.maptext_height = 40 + if(ghost != COLOR_WHITE) + letters.blend_mode = BLEND_ADD + letters.pixel_x = ghost == "#ff3030" ? -1 : 1 + // Swimming in and out every four seconds. + animate(letters, alpha = 0, time = 20, loop = -1, easing = SINE_EASING) + animate(alpha = 255, time = 20, easing = SINE_EASING) + letters.color = ghost + chapter_letters += letters + player.client?.screen += letters + var/shown = "" + var/list/reveal_at = list() + // "INSERT CHAPTER 7": a letter every third of a second. + for(var/i in 0 to 15) + reveal_at += round((i + 1) / 3 * 30) + // Then, four seconds on, slower. + for(var/i in 15 to 15 + length(WEIRD_ROUTE_DESTINATION) - 1) + reveal_at += round((i / 3 * 1.4 + 4) * 30) + var/full = "INSERT CHAPTER 7" + WEIRD_ROUTE_DESTINATION + WEIRD_ROUTE_WAIT(151) + var/timer = 0 + for(var/index in 1 to length(full)) + WEIRD_ROUTE_WAIT(reveal_at[index] - timer) + timer = reveal_at[index] + shown = copytext(full, 1, index + 1) + for(var/atom/movable/screen/weird_route/text/letters as anything in chapter_letters) + letters.maptext = "[html_encode(copytext(shown, 1, 17))]
[html_encode(copytext(shown, 17))]
" + // Ten seconds of it, then ten more, or until they press something. + WEIRD_ROUTE_WAIT(300) + confirm_pressed = FALSE + var/until = world.time + 10 SECONDS + while(!confirm_pressed && world.time < until) + sleep(world.tick_lag) + if(QDELETED(src)) + return + stop_sounds() + for(var/atom/movable/screen/part as anything in chapter_letters) + player.client?.screen -= part + qdel(part) + chapter_letters.Cut() + blackout.plane = FULLSCREEN_PLANE + whiteout.color = COLOR_WHITE + whiteout.alpha = 255 + blackout.alpha = 0 + meat_factory() + +/// They come out in the Meat Factory. +/datum/weird_route/proc/meat_factory() + sun_whitens(0) + player.hud_used?.plane_master_controllers[PLANE_MASTERS_GAME]?.remove_filter(list("weird_route_blur", "weird_route_soften")) + blur_box(0, 0) + var/turf/factory_origin = factory.bottom_left_turfs[1] + var/turf/arrival = locate(factory_origin.x + round(WEIRD_ROUTE_FACTORY_WIDTH / 2), factory_origin.y + round(WEIRD_ROUTE_FACTORY_HEIGHT / 2), factory_origin.z) + for(var/mob/living/carbon/human/who as anything in list(player, moffer)) + who.remove_filter("weird_route_sink") + // Out of the sunset: lit by the white. + against_sun(who, FALSE) + who.pixel_w = who.base_pixel_w + who.limb_rig?.set_seated(null) + who.limb_rig?.play(list(list(list(), 1))) + give_shadow(who, EAST, "#9a9a9a") + player.forceMove(arrival) + moffer.forceMove(get_step(arrival, EAST)) + player.setDir(SOUTH) + moffer.setDir(SOUTH) + camera_x = null + player.client?.pixel_x = 0 + whiteness.alpha = 0 + text_shake = 0 + inner_white = 0 + border_colour = COLOR_WHITE + colour_box() + animate(whiteout, alpha = 0, time = 60 * WEIRD_ROUTE_FRAME) + play_sound("meat_factory.ogg", 60, 0, music_channel, TRUE) + // They're free to look around. Nobody comes to get them; an admin can. + player.remove_traits(list(TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED), WEIRD_ROUTE_TRAIT) + UnregisterSignal(player, COMSIG_MOB_KEYDOWN) + if(old_hud_version) + player.hud_used?.show_hud(old_hud_version) + old_hud_version = null + // Moffer comes along, a step behind them wherever they go. + moffer.remove_traits(list(TRAIT_IMMOBILIZED, TRAIT_HANDS_BLOCKED), WEIRD_ROUTE_TRAIT) + walk_to(moffer, player, 1, 3) diff --git a/voidcrew/modules/weird_route/screen.dm b/voidcrew/modules/weird_route/screen.dm new file mode 100644 index 00000000000..5dbd4a322b6 --- /dev/null +++ b/voidcrew/modules/weird_route/screen.dm @@ -0,0 +1,101 @@ +// What the Weird Route puts on its player's screen: the text box, the choice, and what's drawn over +// the world (the black, the white, the pinwheel). + +/atom/movable/screen/weird_route + icon = 'voidcrew/modules/weird_route/icons/weird_route.dmi' + icon_state = null + plane = HUD_PLANE + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + appearance_flags = PIXEL_SCALE | NO_CLIENT_COLOR + /// The route this is for: clicks go to it. + var/datum/weird_route/route + +/atom/movable/screen/weird_route/Initialize(mapload, datum/hud/hud_owner, datum/weird_route/route) + . = ..() + src.route = route + +/atom/movable/screen/weird_route/Destroy() + route = null + return ..() + +/// The box's fill and border, coloured apart (see /datum/weird_route/proc/colour_box()). Clicking it +/// moves the text on, as a confirm press would. +/atom/movable/screen/weird_route/box + icon = 'voidcrew/modules/weird_route/icons/weird_route_box.dmi' + icon_state = "fill" + screen_loc = "CENTER-7:16,SOUTH:6" + layer = 1 + mouse_opacity = MOUSE_OPACITY_OPAQUE + color = COLOR_BLACK + +/atom/movable/screen/weird_route/box/Click(location, control, params) + route?.confirm() + +/atom/movable/screen/weird_route/box/border + icon_state = "border" + layer = 2 + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + color = COLOR_WHITE + +/// Moffer's face, beside his lines: added on, its black let through. +/atom/movable/screen/weird_route/box/portrait + icon_state = "moffer" + layer = 3 + mouse_opacity = MOUSE_OPACITY_TRANSPARENT + color = null + blend_mode = BLEND_ADD + +/atom/movable/screen/weird_route/text + screen_loc = "CENTER-7:16,SOUTH:6" + layer = 4 + maptext_width = 420 + maptext_height = 92 + maptext_x = 18 + maptext_y = 12 + +/// One of the choice's two options. Clicking it chooses it. +/atom/movable/screen/weird_route/text/option + maptext_width = 140 + // Room for a whole line of the font, or BYOND shows none of it. + maptext_height = 40 + maptext_y = WEIRD_ROUTE_OPTION_Y - 26 + mouse_opacity = MOUSE_OPACITY_OPAQUE + /// 1 for the left, 2 for the right. + var/index = 1 + +/atom/movable/screen/weird_route/text/option/Click(location, control, params) + route?.choose(index) + +/// The heart beside whichever option's picked: an ordinary one, out of someone's chest. Placed by +/// /datum/weird_route/proc/update_choices(). +/atom/movable/screen/weird_route/heart + icon = 'icons/obj/medical/organs/organs.dmi' + icon_state = "heart-on" + screen_loc = "CENTER-7:16,SOUTH:6" + layer = 5 + +/atom/movable/screen/weird_route/heart/Initialize(mapload, datum/hud/hud_owner, datum/weird_route/route) + . = ..() + // Its 6x9 pixels as big as the game's heart is in its box. + transform = matrix() * WEIRD_ROUTE_HEART_SCALE + +/// A colour over the whole screen, under the box. +/atom/movable/screen/weird_route/fill + icon_state = "solid" + screen_loc = "WEST,SOUTH to EAST,NORTH" + plane = FULLSCREEN_PLANE + alpha = 0 + +/// White behind the two of them as they go under: over the lake, under them. +/atom/movable/screen/weird_route/fill/behind + plane = GAME_PLANE + layer = MOB_LAYER - 0.2 + +/// Part of the pinwheel behind the monologue (a memory, the wedge it shows through, the silhouette), or +/// one of the ominous clouds before it: over the lake, under the two of them. +/atom/movable/screen/weird_route/pinwheel + icon = 'voidcrew/modules/weird_route/icons/weird_route_pinwheel.dmi' + screen_loc = "CENTER-7,CENTER-7" + plane = GAME_PLANE + layer = MOB_LAYER - 0.3 + alpha = 0 diff --git a/wiki/content/rhythm-battles.md b/wiki/content/rhythm-battles.md new file mode 100644 index 00000000000..0456a234ba9 --- /dev/null +++ b/wiki/content/rhythm-battles.md @@ -0,0 +1,80 @@ +--- +title: Rhythm Battles +category: Getting Started +order: 20 +blurb: Challenge someone to a Friday Night Funkin'-style singing battle with the battle microphone. +--- + +The battle microphone starts a rhythm battle: two singers stand side by side facing you, arrows rise up past them, and you press your movement keys as each arrow meets its outline. Hit notes to push the health bar your way. Miss too many and you lose, loudly. You can take the microphone as a pocket item in your loadout. + +## Picking a song + +Use the microphone in your hand. You choose a week, then a song from it, a difficulty if the song has more than one, and who you're singing against. Weeks are grouped as Friday Night Funkin' has them (Week 1 is Bopeebo, Fresh and Dadbattle, Weekend 1 is Darnell, Lit Up, 2hot and Blazin'). Each song is followed by its mixes where it has them: the Erect remix, and the Pico Mix, where you sing as Pico, gun in your free hand, with his moves and his voice. Songs that aren't in a week are under "Other songs". + +- **Summon someone** starts at once. The song's own opponent appears four tiles to your left to sing the other part, and leaves when the song is over: Daddy Dearest, Mommy Mearest, Pico, Senpai, Tankman and the rest, each with their own moves (Pico and Tankman sing with a gun in the other hand). Songs without a known opponent get an Experiment called Experiment Dearest. +- **I'll pick someone** cues the song up. Hit someone with the microphone to challenge them. A player gets asked whether they want to sing and has 20 seconds to answer. Anyone nobody is playing, conscious and willing or not, gets their part sung for them by the game. + +The singer who used the microphone always takes the right side. The opponent is moved four tiles to your left if there's room (three or five if not), and you turn to face each other. Both singers' cameras swing over to frame the stage, leaning toward whoever the song says is singing. + +## Playing + +While you sing, you're all in: your hands are busy, you can't drop what you're holding or turn away from your rival, your HUD is hidden, and your view zooms in on the stage (alt-click the microphone to turn the zoom off). It all comes back when the song ends. Before the countdown the bar may say Loading... while the song finishes downloading to both singers, so it starts in time with the notes. + +Each singer has four outlines above their head: left, down, up and right. Arrows rise from below and cross the outlines on the beat. Press the matching movement key (WASD or the arrow keys, whatever you move with) as an arrow crosses its outline. You can't walk while singing, and pressing when there's no arrow costs you nothing. + +Long notes have a tail behind the arrow. Hit the arrow, then keep the key held until the tail has drained into the outline. Letting go early drops the note: your combo breaks and you lose a little health. + +Every press is rated on how close it was: + +| Rating | Off by | Points | Health | +|---|---|---|---| +| Sick!! | up to 55 ms | 350 | +2.3 | +| Good | up to 100 ms | 200 | +2 | +| Bad | up to 140 ms | 100 | +0.5 | +| Shit | up to 175 ms | 50 | −0.5 | +| Miss | more, or not at all | −10 | −4 | + +Missing flashes you blue, plays a miss sound only you hear, and cuts your vocals out of the song until you hit the next note. Holding a long note earns points and a trickle of health the whole time. + +Above each singer's arrows are their name, score, misses and accuracy. + +## On stage + +Songs with a girlfriend in them (Girlfriend, or Nene in the Pico mixes) bring her along: she stands a step behind the two of you, bopping to the beat, and throws her arms up with a "Hey!" whenever someone else does. Songs with lyrics show them under the stage as they're sung. + +Stages do what they do in Funkin': lightning strikes the mansion in Week 2, making everyone jump; a train rumbles past in Week 3; Mommy's henchmen dance in a row behind the stage all through Week 4; in Stress, Pico (or Otis, in the Pico mix) sits on the speaker dual-wielding, gunning down the tankmen who run in from both sides; Week 6 opens with its dialogue, typed out under the stage to its music (fetched with the songs); and Darnell opens with Darnell lighting a can and kicking it up for Pico to shoot. Singers stand four tiles apart (next to each other for a fistfight). + +Some moments get acted out: Boyfriend's "hey!" in Bopeebo, Tankman's "ugh" in Ugh, a burp or a laugh in the Pico mixes. Weekend 1 goes further: + +- **2hot**: Darnell lights spray cans and kicks them up into the air in front of Pico, who racks his gun and shoots them down. Miss the shot and the can blows up in Pico's face, taking about a third of the health bar with it. +- **Blazin'** is a fistfight rather than a sing-off, fought close up with no gun, and only your own arrows show. Every note is a punch, block, dodge or uppercut. Hit it and it goes Pico's way; miss a punch and it whiffs, miss a block and Darnell's punch lands. + +## The health bar + +The bar hangs between the singers with each singer's head on it, facing the other and bobbing to the beat. A singer who's losing badly goes pale. Your hits push the split toward the other singer's side, and your misses let it slide back toward yours. + +- **Against a person**, nobody can be run off the bar, and Corruption+ songs don't drain you when the other singer hits a note. The song plays to the end, and whoever has more of the bar then wins, or the better score if the bar is even. +- **Against the game**, its notes never move the bar, so you can't lose to it by sitting still. But if your own misses empty the bar, you're blue-balled all the same. + +## Game over + +Getting run off the bar also gives you, and only you, a game over. The world goes black around you. If you're an Experiment, you're left standing alone, drawn in outline and cowering, and get shot in the head: the head turns red and whips back, and you go over backwards in a spray of blood and lie there twitching. Anyone else dies the way Pico does, to his game over music, with only your own body left lit: usually a knife in the neck and a fountain of blood, with RETRY written in it; if a missed shot in 2hot finishes you, the spray can goes off in your face and leaves you charred, dazed and coughing up smoke, RETRY in pink smoke; in Blazin' a punch to the gut folds you over and drops you in your own blood, RETRY splattered under you. To see any of these without losing a song, alt-click the microphone and pick Preview a game over. Then the game over music plays until you choose. While you're down you can't move or touch anything, and your microphone stays in your hand. + +- **RETRY** (against the game only) starts the same song again. An Experiment makes its last stand first: "Let's not give up just yet." flickers over everything while the world slowly fades back in, then the song starts. +- **GIVE UP** fades everything back and lets you get up. + +If the song finishes first, whoever has more of the bar wins. On an exact tie, the higher score wins. + +## Leaving early + +Passing out, dying, being knocked off your tile or vanishing forfeits the battle. If a player disconnects mid-song, the game sings the rest of their part for them. + +## Results + +Everyone listening gets the results at the end: each singer's score, accuracy, grade, how many of each rating they got, and their best combo. Grades go S+ (every note sick) through S, A, B, C and D down to F. A run with no misses is a Full Combo. It's a Good Full Combo if no note was worse than good, and a Sick Full Combo if every note was sick. + +## Sound and timing + +The song starts for both singers at the same moment after a four-beat countdown. Its sound files are sent to you as soon as the song is picked or you're challenged, so they're ready by the time it starts. Anyone within earshot when the battle starts hears it too, a little quieter. + +Your ping is taken off every press automatically. If you still hit consistently early or late, your speakers or headphones are adding a delay. Alt-click the microphone and pick Audio offset: raise it if you're always late, lower it if you're always early. The song's volume follows your jukebox volume setting.